Skip to content

fix(secrets): Close credential leaks in request text and recording URLs - #54

Merged
dcramer merged 3 commits into
mainfrom
fix/redaction-gaps-3
Nov 5, 2026
Merged

dcramer merged 3 commits into
mainfrom
fix/redaction-gaps-3

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Closes credential leaks that a probe audit found in sample text, miss text and the recording request.url. Every leak below also happens on main. Recording keys don't change: request-key.ts has no diff, and the pinned key tests pass.

What changes in secrets.ts

  • Fail closed on more bodies. These now withhold the sample instead of keeping the credential:
    • JSON or XML sent with a form content type
    • a top-level JSON string
    • an escaped key such as pass\u0077ord in invalid JSON
    • a bare ghp_… or JWT in text/plain or invalid JSON
    • URL-escaped JSON inside a form value
    • a % that can't be decoded, which no longer turns off the escape check
  • Nested form names. password[], user[password][0] and user%5Bpassword%5D now match on their last bracket part that isn't empty and isn't a number. Every part of a form name, credential fields included, is checked for a known value or a token prefix. A non-credential field whose value may show a credential, including a bare token, withholds the sample. grant_type=password and refresh_token keep their text.
  • Array, object and number credentials. Each string inside the value is redacted in place, and copies of it elsewhere in the body and in ?q= are redacted too. A number anywhere in a credential value withholds the sample, and a copy of the number in the recording URL is redacted.
  • JWTs. A linear scanner replaces the JWT regex. It catches a JWT after _, - or an alphanumeric character, and a JWT with - in its header.
  • Field-kind cache. The cache clears when it is full, so a large first body no longer leaves later bodies without a cache.
  • Recording URL. recorder.ts now redacts a copy of a body credential in the recording's request.url.

Trade-offs

  • These bodies now withhold the sample, so they lose debug text only:
    • RFC 7523 and RFC 8693 *_type OAuth bodies
    • a base64url run followed by .eyJ
  • Tool schemas with a password property show "type":"<<redacted>>" in samples.
  • The worst adversarial 16 MiB form takes about 2.8 s to redact against the 9 s key time limit. It stays linear and fails closed.
  • Copies of a credential in free text, and in escaped or percent-encoded forms, are still a documented gap. The README says so.

Verification

  • pnpm check passes on Node 24: 114 tests, lint clean, knip clean.
  • I reverted each fix on its own, and a test in redaction.test.ts failed each time.
  • An independent reviewer did two rounds of adversarial probes through the real sample path: keyRequest, then redactRequestWith / redactRequestUrl. The final verdict was approve, with no must-fix items.
  • The remaining nits are adversarial only:
    • a form name with a % that can't be decoded
    • a known value split across bracket parts
    • a percent-encoded . or J inside a JWT

refactor/service-split (move-only) is stacked on this branch.

via @dcramer.

--

View Session [Sentry]

sentry-junior Bot and others added 3 commits November 5, 2026 17:20
…osed on more bodies

Each of these showed a password, a GitHub token, or a JWT in the text
of a request sample and a miss, as a probe of `redactRequest()` on main
shows:

- A form field with a nested name, such as `user[password]` or
  `user%5Bpassword%5D`. The form fields now compare by the last part of
  the name.
- A JSON credential field whose value is an array or an object, such as
  `{"api_key":["..."]}`. Each string in the value is now replaced at
  its place. A number stays, so a tool schema with a `password`
  property keeps its shape. A credential field with a number gives no
  text.
- A body whose fields the proxy cannot find: JSON or XML with a form
  content type, a JSON string, `pass\u0077ord` in JSON that is not
  valid, and a bare `token` with a credential prefix in a body that is
  not JSON. The fail-closed check now covers forms without a credential
  field and JSON scalars, reads names with unicode and URL escapes, and
  looks for the credential prefixes of `TOKEN_VALUE`.

The URL of a recording now also redacts a parameter that holds the
value of a credential field of the body, as the sample already did. It
reads the body only when the URL has a query or a fragment.

`fieldKind()` starts its map again when it is full. Before, the names
of one body could fill it for all later runs: 1 M form fields after
another body with 5,000 names took 850 ms on main and 576 ms now.

No key changes: `request-key.ts` and the redaction of responses do not
change. A copy of a field value in other text, such as in a prompt or
an escaped tool input, still shows. The README says so.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
…equest text

Review of the first commit found three partial fixes:

- A string in an object or array credential value was redacted in place,
  but its copies at other places of the body and in the recording URL
  stayed. Each such string is now a known value of the body.
- A number inside an object or array credential value stayed, such as
  the bytes of a Buffer. Any number in the value now withholds the
  sample, as a number at the top does.
- `password[]` and `user[password][0]` were not credential fields, and a
  form with another credential field passed `user.password=` and JSON
  with URL escapes through. The form name is now the last part that is
  not empty and not a number, and a part that can still show a
  credential withholds the sample. A value that is only a field name,
  such as `grant_type=password`, does not count.

Also: one bad `%` no longer turns off the URL escape check, and a JWT
with `-` in its header counts in text. Both stay linear on 16 MiB
bodies. Recording keys do not change.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
…ic copies

Re-review of the second commit found:

- A form field named `token` without a credential prefix skipped the
  check for a credential in its value, so `token=%7B%22password…` kept
  its value when the form had another credential field. Only a field of
  `CREDENTIAL_FIELDS` now skips that check.
- A credential value or a JWT in a part of a form name that `formName`
  does not keep, such as `S[password]=`, stayed. Each part of the name
  of each field is now checked.
- A JWT right after `_`, a letter, a digit, or `-` was not found in
  text. `showsToken` now finds a run of base64url characters with `eyJ`
  in it before `.eyJ`, and it reads each run a few times at most.
- A number of a credential value that the query copies stayed in the
  recording URL. The numbers are now known values of the body.

Recording keys do not change.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
@sentry-junior
sentry-junior Bot added this pull request to stack #56 November 5, 2026 17:29
@dcramer
dcramer marked this pull request as ready for review November 5, 2026 17:40
@dcramer
dcramer merged commit 1d34752 into main Nov 5, 2026
18 of 19 checks passed
@dcramer
dcramer deleted the fix/redaction-gaps-3 branch November 5, 2026 17:40
dcramer added a commit that referenced this pull request Nov 5, 2026
…, and the JSON scan into files (#55)

Stacked on #54. Merge that first, then change the base to `main`.

This PR only moves code. It moves code out of `server.ts`, `service.ts`,
`recorder.ts` and `secrets.ts` into 5 new files:
- `bodies.ts`
- `clients.ts`
- `run-config.ts`
- `recording-file.ts`
- `json-strings.ts`

A compare of the code lines before and after shows only
`export`/`import` changes and oxfmt line wraps. `git range-diff` gives
`=` against the version that was checked before the last rebase.

It also updates docs:
- the README "Files" map
- the README text on idle tunnels: Node closes an idle tunnel between
requests after about 5–6 s (`keepAliveTimeout`), not 10 min
- the TELEMETRY table: `roach.mode` can be `off`, and `roach.reason` is
on the metric too

**Verification:**
- `pnpm check` passes on Node 24: 114 tests, lint clean, knip clean.
- `request-key.ts` has no changes, and the pinned key tests pass.

<!-- junior-request-attribution:start -->
via @dcramer.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC0ACFA5JBDX%3A1793830221.324719 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/slack%3AC0ACFA5JBDX%3A1793830221.324719)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC0ACFA5JBDX%3A1793830221.324719/?project=4510944073809921)

<!-- junior-session-footer:end -->
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.

1 participant