Skip to content

fix: v0.20 adapter hardening (#225 to #231) - #234

Merged
Bccorb merged 8 commits into
mainfrom
fix/v020-hardening
Oct 26, 2026
Merged

Bccorb merged 8 commits into
mainfrom
fix/v020-hardening

Conversation

@Bccorb

@Bccorb Bccorb commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

v0.20 hardening for the JS server adapters (core, Express, Fastify, Next.js). One commit per issue.

Fixes

#225 Refuse dot-segment params in hand-written passthroughs

  • Core gains encodePathParam and InvalidPathParamError (400). They refuse empty, . and .. params, sharing the dot-segment rule (isDotSegment) with the manifest matcher.
  • Used by the Express hand-written passthroughs, the Fastify and Next.js resolveUpstreamPath, and the core admin, session, organization switch, magic link verify and OAuth handlers. Next.js maps the error to 400 bad_request; Express and Fastify already did that for a 4xx error.
  • Tests: packages/fastify/tests/dotSegmentParams.test.js sends raw %2e%2e paths over a real socket to both Fastify and Express (light-my-request and supertest resolve dot segments themselves). packages/core/tests/pathParam.test.js covers the helper and the core handlers. Next.js parses every request URL with the WHATWG parser, which already resolves %2e%2e, so it gets the shared core check but no route-level test.

#226 Check the token type in ensureCookies and verifyUpstreamSession

  • ensureCookies now requires the access cookie's inner token to be typ: "access", using the guards' holdsAccessToken (now exported). Otherwise it answers 401 and clears the cookie, as for an invalid one.
  • verifyUpstreamSession checks typ. It takes a new optional expectedType argument (default access). /login and the manifest's pre-auth and registration routes pass ephemeral.
  • Tests: core ensureCookies and upstreamSession cases, plus a Fastify/Next.js parity scenario (an access cookie holding a pre-auth token gets 401 on both). Existing tests that put opaque strings like "access-token" in access cookies now use typed tokens.

#227 Do not follow redirects on upstream requests

  • authFetch sends every upstream request with redirect: "manual", and a 3xx becomes 502 { "error": "upstream_redirect" } with one error log. The log names only the origin, because the path can carry a magic link token. The manifest fetch also uses redirect: "manual" and falls back to the bundled copy.
  • Tests: a real two-server test proves that 301/302/307/308 never reach the redirect target. Also a manifest test, and a parity scenario (upstream 302 gives 502 on both adapters).

#228 Keep the API's framing and content-type headers on the console proxy

  • All three console proxies forward Content-Security-Policy, X-Frame-Options and X-Content-Type-Options. When neither the API nor the application set the last two, the proxy falls back to X-Frame-Options: DENY and X-Content-Type-Options: nosniff.
  • Tests: console parity scenarios in Fastify and Next.js, for headers forwarded and for the fallback.

#229 Least-privilege tokens and pinned actions

  • Every workflow sets top-level permissions: contents: read. Release (contents/pull-requests/id-token: write) and prerelease (id-token: write) keep their job-level grants.
  • actions/checkout, actions/setup-node, pnpm/action-setup and changesets/action are pinned to full commit SHAs, with a version comment. Each stays on its current major.
  • .github/dependabot.yml adds weekly, grouped github-actions updates.
  • Not done here: npm trusted publishing. Each package has to be configured on npmjs.com before NPM_TOKEN can go, so publishing still uses the token and stays working. The reusable conformance workflow stays on fells-code/seamless-cli@main (first-party).

#230 Check Origin against allowedOrigins even when Sec-Fetch-Site is present

  • checkOrigin: cross-site is refused and same-origin passes. With allowedOrigins configured, a state-changing same-site request must now carry an Origin on the list (a missing Origin is refused).
  • Default decision: without allowedOrigins, behavior is unchanged (a same-site request passes). This is documented in the Express, Fastify and Next.js READMEs.
  • Tests: Express origin guard cases and a Fastify/Next.js parity matrix.

#231 Refuse known placeholder secrets at startup

  • assertSecretStrength refuses dev-only-cookie-signing-key-replace-me, GRAB_FROM_SEAMLESS_AUTH_PORTAL_DEV and anything containing replace-me. It takes a new { allowPlaceholder } option. assertSecrets reads cookieSecure, so the Express router, the Fastify plugin and the Next.js handler refuse a placeholder at startup unless cookieSecure: false (the insecure local mode).
  • Per-request and guard checks (ensureCookies, refresh, getSeamlessUser, createServiceToken, requireAuth, the Next.js session helpers) keep the length check only. They cannot tell local mode apart, and the adapter has already refused at startup.
  • Defense in depth: authenticateRequest (behind requireAuth in Express and Fastify) now verifies a cookie's inner access token against JWKS when the guard has authServerUrl and audience, requires a matching sub, and takes the roles from the verified token. Without that configuration there is no JWKS to check against, so it still only decodes. The sync authenticateCookie and the Next.js getSeamlessClaims keep their local-only contract. getSeamlessSession sends the token to the API, which verifies it.
  • Tests: core validateSecrets cases, Express startup cases, Fastify/Next.js parity startup cases, and Express/Fastify guard cases (forged inner token, sub mismatch, roles taken from the token).

Changesets

Patch for #225, #226, #227 and #228. Minor (pre-1.0 signal for a behavior change) for #230 and #231. Each covers core and the adapters it touches. None is major.

Checks (worktree root)

pnpm install --frozen-lockfile   ok
pnpm build                       exit 0
pnpm test                        exit 0
  core     Test Suites: 36 passed  Tests: 462 passed
  express  Test Suites: 33 passed  Tests: 228 passed
  nextjs   Test Suites: 7 passed   Tests: 191 passed
  fastify  Test Suites: 9 passed   Tests: 205 passed

pnpm check:types-current fails on main as well: @seamless-auth/types 0.31.0 is published and the packages pin ^0.29.0. That bump is a separate change, so it is not part of this PR.

Cross-repo notes

Closes #225
Closes #226
Closes #227
Closes #228
Refs #229
Closes #230
Closes #231

encodeURIComponent leaves `.` and `..` unchanged, so a decoded `%2e%2e` route param moved the
upstream path on the hand-written passthroughs. Core now has encodePathParam, which refuses empty
and dot-segment values with InvalidPathParamError (400), shared with the manifest matcher.
Express, Fastify, Next.js and the core admin, session, organization switch, magic link and
OAuth handlers all build upstream paths with it.

Closes #225
…ssion

Every adapter cookie is signed with the same secret, so ensureCookies accepted any signed cookie
with a token string in the access slot. It now requires the inner token to be an access token,
reusing holdsAccessToken from the guards. verifyUpstreamSession checks the verified token's typ:
access for session flows, ephemeral for /login and the manifest's pre-auth and registration
routes. Tests that put opaque strings in access cookies now use typed tokens.

Closes #226
authFetch used fetch's default redirect: "follow", so a redirect from a misconfigured proxy in
front of the API would resend the service token and forwarded client headers to another origin.
Every upstream request now uses redirect: "manual", and a 3xx becomes a 502 upstream_redirect
error. The manifest fetch also refuses to follow a redirect and uses the bundled copy.

Closes #227
…console proxy

The console proxy forwarded only Content-Type, Cache-Control, ETag and Last-Modified, so the
API's X-Frame-Options, Content-Security-Policy and X-Content-Type-Options were dropped and the
admin console served on the app origin could be framed. All three adapters now forward them, and
fall back to X-Frame-Options: DENY and X-Content-Type-Options: nosniff when neither the API nor
the application set them.

Closes #228
… present

A present Sec-Fetch-Site short-circuited the origin guard and same-site was allowed, so
allowedOrigins only applied to browsers that omit the header. With SameSite=None cookies a sibling
subdomain counts as same-site. When allowedOrigins is configured, checkOrigin now also requires a
listed Origin for a state-changing same-site request. same-origin still passes, cross-site is
still refused, and the default without allowedOrigins is unchanged. The READMEs document it.

Closes #230
assertSecretStrength checked only length, so the starters' example cookie secret passed it. It now
refuses the published placeholders and anything containing replace-me. assertSecrets reads
cookieSecure, so the Express, Fastify and Next.js adapters refuse one at startup unless they run
in insecure local mode. Per-request and guard checks keep the length check only, since they cannot
tell local mode apart.

As defense in depth, authenticateRequest verifies a cookie's inner access token against the auth
API's JWKS when the guard has authServerUrl and audience, and takes the roles from that token.

Closes #231
Every workflow now sets a top-level permissions: contents: read, and the release and prerelease
jobs keep their own wider grants for publishing. Actions are pinned to full commit SHAs with a
version comment, including changesets/action in the publishing job, and Dependabot keeps the
github-actions pins current. Publishing still uses NPM_TOKEN: moving to npm trusted publishing
needs each package configured on npmjs.com first.

Refs #229
@Bccorb
Bccorb merged commit 0e554e3 into main Oct 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment