Skip to content

fix(nextjs)!: fail closed without a verifier secret, refuse a path that is not one - #110

Closed
msalvatti wants to merge 1 commit into
audit5/05-rate-limitfrom
audit5/06-nextjs-edge
Closed

fix(nextjs)!: fail closed without a verifier secret, refuse a path that is not one#110
msalvatti wants to merge 1 commit into
audit5/05-rate-limitfrom
audit5/06-nextjs-edge

Conversation

@msalvatti

Copy link
Copy Markdown
Member

6 of 7 in the fifth audit round. Base: audit5/05-rate-limit (PR 5).

Paired with nest-auth's audit5/05-nextjs-edge (PR #52, merged).

What this closes — BREAKING

The edge helper fell back to a decode when no secret was configured, and the two branches
returned the same shape with the same success flag. A caller reading it — the natural reading
of a function named verify — admitted a token an attacker minted the moment the secret went
missing. An unset environment variable was enough to arrange that. It now refuses, and the
decode-only read stays available under its own correctly-named entry point.

redirectToPath states its same-origin invariant where the header is written. The open
redirect was already closed here — resolving against the placeholder base drops any authority —
but it was closed by silently substituting a different destination for the caller's, reporting
nothing. Of the shapes now refused, two were reachable: a bare relative reference, which the
browser resolves against the current directory, and a control character, which ends the header
and lets what follows be read as another one.

Verification

fmt, clippy -D warnings, the full --all-features suite, and the packages/rust-auth
vitest suite pass at this commit in isolation.

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.

Pull request overview

This PR hardens the Next.js edge auth utilities to fail closed when a verifier secret is missing, and to ensure redirects emitted by the edge proxy never name an origin (mitigating Host-header influence), while adding a runtime-distinguishable signatureVerified signal for authorization decisions.

Changes:

  • Make verifyJwtToken refuse verification when secret is missing/empty and add signatureVerified to distinguish verified vs decode-only results.
  • Update createAuthProxy to gate authentication on signatureVerified and emit relative Location redirects via a new redirectToPath helper.
  • Expand Vitest coverage around missing-secret behavior, signatureVerified, and redirect-path validation.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/rust-auth/tests/nextjs.test.ts Adds regression tests for fail-closed verification, signatureVerified, and relative/validated redirect Locations.
packages/rust-auth/src/nextjs/proxy.ts Switches auth gating to signatureVerified and introduces relative redirect construction + same-origin path reduction.
packages/rust-auth/src/nextjs/jwt.ts Adds signatureVerified, removes verify decode-fallback, and documents/implements fail-closed secret handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/rust-auth/src/nextjs/proxy.ts Outdated
Comment on lines +183 to +187
// Reject an unverified, expired, or non-access token. `signatureVerified` rather than
// `isValid`: the latter is also `true` on `verifyJwtToken`'s decode-only branch, so gating
// on it would admit a forged token the moment the secret went missing. The ternary above
// already refuses to call without a secret; this is the second lock on the same door, and
// it is the one that survives a refactor of the first. Admitting a non-access type —
Comment thread packages/rust-auth/src/nextjs/proxy.ts Outdated
}
return new NextResponse(null, {
status: 307,
headers: { location: `${url.pathname}${url.search}` },
Comment on lines 90 to 92
* Decode a token's header and payload WITHOUT verifying its signature. Never throws: a
* malformed token yields `{ isValid: false }`. The result is non-authoritative — it proves
* the token is well-formed, never that it is genuine — so it must not gate a decision.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

…t is not one

The edge helper fell back to a decode when no secret was configured, and the two
branches returned the same shape with the same success flag — so a caller
reading it, the natural reading of a function named `verify`, admitted a token
an attacker minted the moment the secret went missing. An unset environment
variable was enough to arrange that. It now refuses, and the decode-only read
stays available under its own correctly-named entry point.

`redirectToPath` also states its same-origin invariant explicitly rather than
relying on `URL` resolution to imply it. The open redirect was already closed
here — resolving against our own origin and keeping only the path drops any
authority the input carried — but it closed it by substituting a different
destination for the one the caller asked for, and reported nothing. Of the
shapes now refused, two were reachable before: a bare relative reference, which
the browser resolves against the current directory, and a control character,
which ends the header and lets what follows be read as another one.

The `Location` itself stays absolute, which is a constraint and not a
preference. Next hands the header this middleware sets straight to
`new NextURL(location, ...)` with no base, so a relative `Location` throws
`ERR_INVALID_URL` there and every unauthenticated request 500s. Naming the
request's own origin is safe: Next compares the two hosts immediately
afterwards and rewrites `Location` down to a path whenever they match, which
here they always do. A redirect to a genuinely different origin is the case
Next leaves alone — and the one the path validation above refuses.
@msalvatti

Copy link
Copy Markdown
Member Author

The Location is now absolute, and the browser E2E was reporting a real break.

Next does not send a middleware's response as written. server/web/adapter.js reads the Location back and hands it to new NextURL(location, ...), passing options and no base — so the relative Location this commit introduced has nothing to resolve against and throws ERR_INVALID_URL. The Playwright job's log carried the proof: input: '/login?reason=expired&redirectTo=%2F'. Every request that redirects, which is every unauthenticated one, would 500.

Three lines after that parse, Next compares the redirect's host against the request's and, when they match, rewrites Location down to a path itself. They always match here — both sides come from the same request — so a forged Host reaches the header and not the browser. The property the relative form was introduced to protect is one Next already provides.

What Next does not normalise is a Location on a genuinely different origin, which it leaves absolute and the browser follows. toSameOriginPath stays exactly as reviewed: it is now the only thing standing between a caller and an off-site redirect, rather than a second layer behind the relative form.

The same change has been applied to nest-auth, whose equivalent had already been merged — its unit tests drive a mocked NextRequest and never reach Next's parse, so nothing there caught it.

@msalvatti

Copy link
Copy Markdown
Member Author

Merged. This commit (996e3ef) is on feat/parity-hardening — the stack landed as a fast-forward, so what is on the branch is bit-for-bit the commits CI verified here rather than rebased copies of them. GitHub will not retarget this PR ("no new commits between base and head"), which is itself the confirmation, but it means the record reads Closed rather than Merged.

All review threads on this PR were answered and resolved before it landed.

@msalvatti msalvatti closed this Aug 2, 2026
@msalvatti
msalvatti deleted the audit5/06-nextjs-edge branch August 2, 2026 12:31
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