Skip to content

Close the two residual MJ-001 reflection paths in the hosted doctor - #4884

Merged
chelojimenez merged 4 commits into
mainfrom
soc2/mj001-redaction-gaps
Sep 12, 2026
Merged

chelojimenez merged 4 commits into
mainfrom
soc2/mj001-redaction-gaps

Conversation

@olartgabo

@olartgabo olartgabo commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

#4783 pinned the hosted transport so a private target cannot be reached, and added redactHostedDoctorTransportDetail so the failed attempt cannot describe what it found. Several paths through that redaction still carried the open-versus-closed differential the finding calls Scenario B. This closes them. It does not close MJ-001: that needs a deployed environment and a pentester retest.

What leaked

probe.oauth.discoveryError. The field was not in the redaction's list, so in hosted mode it reached the caller verbatim. It reports the RFC 9728 protected-resource-metadata fetch, and the host that fetch dials comes from the resource_metadata parameter of the target's own WWW-Authenticate challenge — a second origin, chosen separately from the server URL. So connect ECONNREFUSED 10.0.0.5:6379 against a closed port and a TLS record error against an open one were still distinguishable, for an address the attacker selects on the second hop. server/services/bench-probe-child.ts:215 copies the same string onto a user-visible check detail, so it surfaces in the checklist as well as the raw envelope.

Sibling attempts on a mixed run. The gate was attempts.some((attempt) => attempt?.response !== undefined) followed by an early return. One attempt with a response meant nothing in the envelope was rewritten, including attempts that never got past the socket. A target whose first transport answers and whose second is refused against a different port or host leaked the second attempt's message.

error.code, beside the message that was already being rewritten. normalizeServerDoctorError derives the code from the raw message by substring: a refused connect matches econn and becomes SERVER_UNREACHABLE, an open cleartext port's TLS record error matches nothing and becomes INTERNAL_ERROR, a filtered port times out and becomes TIMEOUT. Rewriting only error.message left the same three-way split one key over — and the code was not even modelled on the redactor's envelope type.

The connect leg, exempted by an attempts-only test. The early exit read the probe's attempt array. The doctor's connect leg (sdk/src/http-server-doctor.ts:165) runs after the probe, records no attempt, and writes its raw transport error onto connection.detail, checks.connection.detail and error. So a target that answers the probe cleanly and then redirects the connect elsewhere had that socket outcome reflected verbatim. The docblock called an all-answered run "the one state in which no socket text existed to be summarised", which was false, and the test at hosted-doctor-redaction.test.ts:140 pinned the gap as intended behaviour.

attempts[].durationMs. The same oracle with a stopwatch. sdk/src/server-probe.ts:430 and :448 record it, it ships in transport.attempts[], and it was never redacted: a refused port returns in about a millisecond, a filtered one burns the whole timeout.

Why each survived #4783

The redaction was written against the four fields normalizeServerDoctorError populates plus attempts[].error. OAuth discovery writes its failure somewhere else, and the existing regression test asserted only that oauth.discoveryError was defined after a refused private-address metadata fetch, never what it contained — so the field was exercised and still unexamined.

The docstring's reasoning was already per attempt ("if no probe attempt received a response, nothing HTTP-level happened"); the implementation applied it once to the whole envelope.

error.code and durationMs survived because the fixtures hid them. hosted-doctor-redaction.test.ts hand-wrote three keys per attempt and a hardcoded error.code, and omitted durationMs, request.method, request.headers, response.statusText and the attempt name that a real ProbeHttpAttempt carries. That absence, not the redaction, is what made expect(JSON.stringify(closed)).toBe(JSON.stringify(open)) pass.

The fix

Attempts are judged one at a time on their own response. An attempt that received one reached a host answering as a public server and keeps its diagnostic; an attempt that did not is replaced with the uniform message.

The envelope-level fields — probe.error, connection.detail, checks[].detail, error, and oauth.discoveryError — are summaries that name no attempt, so they cannot be resolved per attempt on a mixed run and may be quoting the refused hop. They pass through only when every recorded attempt received a response and the connect leg did not fail, and a run that recorded no attempt at all is redacted too. That is strictly more redaction than before, never less.

