Skip to content

fix!: an introspection URL carries no credential, and a raw one is redacted (#140), with the release audit's findings - #141

Merged
y1o1 merged 8 commits into
developfrom
fix/140-introspect-url-and-audit
Sep 24, 2026
Merged

y1o1 merged 8 commits into
developfrom
fix/140-introspect-url-and-audit

Conversation

@y1o1

@y1o1 y1o1 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #140. Also carries four findings from the release audit of v0.6.0..2ba68c0 (#95). One commit per item; each fix commit is test-first and its test failed against its parent.

#140 — a credential in INTROSPECT_URL reached the log

Severity, corrected (see the correction on #140): fetch quotes the configured URL as given in both of its refusals, not only when the URL fails to parse. On develop, an INTROSPECT_URL whose password contains a space or an @ — a perfectly parseable URL — wrote that password into the validation path's log on every request. One misconfiguration was enough.

commit what
fix(config) auth.validation.introspect.url must be an absolute http(s) URL without userinfo, refused at boot with a fixed message that never quotes the value.
fix(logger) redactUrlCredentials takes everything up to the last @ before the next / or end of line, so a raw password with a space, @, ? or # is redacted whole. A / inside a raw password remains a stated limit, as before.

The config commit is the real fix: no credential-bearing introspection URL reaches fetch any more. The logger commit keeps the helper sound for any other raw URL that reaches a message.

Release-audit findings

commit what
fix(logger) The error allowlist covers err as well as error. shutdown.mts logs under err, where pino's own serializer copied every enumerable property.
fix SingleFlight.run: a fetcher that throws synchronously no longer pins its rejection under the key. Unreachable from the bundled callers (all async); the fetcher still starts before run yields.
docs src/README.md: the log vocabulary is shared, but the error field is an object in validation and a string in injection.
docs src/README.md: one timeout is logged at info — a session 401 whose body read times out.
docs A failure while reading a 200 body — timeout or dropped connection — is provider_invalid_response, not provider_unavailable: README.md, README.ja.md, src/modes/injection/README.md and the jwt-bearer client's header.

For the cut — breaking

The config commit is marked breaking (fix(config)!, with a BREAKING CHANGE: footer) on the release owner's decision. A deployment that booted with an INTROSPECT_URL carrying userinfo, a string that is not a URL, or a non-http(s) scheme will no longer boot. Such a deployment served only requests with no Authorization and failed every other — and a data: URL was worse than failing: fetch answers a POST to one with its encoded body, so data:application/json,{"active":true} admitted every token.

Operator action: set INTROSPECT_URL to the endpoint's http(s) URL with no credential in it, and configure CLIENT_ID / CLIENT_SECRET if the introspection client should authenticate.

This brings the proxy cut's breaking list to ten: F7, F8, F29, F38, F42, F43, F47, F30 and #139 (the last two carry the footer without !), and this one.

Suggested CHANGELOG lines: Changed — BREAKING — the boot-time refusal above. Security — the #140 leak with the corrected severity, the data: case, and the err allowlist. Fixed — the single-flight pinning. The three docs commits need no entry.

None of the other six commits is breaking: the package is private, so SingleFlight and serializeLoggedError are not public API, and the shutdown line reachable today serializes exactly as before.

Checks

  • Gates: lint / typecheck / build / test all exit 0, 693 tests.
  • Reviewed by a Claude reviewer and by Codex, twice each, and re-verified finding by finding after the fixes. The first review found that an earlier version of the redaction regressed on ? and #, that the severity above was understated, and that the data: case had been described wrongly; all were folded into the commits they concern.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 24, 2026 01:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Security-sensitive configuration and logging changes warrant final human review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

Hardens introspection URL validation and credential redaction, fixes synchronous single-flight failures, and updates provider behavior documentation.

Changes:

  • Rejects unsafe introspection URLs at startup.
  • Improves URL redaction and error-field allowlisting.
  • Fixes single-flight cleanup and updates tests/documentation.
  • Minor grammar nit remains in a test comment.
File Description
src/​single-flight.mts Handles synchronous fetcher failures.
src/​README.md Documents logging and timeout behavior.
src/​modes/​injection/​README.md Documents response-read failures.
src/​modes/​injection/​jwt-bearer-client.mts Updates response classification documentation.
src/​logger.mts Improves error serialization and URL redaction.
src/​__tests__/​single-flight.test.mts Tests synchronous failure cleanup.
src/​__tests__/​logger.test.mts Tests redaction and error allowlisting.
src/​__tests__/​config.test.mts Tests URL validation.
README.md Updates configuration and provider documentation.
README.ja.md Mirrors documentation updates.
config/​application.schema.mts Validates introspection URLs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/__tests__/logger.test.mts Outdated
y1o1 and others added 7 commits September 24, 2026 11:07
… checked at boot (#140)

`auth.validation.introspect.url` was a bare `z.string()`, and two kinds of
value passed it that could never introspect a token, and leaked:

- A URL with userinfo. `fetch` refuses to build a request from one, on every
  call ("Request cannot be constructed from a URL that includes
  credentials"), and the refusal quotes the URL exactly as configured — not
  normalised, so a space or an `@` in the password stays as written. The
  validation path logs that message. The logger's URL redaction did not
  recognise a raw password with a space or an interior `@`, so such a
  password reached the log on every request. That takes one misconfiguration,
  not two: the URL need not be unparseable.
- A string that is not a URL, which `fetch` fails to parse, with the same
  raw quote in its message.

The URL must now be an absolute http(s) URL without userinfo, as
`auth.injection.providerOrigin` always had to be; anything else stops the
process at boot, naming the key. The check
is a refine rather than `.url()`, so the message is a fixed one and never
quotes the value it refused; the tests pin both that it names the key and
that it does not carry the credential.

**What changes for an operator:** a deployment that booted with a URL with
userinfo, or a string that is not a URL, served only what validation passes
through without introspecting — requests with no `Authorization` — and failed
every other. It now does not boot. A non-http(s) scheme is refused too, and
one of them was worse than failing: `fetch` answers a POST to a `data:` URL
with the body it encodes, so `data:application/json,{"active":true}` admitted
every token. A deployment configured that way stops at boot as well.
Because a deployment that booted before may not boot after, this is marked
breaking. The URL carries no credential in any configuration that works: the
introspection client authenticates with `auth.validation.client`, or presents
the inbound token when that is unset.

The tests failed before the schema change: every refusal was accepted.

BREAKING CHANGE: a deployment whose INTROSPECT_URL (auth.validation.introspect.url) carries userinfo, is not a URL, or uses a scheme other than http(s) now stops at boot, naming the key, where it used to boot. Such a deployment served only requests with no Authorization and failed every other one — or, with a data: URL, admitted every token. Set INTROSPECT_URL to the endpoint's http(s) URL with no credential in it, and configure CLIENT_ID / CLIENT_SECRET if the introspection client should authenticate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…@` in it (#140)

`redactUrlCredentials` matched `[^\s/@]*@` after the scheme. That is right
for a URL in its WHATWG spelling, where a space is `%20` and an interior `@`
is `%40`, and wrong for the spelling error messages actually carry: `fetch`
quotes a URL as it was given, both when it refuses one with credentials and
when it fails to parse one. A space in the password stopped the match before
the `@`, so nothing was redacted; an interior `@` ended it early and left the
rest of the password in the log. The first holds for a perfectly parseable
URL: `https://proxy:my pw@auth.test/x` was logged whole on every request.

It now takes everything after `://` up to the last `@` before the next `/` or
the end of the line. Userinfo may carry a space, an `@`, a `?` or a `#`
unencoded and is redacted whole; the match never reaches past a line of a
stack, and a URL with a path keeps a query or fragment `@` intact.

Known limit, stated in the helper: a `/` inside a raw password cannot be told
from the start of a path, and what follows it is not redacted — as before.
The configuration change before this one keeps any credential-bearing
introspection URL out of the process; this keeps the helper sound for any
other raw URL that reaches a message. It errs towards redacting after a bare
origin with no path, where an `@` later on the line takes the text before it.

Three new cases failed before the change: a raw password with a space
(parseable and not) and one with an interior `@`. The `?` and `#` cases were
redacted by the old pattern and are pinned so this change keeps them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
F48's allowlist was installed as the serialiser for the `error` key only.
`shutdown.mts` logs its failures under `err`, where pino's own serialiser
applied — and that one copies every enumerable property, which is the
behaviour the allowlist exists to prevent (undici's `HTTPParserError.data`
is the provider's unparsed response). Nothing on the shutdown path carries
a provider body today, so no line leaked; but the guarantee depended on
which name a log line chose, and nothing stopped a future provider-path line
from choosing `err`.

Both keys now go through `serializeLoggedError`. For the one shutdown line
reachable today (a failed `server.close`, no cause) the output is the same
`{ type, message, stack, code }` pino produced. What pino's `err` serialiser
kept and the allowlist drops: `aggregateErrors` of an `AggregateError`, and
the concatenated cause messages — neither of which the proxy produces. The logger header and
`src/README.md` say which keys are covered, and that only an `Error`
instance is recognised.

The new test failed before the change: a `data` field on an Error logged
under `err` reached the line.

Found by the release audit of `v0.6.0..2ba68c0`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`run` started the leader's fetcher inside an async wrapper whose `finally`
cleared the key. A fetcher that threw before returning a promise made the
wrapper reject synchronously, so `finally` ran — and deleted nothing — before
`pending.set` stored the already-rejected promise. The key stayed pinned to
that rejection: every later call for it got the old error without running
its own fetcher, until the process restarted.

Unreachable from the bundled callers, which all pass `async` functions and so
cannot throw synchronously. But `SingleFlight` has been shared by both modes
since F5/F23, and a supplied fetcher is anyone's; the header said the slot is
cleared "settled either way", which was not true for this case.

The fetcher is still started within `run`, before it yields, and a
synchronous throw now becomes the flight's rejection; `finally` hangs off the
promise the waiters await, so the key is cleared before any of them resumes.
A test pins the synchronous start, so the fix did not trade one ordering for
another.

The new test failed before the change: the entry was still there after the
throw.

Found by the release audit of `v0.6.0..2ba68c0`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`src/README.md` said both modes share one log vocabulary. They share
`requestId` and a per-mode `event` (#134), but not the shape of `error`:
validation logs the Error itself, which the allowlist serialises into an
object with `message`, `stack` and `cause`; injection logs a string —
`err.message`, or `String(err)` for a throw it could not classify
(`src/modes/injection/decision.mts`, `exchange.mts`). A query on
`error.message` therefore matches validation lines only. The paragraph now
says so.

Found by the release audit of `v0.6.0..2ba68c0`: #134 listed this
difference and was closed with it still in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`src/README.md` listed "a timeout" among the proxy's own failures, all
logged at `error`. On the session grant a provider `401`'s body is read,
bounded, to tell `invalid_client` from an expired session (#95 F47). A
timeout during that read leaves `data` null, which falls through to the
expired session: `401 session_required`, logged
`injection.session_unauthorized` at `info`
(`src/modes/injection/session-grant-client.mts`, and the level table in
`src/modes/injection/decision.mts`). `session-grant-client.mts` already
documents the residual; the directory README now does too, so an operator
alerting on error-level timeouts knows this one is not among them.

Found by the release audit of `v0.6.0..2ba68c0`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…available one

The exchange's error table, the injection README and the jwt-bearer
client's header all listed
"network error" and "timeout" under `provider_unavailable` without
qualification. That holds before the response arrives, where `fetch`
rejects. Once a `200` has arrived its body is read through
`readBoundedJsonObject` (`src/response-body.mts`), which answers `null` for
a body it cannot read — an aborted read or a dropped connection alike — and
the jwt-bearer client answers a `null` 200 body as
`provider_invalid_response` (`src/modes/injection/jwt-bearer-client.mts`).

The two rows in `README.md` and `README.ja.md`, the injection directory's
README (`src/modes/injection/README.md`), and the error-code list in the
client's header now say which failure is which. The header follows the
wording `session-grant-client.mts` already had; per AGENTS.md the file's
header is the source of truth, so it had to change with the READMEs.

Found by the release audit of `v0.6.0..2ba68c0`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@y1o1
y1o1 force-pushed the fix/140-introspect-url-and-audit branch from 3441018 to 9e075e4 Compare September 24, 2026 02:07
@y1o1 y1o1 changed the title fix: an introspection URL carries no credential, and a raw one is redacted (#140), with the release audit's findings fix!: an introspection URL carries no credential, and a raw one is redacted (#140), with the release audit's findings Sep 24, 2026
Copilot on #141: "both names an Error is logged under" read as incomplete.
It now says "both keys under which an Error is logged". Comment only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@y1o1

y1o1 commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

On the Copilot overview ("Needs a closer look — security-sensitive configuration and logging changes warrant final human review"): not a code finding, and nothing in this branch answers it. It is a request for the owner's review before merge, which is where it is left. Its one finding (the test comment) is fixed in 4e01f76. For that review, the parts that carry the risk are the boot-time refusal in config/application.schema.mts (marked breaking) and the redaction pattern in src/logger.mts; the corrected severity of #140 is in its correction comment.

@y1o1
y1o1 merged commit f42f042 into develop Sep 24, 2026
1 check passed
@y1o1
y1o1 deleted the fix/140-introspect-url-and-audit branch September 24, 2026 03:21
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.

logger: URL credentials survive redaction when the configured introspection URL does not parse

2 participants