MemberJunction / MemberJunction/MJ
A stack trace reaches an unprivileged caller on two requests Apollo and Express answer before any plugin runs
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
**Found by** the PR gauntlet on MemberJunction/bizapps-forms#132 (`fix/119-respondent-safe-errors`), 2026-09-02. Not caused by that PR: verified pre-existing on `origin/next` at `3daab0eec0d7932f1cf1ab1dcbb1a65dc5fe8d1b`, and unchanged by it.
Target repo: **MemberJunction/MJ** (`@memberjunction/server`). Filed here because the fix cannot live in a downstream app — see *Why it cannot be fixed downstream*.
### Where
- `@memberjunction/server` — `buildApolloServer` never sets `includeStacktraceInErrorResponses`, so Apollo's default governs it (on unless `NODE_ENV` is `production` or `test`).
- `@memberjunction/server` — the Express GraphQL route has no JSON body-parser error handler, so `body-parser`'s throw falls through to Express's default handler.
Observed against `@apollo/server@5.5.1` as resolved by `@memberjunction/server@6.1.0-edge.2`.
### What happens
On a host with `NODE_ENV` unset or set to anything other than `production`/`test`, two requests return server internals to a caller holding nothing but a low-privilege session token:
1. **An empty JSON body** (`{}`) returns Apollo `BAD_REQUEST` **with `extensions.stacktrace`** — Apollo's own frames, absolute filesystem paths, and pinned dependency versions:
```
{"errors":[{"message":"POST body missing, invalid Content-Type, or JSON object has no keys.",
"extensions":{"code":"BAD_REQUEST","stacktrace":["BadRequestError: …", " at … /node_modules/.pnpm/@apollo+server@5.5.1/…"]}}]}
```
2. **A body that is not JSON** returns an **HTML page rendering the stack**, from Express's default error handler:
```html
SyntaxError: Unexpected token 't', "this is not json" is not valid JSON
at JSON.parse (<anonymous>)
at createStrictSyntaxError (…
```Both are clean under `NODE_ENV=production` (verified in the same binary, same requests).
### Why it matters
This was found on a host serving MJ Forms' public `/f/:slug` respondent link, whose entire job is to hand an anonymous session to a stranger on the internet. The caller needs no credentials of their own: the form link mints them. Filesystem layout and pinned dependency versions turn "is this host running a vulnerable graphql or apollo" into a lookup rather than a guess.The exposure is not limited to Forms. Any MJ host with a low-privilege or anonymous caller has it, and it is reachable on any route Apollo serves.
Production is clean today, so this is a defence-in-depth fix rather than an active production leak. But the protection currently rests entirely on one environment variable being set correctly on every deployment, with no failure signal if it is not — a host that boots with `NODE_ENV` unset serves stack traces and looks completely healthy.
### Repro
Any MJ host with `NODE_ENV` unset, and any token the host accepts (an anonymous magic-link session is enough; a normal user session shows the same thing).```bash
# With MJ Forms installed, a public form link mints a suitable token:
T=$(curl -s http://localhost:4000/f/ | grep -o 'data-token="[^"]*"' | cut -d'"' -f2)# 1. Empty JSON body -> Apollo BAD_REQUEST carrying extensions.stacktrace
curl -s -X POST http://localhost:4000/ -H 'content-type: application/json' \
-H "authorization: Bearer $T" -d '{}' | head -c 500# 2. Non-JSON body -> Express HTML error page containing the stack
curl -s -X POST http://localhost:4000/ -H 'content-type: application/json' \
-H "authorization: Bearer $T" -d 'not json' | head -c 500# Both are clean when the same host is started with NODE_ENV=production.
```### Evidence
From the gauntlet run on bizapps-forms#132, same binary, two boots:| Request | `NODE_ENV` unset | `NODE_ENV=production` |
|---|---|---|
| empty JSON body | `extensions.stacktrace`, stack frames, filesystem paths, `pkg@x.y.z` | clean |
| non-JSON body | stack frames, filesystem paths, `pkg@x.y.z` (HTML) | clean |bizapps-forms' own smoke suite reports both as warnings named as MJ core's, deliberately, so its own check stays honest: `pnpm run smoke:errors `, warn lines `a body with no query (bad request)` and `a body that is not JSON (bad request)`.
### Why it cannot be fixed downstream
bizapps-forms#132 added a `BaseServerMiddleware` contributing an Apollo plugin through `GetApolloPlugins()`, whose `willSendResponse` strips `extensions.stacktrace` from every response. It fixes every error the request pipeline produces — parse errors, validation errors, resolver errors — and was verified to do so. It cannot reach these two:- Apollo builds the empty-body `BAD_REQUEST` in `executeHTTPGraphQLRequest`, **before** the request pipeline exists, so no plugin ever sees that response.
- The non-JSON body never reaches Apollo at all; `body-parser` throws first.A downstream package could only intercept these with a host-wide Express error handler, which would change error rendering for every other app on the host from a package none of them depend on. That was considered and correctly declined.
### Suggested fix
Both changes belong in `@memberjunction/server`, in `buildApolloServer` and its route setup, because that is the only place that owns the Apollo instance and the Express route:1. Pass `includeStacktraceInErrorResponses: false` when constructing `ApolloServer`. Prefer unconditional; if a debugging escape hatch is wanted, make it an explicit opt-in config flag that defaults to `false`, so the safe state is the default and does not depend on `NODE_ENV`.
2. Register a JSON error handler on the GraphQL route, after `express.json()`, that turns a body-parser failure into a JSON error response with no stack — matching the shape Apollo returns for other bad requests, so a client sees one error format.### Definition of done
- [ ] A failing test that reproduces it, then green: a request with an empty JSON body and one with a non-JSON body, against a server built with `NODE_ENV` **unset**, asserting no `stacktrace` key, no absolute path and no `pkg@version` string in the response
- [ ] The repro above returns no stack on a host with `NODE_ENV` unset
- [ ] The non-JSON body returns JSON, not HTML
- [ ] Existing suite and gates green; no changed expectation in an existing test### Verify by
Running both `curl` commands from the Repro against a host started with `NODE_ENV` unset: neither response contains `stacktrace`, `node_modules`, an absolute path, or a `pkg@x.y.z` string, and the second returns `content-type: application/json`.
Contributor guide
Assessment
This issue has not been assessed yet.