error.code collapses to a single value whenever the message it was derived from is replaced, so the three outcomes are one outcome after redaction.

durationMs: collapsed, not bucketed. A redacted attempt reports 0 — the value server-probe.ts already writes for an attempt it never dialled — and an attempt that received a response keeps its real latency untouched. Quantising or bucketing was the wrong trade here: any bucket wide enough to hide a 1 ms refusal from a 10 s timeout has thrown the number away anyway, and a coarse bucket still separates the two outcomes at the boundary that matters. Collapsing costs nothing real, because the timing of an attempt that never got past the socket is not a diagnostic about the user's server — it is a stopwatch on whoever's firewall was in the way. The answered case, which is the only one where latency tells the user something, is unchanged.

isEgressRefusalDetail is unchanged and still applies to the new fields, so the guard's own verdict keeps its wording — it names the hostname the target chose and never the address it resolved to. The HOSTED_MODE short-circuit is unchanged: locally the socket error is the answer.

What the tests prove

Both fixtures in hosted-doctor-redaction.test.ts are rebuilt on a real ProbeHttpAttempt, with the error derived through normalizeServerDoctorError(new Error(socketError)) rather than hand-written, and with the caller supplying the attempt duration. The closed-port and open-port envelopes now genuinely differ before redaction — in the message, the derived code, and the duration — so the indistinguishability assertion is a test of the redaction instead of a test of the fixture. Each of the three source fixes was reverted individually and the suite watched go red, then restored.

  • A socket-failure envelope built from a closed port and one built from an open cleartext port serialize identically, and neither serialization contains ECONNREFUSED, tls_get_more_records, 127.0.0.1 or 6379. Same for the OAuth-discovery pair.
  • Three raw messages produce SERVER_UNREACHABLE, INTERNAL_ERROR and TIMEOUT before redaction and a single code after it.
  • A run where every probe attempt answered and the connect leg then failed is indistinguishable between the two socket outcomes, while the answered attempt's 200 response survives.
  • A run where every attempt answered and nothing dialled afterwards still passes its detail and its real durationMs through.
  • On an envelope where attempt 0 has a response and attempt 1 does not, attempt 0 keeps its detail and its duration, and attempt 1 loses both.
  • An egress refusal keeps its own wording, and nothing changes outside hosted mode.

In hosted-doctor-probe-egress.test.ts, the two hosted refusal cases assert what discoveryError actually says — the guard's verdict, with no resolved address in it — instead of only that it exists. Both drive the real probe through the real guard.

What this does not cover

Named so the next reader does not mistake the scope. None of these is addressed here:

  • The doctor echoes the stored bearer token back in attempts[].request.headers (sdk/src/server-probe.ts:501).
  • Only Authorization is stripped before dialing target-named metadata hosts, so a stored X-Api-Key egresses (sdk/src/server-probe.ts:563).
  • The bench-probe-child path copies redacted-envelope-adjacent strings onto check details on its own route; it is not run through this redactor.
  • isEgressRefusalDetail is still a prose allowlist — a reworded refusal degrades to the uniform message rather than leaking, but the coupling to two message spellings remains.

And, again: this PR does not close MJ-001. Closure needs the finding ID, owner, SLA mapping, the fix deployed to a named environment at a named version on a named date, an approver, and a pentester retest. None of that is claimed here.

Verification

