Skip to content

fix(security): stop a project's auth policy 401ing the control plane's own dispatch - #3645

Merged
kojiwakayama merged 3 commits into
mainfrom
fix/auth-signed-control-plane-dispatch
Aug 12, 2026
Merged

fix(security): stop a project's auth policy 401ing the control plane's own dispatch#3645
kojiwakayama merged 3 commits into
mainfrom
fix/auth-signed-control-plane-dispatch

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Second instance of the bug class #3641 fixed for CSRF: a gate demands a credential that a legitimate caller structurally cannot hold, and is positioned to run before that caller's handler.

The hole

AuthHandler (src/security/http/auth.ts) declares priority: 0 and patterns: []. The registry never consults metadata.patternsregistry.ts calls every handler in priority order — so a handler must self-limit, and AuthHandler.handle had no path check. It ran on every request, ahead of CsrfHandler (priority 5) and ProjectRunExecuteHandler, exempting only OPTIONS and isCspReportRequest.

The platform cannot satisfy it:

  • The control plane sends Authorization: Bearer <platform-minted per-run service JWT> plus the JWS envelope header (veryfront-api runtime-project-run-client.ts:120-158). checkBasicAuth can never match a Bearer header; checkBearerAuth compares it against a secret the project authored.
  • So any project with security.auth — or VERYFRONT_BASIC_USER / VERYFRONT_BEARER_TOKEN — answered its own task:release-asset-build dispatch with 401.
  • 401 is not retryable (the client retries only on >=500 or 429), so the run died before the manifest row existed, the state stayed missing, and veryfront deploy failed at its 120s deadline reporting Release assets were not ready within 120s (last state: missing) — naming neither auth nor config.

Second surface: POST /api/control-plane/agents/list fails the same way and worse — runtime-agent-client.ts:253-269 sends no Authorization header at all, so Studio's agent listing 401s for any project with security.auth set.

The fix

Reuses the predicate #3641 introduced, in AuthHandler.handle alongside the OPTIONS and CSP-report exemptions:

if (isSignedControlPlaneDispatch(req)) return Promise.resolve(this.continue());

isSignedControlPlaneDispatch returns true only when both hold:

  1. isControlPlaneSurfaceRoute matches one of five anchored method/path shapes, and
  2. the request carries x-veryfront-control-plane-jws.

Not a weakening: it exempts nothing that is not authenticated more strongly downstream. Every one of those five surfaces calls verifyControlPlaneRequest, and verifyControlPlaneJws binds the Ed25519 signature to issuer, audience, project id, request method, request path and body hash, with expiry/skew bounds.

Why not a prefix match. /api/control-plane/ is reserved but not exclusively routed. The run handlers register only the prefix and return this.continue() when their anchored regex misses; ApiHandlerWrapper declares no patterns and sits after them, so /api/control-plane/checkout falls through to project code. Anything keyed on startsWith/includes would ship a bypass a project could trigger by choosing a path.

Why ahead of resolveAuth. Same placement as the CSP-report exemption. An unresolvable auth config still fails closed for every browser request, but a project's config typo must not brick the platform's own dispatch, which authenticates itself downstream. A test pins both halves of that with one config.

Tests (written first, confirmed red)

Red, before the production change:

AuthHandler signed control-plane dispatch ...
  passes every registered surface through for every configured auth shape ... FAILED
  passes a registered surface through for env-configured auth ... FAILED
  still challenges a path that merely starts alike ... ok
  still challenges a project route inside the control-plane namespace ... ok
  still challenges a registered surface with no signature header ... ok
  still challenges a registered surface with an empty signature header ... ok
  dispatches a signed surface even when the auth config is unresolvable ... FAILED

error: AssertionError: Values are not equal.
    [ "POST", "/api/control-plane/agents/list", -401 / +"continue" ]
release assets: control-plane build dispatch ...
  builds a manifest when the project puts the site behind basic auth ... FAILED
  builds a manifest when the project puts the site behind bearer auth ... FAILED
  builds a manifest when the project enables both gates at once ... FAILED

error: AssertionError: release asset build never started; runtime answered 401: Unauthorized
    -false / +true
internal-agents-list.handler auth gate ...
  lists agents for a signed dispatch behind basic auth ... FAILED   (-401 / +200)
  lists agents for a signed dispatch behind bearer auth ... FAILED   (-401 / +200)
  still challenges the same surface without the signature header ... ok
  still challenges a project route inside the control-plane namespace ... ok

Coverage:

  • src/security/http/auth.test.ts — all five surfaces pass for basic, bearer and env-configured auth. Adversarial: a look-alike path (/api/control-plane-mirror/...), seven project routes inside the namespace with the signature header attached, every registered surface with no signature header, and a surface with an empty signature header — all still 401.
  • src/release-assets/build-dispatch-security.test.ts — drives the real chain (AuthHandlerCsrfHandlerProjectRunExecuteHandler) with a genuine signed Ed25519 envelope and asserts the release asset build executor is reached, under basic auth, bearer auth, and both gates at once. The four pre-existing CSRF cases are unchanged and still pass with AuthHandler added to the chain.
  • src/server/handlers/request/internal-agents-list.handler.test.ts — real chain (AuthHandlerInternalAgentsListHandler) for the second surface, sending exactly what runtime-agent-client sends (no Authorization header).

No existing test was weakened or deleted.

Verification

  • deno test src/security/ src/release-assets/ src/channels/ src/server/handlers/ (repo test flags): ok | 216 passed (2435 steps) | 0 failed
  • deno task lint:module-boundaries — exit 0; the import mirrors the one csrf-handler.ts already makes. (The "debt decreased by 1" note it prints is pre-existing on clean origin/main.)
  • deno lint / deno fmt --check on the changed files, deno check src/security/http/auth.ts
  • deno task docs regenerated the one shifted line pin in docs/api-reference/veryfront/security.md

Deno 2.7.7 throughout.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Security Improvements

    • Improved handling of verified control-plane requests so authorized dispatches proceed without unnecessary project authentication prompts.
    • Added coverage for Basic and Bearer authentication, CSRF protection, signed requests, and invalid or unregistered routes.
    • Unauthorized, unsigned, or unmatched requests continue to receive appropriate protection.
  • Documentation

    • Corrected the AuthHandler source reference in the API documentation.

…s own dispatch

Second instance of the bug class #3641 fixed for CSRF: a gate demands a
credential a legitimate caller structurally cannot hold, and sits in front of
that caller's handler.

`AuthHandler` runs at priority 0 with `patterns: []`, and the registry never
consults `metadata.patterns` -- it calls every handler in priority order -- so
the gate stands in front of every control-plane surface, ahead of `CsrfHandler`
and `ProjectRunExecuteHandler`. It exempted only OPTIONS and
`isCspReportRequest`.

The platform cannot satisfy it. The control plane sends `Authorization: Bearer
<platform-minted per-run service JWT>` alongside the signed operation envelope:
`checkBasicAuth` can never match a `Bearer` header, and `checkBearerAuth`
compares it against a secret the project authored. So any project that set
`security.auth` -- or `VERYFRONT_BASIC_USER`/`VERYFRONT_BEARER_TOKEN` --
answered its own `task:release-asset-build` dispatch with 401. That status is
not retryable (the client retries only on >=500 or 429), so the run died before
the manifest row existed, the state stayed `missing`, and `veryfront deploy`
failed at its 120s deadline naming neither auth nor config.

`POST /api/control-plane/agents/list` is the same failure on a second surface,
and worse shaped: `runtime-agent-client` sends no `Authorization` header at all,
so Studio's agent listing 401d for any project with `security.auth` set.

The fix reuses the predicate #3641 introduced. `isSignedControlPlaneDispatch`
requires both conditions: an anchored method/path pair that a control-plane
handler owns, and the signature header that handler verifies through
`verifyControlPlaneRequest`, which binds the Ed25519 signature to issuer,
audience, project id, request method, request path and body hash with
expiry/skew bounds. It is not a weakening -- it exempts nothing that is not
authenticated more strongly downstream.

It deliberately does not key on the prefix. `/api/control-plane/` is reserved
but not exclusively routed: the run handlers register the prefix and
`return this.continue()` when their anchored regex misses, and
`ApiHandlerWrapper` declares no patterns and sits after them, so
`/api/control-plane/checkout` falls through to project code. A `startsWith`
match would ship a bypass any project could trigger by choosing a path.

The check sits ahead of `resolveAuth`, like the CSP report exemption: an
unresolvable auth config still fails closed for every browser request, but a
project's config typo must not brick the platform's own dispatch.

Tests cover both directions:
- `auth.test.ts` -- all five surfaces pass for basic, bearer and env-configured
  auth; a look-alike path, seven project routes inside the namespace, every
  surface with no signature header, and a surface with an empty header all
  still get 401.
- `build-dispatch-security.test.ts` -- the real chain (`AuthHandler` ->
  `CsrfHandler` -> `ProjectRunExecuteHandler`) with a genuine signed envelope
  reaches the release asset build executor under basic auth, bearer auth, and
  both gates at once.
- `internal-agents-list.handler.test.ts` -- the real chain (`AuthHandler` ->
  `InternalAgentsListHandler`) lists agents for a signed dispatch behind basic
  and bearer auth, and still challenges the same surface without the signature
  and a project route inside the namespace.

Before the fix, those cases answered `401: Unauthorized` with the executor
never reached.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c4dea832-4a5a-4de4-b113-dde863fba9d6

📥 Commits

Reviewing files that changed from the base of the PR and between fb7ab71 and 4e2e3cc.

📒 Files selected for processing (2)
  • src/release-assets/build-dispatch-security.test.ts
  • src/security/http/auth.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f6c55b7e-62f2-4fcd-a946-a15d9502b664

📥 Commits

Reviewing files that changed from the base of the PR and between 870bcde and fb7ab71.

📒 Files selected for processing (5)
  • docs/api-reference/veryfront/security.md
  • src/release-assets/build-dispatch-security.test.ts
  • src/security/http/auth.test.ts
  • src/security/http/auth.ts
  • src/server/handlers/request/internal-agents-list.handler.test.ts

📝 Walkthrough

Walkthrough

AuthHandler now bypasses project authentication for verified signed control-plane dispatches. Regression tests cover release-asset builds, internal agent-list routes, invalid signatures, route matching, and Basic/Bearer authentication.

Changes

Control-plane authentication

Layer / File(s) Summary
AuthHandler bypass and coverage
src/security/http/auth.ts, src/security/http/auth.test.ts, docs/api-reference/veryfront/security.md
AuthHandler bypasses project authentication for verified signed control-plane dispatches. Tests cover registered routes, invalid signatures, route mismatches, and unresolved authentication settings.
Release-asset security chain
src/release-assets/build-dispatch-security.test.ts
The test handler chain registers AuthHandler before CsrfHandler. Tests cover Basic auth, Bearer auth, and combined authentication and CSRF settings.
Internal agent-list route coverage
src/server/handlers/request/internal-agents-list.handler.test.ts
Tests verify signed Basic and Bearer requests succeed, while unsigned and unregistered nested routes return 401.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: needs-human-input

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the fix preventing project authentication from rejecting authenticated control-plane dispatches.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-signed-control-plane-dispatch

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

…ions bag

The auth gate and the project-middleware gate are being exempted on two
branches, and both widen `dispatchReleaseAssetBuild`. Grown as positional
parameters they merge into a signature that still compiles while every
existing call binds its argument to the wrong slot: a `{ projectMiddleware }`
argument lands in the `auth` parameter, no middleware is installed, and the
test that proves the middleware bypass works keeps passing without ever
exercising it.

Take one named options bag instead, and assemble all three gates the way the
runtime does — project middleware outermost, then the security handlers, then
the run executor — so a fix for one gate is exercised with the others
standing.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Pushed: this branch and #3646 would have merged into something broken

Both this PR and #3646 widen dispatchReleaseAssetBuild in src/release-assets/build-dispatch-security.test.ts, and both grew it as a positional parameter — auth? here, options there.

Merging them produces a two-line conflict in the parameter list and auto-merges everything else. That is the trap: the conflict looks trivial, so the natural resolution is to keep both parameters. It compiles. And then:

  builds a manifest when the project's own middleware gates every request ... ok
  keeps project middleware in front of an unsigned request to the same path ... FAILED

    [Diff] Actual / Expected
    -   200
    +   401

The ok on the first line is the real damage. dispatchReleaseAssetBuild(undefined, { projectMiddleware: [...] }) binds its argument to auth, so no middleware is installed at all — the test that proves the middleware bypass works passes without ever exercising it. The second test fails only because it also asserts the negative direction.

Fix

One named options bag, identical on both branches:

interface DispatchOptions {
  readonly auth?: SecurityConfig["auth"];
  readonly projectMiddleware?: MiddlewareFunction[];
  readonly unsigned?: boolean;
}

async function dispatchReleaseAssetBuild(
  csrf: CsrfSetting | undefined,
  options: DispatchOptions = {},
): Promise<DispatchOutcome>

A named field cannot mis-merge the way a positional slot can. The harness also now assembles all three gates in the order the runtime does — project middleware outermost, then the security handlers, then the run executor — so a fix for one gate is exercised with the others standing. Each branch keeps only its own tests.

Verified by merging, not by reasoning

… string>

The inline array literal widened to a union in which each member carried the
other's keys as `?: undefined`, so neither was assignable to createEnvCtx's
`Record<string, string>` parameter:

  TS2345 ... Property 'VERYFRONT_BEARER_TOKEN' is incompatible with index
  signature. Type 'undefined' is not assignable to type 'string'.
    at src/security/http/auth.test.ts:547:22

Hoisting the literal into an annotated `Record<string, string>[]` contextually
types both members and drops the synthetic optional keys. No behaviour change.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

ci (lint) was red for one line — and it dragged three innocent files in with it

The 7m18s ci (lint) failure reported four failing entry points. Only one of them was mine:

TS2345 [ERROR]: Argument of type '{ VERYFRONT_BASIC_USER: string; ... VERYFRONT_BEARER_TOKEN?: undefined; }
  | { VERYFRONT_BEARER_TOKEN: string; VERYFRONT_BASIC_USER?: undefined; ... }'
  is not assignable to parameter of type 'Record<string, string>'.
    at src/security/http/auth.test.ts:547:22

The inline array literal widened to a union in which each member carried the other's keys as ?: undefined, so neither member was assignable to createEnvCtx's Record<string, string>. Fixed by hoisting it to an annotated Record<string, string>[], which contextually types both members and drops the synthetic optional keys. No behaviour change.

The other three were collateral, not rot

The run also blamed src/react/components/chat/chat/hooks/attachment-csrf.test.tsx, src/transforms/esm/http-cache.test.ts, and src/workflow/react/use-workflow-start.test.tsx — all untouched by this PR, none in the 51-entry baseline, all failing on the same RequestInit union (Property 'headers'/'method'/'signal' does not exist on type 'global.RequestInit | RequestInit | (RequestInit & { client?: HttpClient })').

They are not new rot. Per scripts/lint/check-test-typecheck-baseline.ts, the expected-clean set runs as one repository-wide check, and is only recursively split when that check fails. Those three files' errors are visible in a small program but not in the repo-wide one — so they surface only once something else has already broken the happy path. My TS2345 was that something.

The timings show it: ci (lint) takes ~2m when the repo-wide check passes and 7m18s when it splits.

Verified

With only the one-line fix applied, the real gate goes green on the full tree:

$ deno task lint:test-typecheck
Test typecheck baseline holds: 51 grandfathered files, 0 new.

No baseline entry was added, and the three innocent files were left alone. Worth knowing that this gate reports latent errors as if they were yours whenever anything else fails first.

The other five red checks were infrastructure

coverage shard 8/8, tests (bun), and tests (rsc browser e2e) all died in ./.github/actions/setup-deno before running a line of code:

curl: (56) Connection died, tried 5 times before giving up
Failed to download Deno archive for deno-x86_64-unknown-linux-gnu.zip     # shard 8/8
Failed to download Deno archive checksum for ...linux-gnu.zip             # bun
Failed to download checksums manifest for v2.7.7 (HTTP 000)               # rsc browser e2e

coverage gate and tests (unit) are aggregators that only mirror the shard result (COVERAGE_SHARDS_RESULT: failure). The new run supersedes all five.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 681d224 Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/auth-signed-control-plane-dispatch branch August 12, 2026 18:54
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 2026
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