Run from the repo root on the merge with main (git merge origin/main reported "Already up to date"), with a real rg on PATH so the ! rg guards are not vacuous:

  • npx tsc --noEmit -p mcpjam-inspector/server/tsconfig.json — 294 errors, all pre-existing; the same command on this branch with the diff reverted gives the identical 294, and none of them are in the two files touched here. (Nothing in CI typechecks server/, and the count depends on whether the generated bundles under server/services/** have been built: without them it is 301, seven unresolved-module errors higher, on both trees.)
  • npm run docs:check-tokens — exit 0
  • npm run typecheck — exit 0
  • npm run typecheck:client -w @mcpjam/inspector — exit 0
  • npm run test:checks — exit 0
  • mcpjam-inspector/server/utils/__tests__/hosted-doctor-redaction.test.ts — Test Files 1 passed, Tests 11 passed. With fix 1 (error.code) reverted: 4 failed. With fix 2 (connect leg) reverted: 1 failed. With fix 3 (durationMs) reverted: 3 failed.
  • hosted-doctor-probe-egress.test.ts — 3 passed; servers-doctor-egress.test.ts and hosted-mcp-base-fetch.test.ts — 14 passed.
  • npm run test -w @mcpjam/inspector — Test Files 26 failed | 2077 passed | 11 skipped (2114). Every failing file is the Windows-local set (utils/harness/local/**, services/browserd/**, services/plugins/**, playwright integration spikes); none of them imports the redactor, whose only other consumer is server/routes/web/servers.ts. CI is green on this branch (all six Inspector Tests N/6 shards) and on main.
  • npm run test:ci:rest — the parallel run kills Node on this machine (Assertion failed: ncrypto::CSPRNG(nullptr, 0) during process init, plus ERR_IPC_CHANNEL_CLOSED in the vitest workers), so it was not usable. @mcpjam/sdk run alone: Test Files 1 failed | 338 passed (339), the one failure an EBUSY: resource busy or locked, rmdir on a Windows temp directory. This diff touches no workspace outside the inspector.
  • npm run build:inspector — exit 0
  • Playwright e2e not run locally: the diff touches no routing, app boot, or the OAuth debugger, and CI runs those in the pinned container.

Summary by cubic

Closes the residual MJ-001 reflection paths in the hosted doctor: oauth.discoveryError was never redacted, and the envelope gate let refused attempts' socket text ride out on a sibling attempt's response.

  • oauth.discoveryError now goes through the same redaction; it reports the RFC 9728 metadata fetch, whose host comes from the target's own WWW-Authenticate challenge, so it was a second open-versus-closed oracle.
  • Attempts are now judged one at a time on their own response; envelope-level fields pass only when every recorded attempt answered and the connect leg did not fail.
  • error.code and attempts[].durationMs carried the same differential and are redacted with the message they belong to.
  • Egress refusals keep their own wording, and nothing changes outside hosted mode.
  • This does not close MJ-001; that needs a deployed environment and pentester retest.

Written for commit f7324b4. Summary will update on new commits.

Review in cubic

…r attempt

MJ-001 left two hosted doctor fields still reflecting a socket outcome.

`probe.oauth.discoveryError` was not in the redaction's field list. It reports
the RFC 9728 metadata fetch, whose host comes from the `resource_metadata`
parameter of the target's own `WWW-Authenticate` challenge — a second origin,
chosen separately from the server URL — so the DNS, socket and TLS text for
that host reached the caller verbatim. `bench-probe-child` copies the same
string onto a user-visible check detail.

The gate was also whole-envelope: one attempt with a response returned early
and rewrote nothing, so a target whose first transport answered and whose
second was refused at the socket leaked the second attempt's message. Attempts
are now judged one at a time on their own `response`. The envelope-level
summaries name no attempt, so they pass through only when every recorded
attempt received a response, and a run with no attempts at all is redacted.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@chelojimenez

chelojimenez commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: a5e8288b-0181-4103-abb1-2898f9449c1a

📥 Commits

Reviewing files that changed from the base of the PR and between 2af40b7 and f7324b4.

📒 Files selected for processing (3)
  • .changeset/hosted-doctor-oauth-discovery-redaction.md
  • mcpjam-inspector/server/utils/__tests__/hosted-doctor-redaction.test.ts
  • mcpjam-inspector/server/utils/hosted-doctor-redaction.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/hosted-doctor-oauth-discovery-redaction.md

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


Walkthrough

Hosted-mode doctor redaction now covers oauth.discoveryError and evaluates transport attempts individually. Answered attempts retain diagnostic details. Unanswered attempts and related envelope fields are redacted. Redacted attempts now expose durationMs as 0, and redacted envelope errors use SERVER_UNREACHABLE. Connect-leg failures also trigger envelope redaction. Local and desktop behavior remains unchanged. Tests cover private-address protection, OAuth discovery errors, mixed attempts, and local-mode passthrough.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f7324

Hosted doctor diagnostics retain answered-attempt details while redacting failed transport and discovery paths in hosted mode. No current merge-blocking risk was identified.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Internal preview

Preview URL: https://mcp-inspector-pr-4884.up.railway.app
Deployed commit: dd740e6
PR head commit: f7324b4
Backend target: staging fallback.
Health: ✅ Convex reachable
Access is employee-only in non-production environments.

The redaction rewrote `error.message` and left `error.code` beside it.
`normalizeServerDoctorError` derives that code from the raw message by
substring, so a refused connect stayed `SERVER_UNREACHABLE`, an open cleartext
port's TLS record error stayed `INTERNAL_ERROR` and a filtered port stayed
`TIMEOUT` — the same three-way split the message had just stopped making. The
code was not even on the redactor's envelope type. It is now, and it collapses
whenever the message it was derived from is replaced.

The early exit read only the probe's attempt array. The doctor's connect leg
runs after the probe, records no attempt, and writes its raw transport error
onto `connection.detail`, `checks.connection.detail` and `error`, so a target
that answered the probe cleanly and then redirected the connect elsewhere had
that socket outcome reflected verbatim. The exit now also requires that the
connect leg did not fail. The docblock claimed an all-answered run was "the one
state in which no socket text existed to be summarised"; it was not, and the
test that pinned the gap as intended behaviour is replaced by one that pins the
connect leg's two outcomes as indistinguishable.

`attempts[].durationMs` was the same oracle with a stopwatch — about a
millisecond for a refused port, the whole timeout for a filtered one — and
shipped unredacted. A redacted attempt now reports 0, the value the probe
already writes for an attempt it never dialled; an attempt that received a
response keeps its real latency, which is a diagnostic rather than a
measurement of somebody's firewall.

The fixtures were the reason `JSON.stringify(closed) === JSON.stringify(open)`
passed: they hand-wrote three keys per attempt and a hardcoded `error.code`, so
every field that still carried the differential was absent from the test. Both
are rebuilt on a real `ProbeHttpAttempt` with the error derived through
`normalizeServerDoctorError`, and the two envelopes now differ in duration
before redaction.
@chelojimenez
chelojimenez merged commit edc7655 into main Sep 12, 2026
26 checks passed
@chelojimenez
chelojimenez deleted the soc2/mj001-redaction-gaps branch September 12, 2026 08:51
L4XB added a commit to L4XB/inspector that referenced this pull request Sep 16, 2026
`buildInitializeRequest` puts the stored access token on the attempt's
`request.headers`, and that same object is what the caller receives inside
`transport.attempts[]`. The MCPJam#4884 redactor rewrites `error.message`,
`connection.detail`, `checks[].detail`, `error.code` and `durationMs`; it
never touched `request.headers`. So a token that never had to leave the
server arrived in a JSON response body, and from there in browser memory,
HAR exports and any support bundle someone pastes it into.

The attempt is both the request to send and the record of it. `performRequest`
now splits the two at the one function that dials: the real headers become a
local, and what stays on the attempt has its credential VALUES replaced. The
names stay — "this attempt carried an Authorization header" is the whole
diagnostic value of the record, and the bearer string beside it carries none.
`probeMcpServer` sweeps once more at its single exit, for attempts recorded
before their request is made.

The predicate is `isSecretKeyName`, exported from `conformance-redaction`
rather than written again here, for the reason that module already gives for
having one list: a second list is a second thing to forget. So
`x-api-key` and a vendor-prefixed `x-vendor-access-token` are covered too,
not just `Authorization`.

Fixes MCPJam#5001

This branch was successfully deployed

1 active deployment
preview-pr-4884 — f7324b4c Deployed Sep 12, 2026 by olartgabo via upsert-preview #19474
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants