Skip to content

fix(proxy): name the reason a control-plane signature was rejected - #3357

Merged
kwakayama merged 4 commits into
mainfrom
fix/control-plane-signature-observability
Aug 4, 2026
Merged

fix(proxy): name the reason a control-plane signature was rejected#3357
kwakayama merged 4 commits into
mainfrom
fix/control-plane-signature-observability

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

verifyInternalControlPlaneSignature returns a bare false on every failure path. An unconfigured verification key, a stripped x-token, a missing signature header and a genuinely bad signature all produce byte-identical output, with no log line at any of them.

This cost real time on veryfront-issue-inbox#356. 0.1.1189 (#3251) added the required request_method / request_path claims; veryfront-api was never updated to mint them, so the signed-internal bypass stopped firing and the proxy 302'd every control-plane run on a protected environment to the sign-in page. The API followed the redirect, got 200 plus ~31KB of HTML, parsed zero SSE events, and failed the run as RUNTIME_TERMINAL_EVENT_MISSING.

Nothing in the proxy logs distinguished that from a missing key or config drift. Diagnosis needed a cross-service log correlation plus two source dives.

Changes

Reason codes. Rejections now carry missing_x_token, verification_key_not_configured, missing_signature_header, or signature_rejected, logged with method, pathname, and audience. Ordinary non-internal routes stay silent — every page request passes through here.

The logger is an optional parameter, so the exported signatures stay source-compatible and the verification logic is unchanged.

Cross-repo contract test. control-plane-signature.test.ts mints its own JWS that always carries request_method / request_path. It proves the verifier works on a compliant token, but never that veryfront-api produces one — exactly the gap that let #3251 ship a breaking change unnoticed. The new test mints the payload as veryfront-api's createControlPlaneRequestSignature does and pins both sides together, plus guards the operation binding against replay across routes.

Verification

Check Result
deno test src/proxy/ 48 files, 473 steps passed
deno lint (changed files) clean
deno check (changed files) clean

Tested with Deno 2.7.7; 2.9.x fails this harness on an unrelated node:util/types brand-check error.

No version bump — recent fix PRs leave that to the release: PRs.

Companion

veryfront-api#4255 adds the missing claims. That is the actual fix; this PR makes the next occurrence diagnosable from a single log line, and stops the contract from drifting again.

Refs veryfront-issue-inbox#356

Summary by CodeRabbit

  • Security

    • Strengthened authentication for internal control-plane requests using signed tokens and expected request claims.
    • Requests with missing, mismatched, or invalid authentication details are rejected consistently.
  • Monitoring

    • Added clearer diagnostic warnings for rejected internal requests, including the reason, method, path, and audience.
    • Non-internal routes continue without unnecessary authentication warnings.
  • Quality

    • Added contract coverage for valid and invalid control-plane authentication scenarios.

Copilot AI review requested due to automatic review settings August 4, 2026 11:02
@kwakayama
kwakayama requested a review from kojiwakayama as a code owner August 4, 2026 11:02
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 8 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: 8b7469b0-9802-4f76-9798-8e93a46a8d91

📥 Commits

Reviewing files that changed from the base of the PR and between 3b22e41 and 33f407e.

📒 Files selected for processing (2)
  • src/proxy/control-plane-signature.api-contract.test.ts
  • src/proxy/control-plane-signature.ts
📝 Walkthrough

Walkthrough

Control-plane signature verification now returns typed rejection reasons, emits optional structured warnings, and receives the proxy logger through request handling. Contract tests cover API-style Ed25519 JWS requests, claim validation, rejection diagnostics, and silent non-internal routes.

Changes

Control-plane authentication

Layer / File(s) Summary
Typed rejection and diagnostic logging
src/proxy/control-plane-signature.ts
Signature verification distinguishes rejection reasons and preserves boolean authentication results. Optional logging records rejected internal requests with request details.
Request-path logger wiring
src/proxy/handler.ts
Proxy request handling passes the configured logger to candidate verification, branch-binding verification, and API token retrieval.
API-style JWS contract validation
src/proxy/control-plane-signature.api-contract.test.ts
Tests mint Ed25519 JWS tokens and cover valid requests, request-claim mismatches, missing configuration, absent headers, invalid signatures, and silent public routes.

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

Sequence Diagram(s)

sequenceDiagram
  participant Handler
  participant CandidateAuth
  participant SignatureVerification
  participant Logger
  Handler->>CandidateAuth: verify control-plane candidate
  CandidateAuth->>SignatureVerification: validate token and request claims
  SignatureVerification-->>CandidateAuth: success or rejection reason
  CandidateAuth->>Logger: warn for rejected internal request
Loading

Suggested reviewers: kojiwakayama, copilot

🚥 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 summarizes the main change: naming rejection reasons for control-plane signature failures.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/control-plane-signature-observability

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

Every failure path in verifyInternalControlPlaneSignature returned a bare
`false`. An unconfigured verification key, a stripped x-token, a missing
signature header and a genuinely bad signature were indistinguishable,
with no log line at any of them.

That cost real time on veryfront-issue-inbox#356. veryfront-api was not
minting the request_method / request_path claims this repo began
requiring in 0.1.1189 (#3251), so the bypass never fired and protected
environments 302'd every control-plane run to the sign-in page. Nothing
in the logs distinguished that from a missing key or a config drift.

Failures now carry a reason: missing_x_token,
verification_key_not_configured, missing_signature_header or
signature_rejected. Ordinary non-internal routes stay silent.

Also adds a cross-repo contract test. control-plane-signature.test.ts
mints its own compliant JWS, so it proves the verifier works on a good
token but never that veryfront-api produces one — which is exactly the
gap that let #3251 ship. The new test mints the payload as veryfront-api
does and pins both sides together.

Refs veryfront-issue-inbox#356
@kwakayama
kwakayama force-pushed the fix/control-plane-signature-observability branch from a154714 to 3d629b8 Compare August 4, 2026 11:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 improves diagnosability of internal control-plane signature verification in the proxy by emitting a structured rejection reason (only for internal-route candidates), and adds a cross-repo contract test to prevent drift between veryfront-api’s minted JWS claims and the proxy verifier.

Changes:

  • Add optional logger plumbing so signature verification can log a specific rejection reason without changing verification behavior.
  • Refactor internal signature verification to return a typed rejection reason internally and log it for internal-route candidates.
  • Add an API-contract test that mints a JWS in the same shape as veryfront-api and validates required request binding claims and replay protection.

Verification (reported in PR description; not run in this review):

  • deno test src/proxy/ (passed)
  • deno lint (changed files, clean)
  • deno check (changed files, clean)

Reviewed changes

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

File Description
src/proxy/handler.ts Passes the proxy logger into internal control-plane candidate/verification helpers so failures can be logged with a reason.
src/proxy/control-plane-signature.ts Introduces rejection-reason classification and logs structured details (reason, method, pathname, optional audience) via an optional logger.
src/proxy/control-plane-signature.api-contract.test.ts Adds a cross-repo contract test that mints an API-style JWS and asserts acceptance/rejection behavior and logging reasons.
Suppressed comments (1)

src/proxy/control-plane-signature.api-contract.test.ts:195

  • For the non-internal route silence assertion, use the same obviously-test hostname style as other proxy tests (and the RUN_STREAM_URL above) to keep fixtures consistent and avoid introducing additional real-looking domains.
    );
  });

  it("stays silent for ordinary non-internal routes", async () => {
    const reasons: string[] = [];
    const pageUrl = "http://slug.preview.veryfront.org/";
    await isAuthenticInternalControlPlaneCandidate(

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

Comment thread src/proxy/control-plane-signature.api-contract.test.ts
Comment thread src/proxy/control-plane-signature.api-contract.test.ts
Copilot AI review requested due to automatic review settings August 4, 2026 11:06

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/proxy/control-plane-signature.api-contract.test.ts`:
- Around line 2-3: Update the imports in the control-plane signature contract
test to use the required explicit helper paths: import assertEquals from
`#veryfront/testing/assert.ts` and afterEach, describe, and it from
`#veryfront/testing/bdd.ts`.
- Around line 12-14: Update the contract fixture constants and related claims to
use reserved synthetic values instead of the preview hostname and
fixture-specific identifiers for RUN_STREAM_URL, aud, project_id, and the run
ID. Preserve the existing URL method/path relationships and ensure all related
assertions remain consistent with the replacement values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 134cc08f-cf7d-4758-acf8-f94eed0df51c

📥 Commits

Reviewing files that changed from the base of the PR and between bd72d8b and a154714.

📒 Files selected for processing (3)
  • src/proxy/control-plane-signature.api-contract.test.ts
  • src/proxy/control-plane-signature.ts
  • src/proxy/handler.ts

Comment thread src/proxy/control-plane-signature.api-contract.test.ts Outdated
Comment thread src/proxy/control-plane-signature.api-contract.test.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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.

Suppressed comments (4)

src/proxy/control-plane-signature.api-contract.test.ts:15

  • RUN_STREAM_URL uses what looks like a real preview hostname. Tests should avoid hardcoding real service/project identifiers to reduce accidental leakage and to keep the fixture clearly synthetic. Use a reserved test domain (for example .invalid) and a generic slug.
const RUN_STREAM_URL =
  "http://outlook-agent-hvjoe9.preview.veryfront.org/api/control-plane/runs/r_1/stream";
const encoder = new TextEncoder();

src/proxy/control-plane-signature.api-contract.test.ts:58

  • The contract fixture hardcodes a specific-looking project slug (aud) and UUID (project_id). Even in tests, prefer obviously synthetic identifiers so the fixture cannot be mistaken for real customer/project data.
    aud: "outlook-agent-hvjoe9",
    sub: "r_1",
    surface: "studio",
    project_id: "979f3e04-e951-4807-8aa8-98530d9b8ba1",
    request_hash: await sha256Base64url(body),

src/proxy/control-plane-signature.api-contract.test.ts:106

  • verifyApiStyleRequest mutates CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY and the surrounding afterEach unconditionally deletes it. With deno test --parallel (as used in this repo), this can leak state across tests and also clobber a pre-existing value set by other suites. Prefer saving the previous value and restoring it in a try/finally local to the helper (or use the same save/restore pattern as control-plane-signature.test.ts).
  const body = JSON.stringify({ messages: [{ role: "user", content: "hi" }] });
  const { jws, publicKeyPem } = await mintApiStyleJws(body, claimOverrides);
  Deno.env.set(PUBLIC_KEY_ENV, publicKeyPem);

  const req = new Request(RUN_STREAM_URL, {

src/proxy/control-plane-signature.api-contract.test.ts:157

  • reasonFor only sets CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY when the chosen value is truthy. This means the test case that passes publicKeyPem: "" never actually sets the env var to an empty string, so it doesn't cover the intended "configured but empty" scenario (it behaves like the var is unset). Also, the suite-level afterEach deletes the var instead of restoring any pre-existing value; restoring locally in reasonFor avoids cross-test interference.
    Deno.env.delete(PUBLIC_KEY_ENV);
    if (built.publicKeyPem ?? publicKeyPem) {
      Deno.env.set(PUBLIC_KEY_ENV, built.publicKeyPem ?? publicKeyPem);
    }

veryfront-code is public. The fixture carried a real project slug and
project UUID; replace them with the placeholders the sibling signature
test already uses (protected / proj-1), and switch the test-helper
imports to the explicit .ts paths the guidelines require.

Addresses review feedback on #3357.
Copilot AI review requested due to automatic review settings August 4, 2026 11:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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.

Suppressed comments (2)

src/proxy/control-plane-signature.ts:276

  • missing_x_token is returned (and logged) for any request to an internal control-plane route without x-token, even when there is no signature header present. Because these endpoints are internet-reachable via the proxy, this can produce high-volume warn logs from routine probes/scans that are not actually attempting a signed internal bypass. Consider only treating this as a reportable internal-rejection when at least one signature header is present; otherwise return not_an_internal_route (so it stays silent) while still failing closed.
  // The candidate only matters when there is an x-token to use for metadata
  // lookup or forward after the resolved project binding succeeds.
  if (!req.headers.get("x-token")) return "missing_x_token";

src/proxy/control-plane-signature.api-contract.test.ts:146

  • In reasonFor, the if (built.publicKeyPem ?? publicKeyPem) guard prevents setting CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY when publicKeyPem is an empty string. That makes the "unconfigured verification key" case behave like the env var is unset, and it never exercises the explicit empty-string configuration path (even though getHostEnv/!publicKeyPem treats both as not configured). Prefer setting the computed key unconditionally (or checking !== undefined) so publicKeyPem: "" does what it says.
    Deno.env.delete(PUBLIC_KEY_ENV);
    if (built.publicKeyPem ?? publicKeyPem) {
      Deno.env.set(PUBLIC_KEY_ENV, built.publicKeyPem ?? publicKeyPem);
    }

Addresses review on #3357.

B1 — unauthenticated log amplification. The warn logged url.pathname
verbatim. Two admissible route patterns carry an unbounded runId segment,
so a client with no headers at all could POST an 8KB path and write one
8KB line per request into log ingest, drowning the signal this feature
exists to surface. Now: pathname bounded to 256 code units, and the two
'missing' reasons are logged only when the caller actually presented a
signature header. Anonymous traffic is silent; every reason still fires
for a real internal caller.

B2 — the contract test asserted through the unbound candidate check,
which verifies neither aud, project_id nor request_hash. A JWS with a
wrong audience and a hash of a different body was accepted. The earlier
identifier scrub is the proof: it changed aud and project_id and nothing
failed. Now asserts through isVerifiedInternalControlPlaneRequest with a
binding, with explicit rejection cases for a drifted aud and project_id,
plus body-binding cases through resolveVerifiedControlPlaneBranchBinding
that pin request_hash.

Also: rename not_an_internal_route -> route_not_admissible, since
reserved routes are internal but inadmissible; export the reason union so
dashboards do not couple to an unexported type; assert exactly one warn
per rejection; drop a redundant undefined-deletion loop; document the
logging side effect in the module header.
Copilot AI review requested due to automatic review settings August 4, 2026 11:38
@kwakayama

Copy link
Copy Markdown
Contributor Author

Adversarial review — round 1: 78/100, two blocking issues. Both now fixed.

Behaviour preservation — verified branch by branch

The central question for this file, since it gates the protected-environment auth bypass and x-token forwarding. Reviewed all seven branches of the original against checkInternalControlPlaneSignature + wrapper:

Guard order and short-circuit order untouched. Both terminal branches keep a character-identical options literal, and the verified ? null : "signature_rejected" ternary is exact because both callees are declared Promise<boolean> — no truthiness widening is possible. isVerifiedInternalControlPlaneRequest's if (!binding.audience) return false still runs before the wrapper.

There is no input for which the new code returns true where the old returned false. The success sentinel is rejection === null — strict equality, so an accidental undefined denies rather than admits. Fails closed. The reason never reaches the HTTP response, so it is not an oracle for an external caller.

BLOCKING 1 — unauthenticated log amplification (fixed)

The warn logged url.pathname verbatim. Two admissible route patterns carry an unbounded segment:

/^\/api\/control-plane\/runs\/[^/]+\/(?:execute|stream|resume)$/u
/^\/api\/control-plane\/runs\/[^/]+$/u

These classify as control-plane, not reserved, so the handler's early 404 does not catch them. The reviewer ran the real function:

POST https://<slug>.preview.veryfront.org/api/control-plane/runs/AAAA…(8000 chars)…/stream
with zero headers — no x-token, no JWS, no crypto work for the attacker
=> reason: missing_x_token, logged pathname length: 8031

~8000× amplification per request into billed log ingest, with no sampling or rate limiting anywhere in src/proxy/, and it drowns the exact signal this PR exists to surface. missing_x_token is also the least diagnostic reason — veryfront-api always sends x-token, so it fires almost exclusively for junk traffic.

Fixed: pathname bounded to 256 code units, and the two "missing" reasons log only when the caller actually presented one of INTERNAL_CONTROL_PLANE_SIGNATURE_HEADERS. Anonymous traffic is silent; every reason still fires for a real internal caller. verification_key_not_configured always logs — it is an operator-critical signal and cannot be provoked on a healthy deployment. Three tests pin this, including the 8000-char attack.

BLOCKING 2 — the contract test did not pin half the claim set (fixed)

It asserted through isAuthenticInternalControlPlaneCandidate, which passes no binding. In channels/control-plane.ts:837-842 the audience and project-id checks are !== undefined && guarded, and verifyControlPlaneJwsSignature never passes expectedRequestHash. So aud, project_id and request_hash were all unverified on that path — a JWS with a wrong audience and a hash of an entirely different body was accepted.

The earlier identifier scrub is itself the proof: it changed aud and project_id and every test still passed. Nothing was asserting them, and the sha256Base64url machinery was decorative.

Fixed: asserts through isVerifiedInternalControlPlaneRequest with a binding, with explicit rejection cases for a drifted aud and project_id, plus body-binding cases through resolveVerifiedControlPlaneBranchBinding that pin request_hash in both directions.

Non-blocking, also fixed

not_an_internal_routeroute_not_admissible (reserved routes are internal, just inadmissible); reason union exported so dashboards do not couple to an unexported type; tests assert exactly one warn per rejection; redundant undefined-deletion loop removed; logging side effect documented in the module header.

Deliberately deferred

signature_rejected is coarse. It conflates expired, wrong audience, binding mismatch, wrong key, wrong iss, wrong alg, and malformed JWS. The reviewer is right that the motivating incident would surface as bare signature_rejected, and that splitting it is the change that pays for itself.

It needs verifySignedRequestJwsSignature in channels/control-plane.ts to return a reason — modifying the shared crypto verification path used by both the proxy and the runtime. That deserves its own PR with its own review, not a ride-along on an observability change. Filed as follow-up.

Also deferred: the silent empty-audience rejection (unreachable from all current call sites), and an { ok, reason } union in place of null-means-success (current form is provably fail-closed).

Verified good

  • Logging correctly scoped to the second pass: binding re-verification runs only behind signedInternalControlPlaneCandidate &&, so a legitimate request failing project-id binding logs exactly once, with the resolved audience attached — and no duplicate noise on the common path.
  • CRLF injection not exploitable. WHATWG URL parsing strips raw CR/LF/tab and percent-encodes the rest; production emits JSON. audience is host-derived. Volume was the only real issue.
  • All four logger call sites have logger genuinely in scope (destructured at handler.ts:225, in the closure of both processRequest and getTokenForApi); every use is logger?.warn. No call site missed — there are no other callers of the two exported verifiers.
  • Positional-parameter confusion is type-blocked: InternalControlPlaneProjectBinding requires audience: string, the logger has only warn, so a swap is a compile error.
  • The narrow module-local logger interface matches repo idiom and avoids a circular import back to handler.ts.

Current state

deno test src/proxy/ — 49 files, 480 steps, 0 failed. deno lint, deno check, deno fmt --check all clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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.

Suppressed comments (1)

src/proxy/control-plane-signature.ts:359

  • shouldLogRejection() uses req.headers.get(header) in a truthiness check. If a signature header is present but has an empty value (e.g. x-veryfront-control-plane-jws:), get() returns "" and this will incorrectly treat it as absent, suppressing the rejection log even though the caller attempted to authenticate. Using headers.has() avoids this edge case without changing the privacy/amplification goal.
function shouldLogRejection(req: Request, rejection: InternalControlPlaneRejection): boolean {
  if (rejection === "route_not_admissible") return false;
  if (rejection === "verification_key_not_configured") return true;
  return INTERNAL_CONTROL_PLANE_SIGNATURE_HEADERS.some((header) => req.headers.get(header));

…Access

deno check failed on the test added in the previous commit: with
noUncheckedIndexedAccess, pathnames[0] is string | undefined, so
pathnames[0].length is TS2532. The test-typecheck ratchet in the ci (lint)
chain gates on this. Optional-chain it — the preceding length assertion
already guarantees presence.

Also:
- shouldLogRejection uses headers.has rather than get() truthiness, so a
  set-but-empty signature header counts as presented and its rejection is
  logged rather than silently dropped.
- Name sub and surface in the test as deliberately unbound at the proxy:
  the run id is already pinned through request_path, and surface is only
  checked for membership of CONTROL_PLANE_SURFACES.
Copilot AI review requested due to automatic review settings August 4, 2026 11:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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.

@kwakayama

Copy link
Copy Markdown
Contributor Author

Round 2: 84/100 — one new blocking issue, introduced by the round-1 fix itself. Now fixed → 93.

B1 and B2 — verified fixed, by attack rather than inspection

The reviewer re-ran their own exploits against the fix:

8000-char path, ZERO headers              -> 0 log lines        (was: 1 line, 8031 chars)
8000-char path, junk JWS header + x-token -> 1 line, pathname exactly 256

And re-ran the B2 acceptance proof through the bound entry point the test now uses:

Case Before After
baseline accepted accepted
aud drifted accepted rejected
project_id drifted accepted rejected
tampered body unverified rejected

Behaviour preservation re-confirmed at the new tip, not on inspection: every probe case was run twice, once with a logger and once without, and the verdicts compared identically. shouldLogRejection is a pure predicate called only inside the logging branch, with return false following unconditionally. Logging is verdict-neutral.

BLOCKING — the round-1 fix did not typecheck (fixed)

src/proxy/control-plane-signature.api-contract.test.ts:273
assertEquals(pathnames[0].length, 256);
TS2532 [ERROR]: Object is possibly 'undefined'.

The repo sets noUncheckedIndexedAccess: true, so pathnames[0] is string | undefined. This gates CI through deno task lint:test-typecheck in the ci (lint) chain. I reproduced it exactly before fixing, then optional-chained it — the preceding assertEquals(pathnames.length, 1) already guarantees presence.

My own miss: I ran deno check on the two source files and not on the test file I had just written.

One correction to the review record

The reviewer reported that the ratchet also fails on a pre-existing entry, src/tool/remote-mcp.test.ts, and concluded ci (lint) was "already red on origin/main independent of you."

Half right, and the conclusion does not follow. That file genuinely has 2 type errors — I confirmed it directly. But it is grandfathered in scripts/lint/test-typecheck-baseline.json, so the ratchet tolerates it and only fails on new rot:

deno task lint:test-typecheck
-> "Test typecheck baseline holds: 51 grandfathered files, 0 new."  EXIT=0

ci (lint) now passes on this PR. B3 was the only thing gating it, and main was never red.

Non-blocking, also fixed

  • shouldLogRejection used .get() truthiness, so a set-but-empty signature header was treated as absent and its rejection silently dropped. Now .has(). Not a live failure mode — veryfront-api throws rather than sending an empty header — but it was a hole in the "name every rejection" goal.
  • The test doc comment now names sub and surface as deliberately unbound at the proxy. The reviewer probed each claim individually and found sub fully inert and surface only allowlist-pinned. Neither should be bound here: the run id is already pinned through request_path, and the proxy has no business asserting a surface. But the previous commit message claimed to "pin the full claim contract", and it does not.

Deferrals — all three accepted, one strengthened

The reviewer withdrew the implication that N1 (splitting signature_rejected) blocks merge, and gave a better argument than mine for deferring it: the B2 fix undercut its own urgency. The drift class that motivated this PR is now caught by a failing test, not by a log line a human has to read. The reason code became a backstop rather than the primary detector.

N3 and N8 remain cosmetic.

Verification at the merged tip

deno test src/proxy/ — 49 files, 480 steps, 0 failed. deno lint, deno check, deno fmt --check all clean. deno task lint:test-typecheck green. All CI checks green including ci (lint), ci (format), ci (typecheck).

@kwakayama
kwakayama added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit f20f0d9 Aug 4, 2026
32 checks passed
@kwakayama
kwakayama deleted the fix/control-plane-signature-observability branch August 4, 2026 12:13
@kwakayama

Copy link
Copy Markdown
Contributor Author

Two corrections to my review comment above

Final review came back 93/100. Both of these are corrections to my explanations, not to the merged code.

1. I explained the remote-mcp.test.ts ratchet report wrong

I wrote that it is "grandfathered in scripts/lint/test-typecheck-baseline.json, so the ratchet tolerates it." That is not the mechanism, and the file is not in the baseline — 51 entries, none matching remote-mcp, none under src/proxy/.

What actually happens, per the ratchet's own header comment: expected-clean tests run as one repository-wide check, and only a failure triggers recursive splitting to isolate the culprit. B3 made the whole-repo check fail, which started the bisection, and src/tool/remote-mcp.test.ts fails only when checked in a small grouping — in isolation RequestInit resolves to an ambiguous union, where the full-repo type graph resolves it cleanly.

So it was an artifact of B3, not pre-existing rot. The accurate statement: B3 was the only ratchet failure, this PR caused it, and fixing it means the isolation-only quirk is never reached. main was never red — that part of my correction stands, for a different reason than I gave.

2. My "anonymous traffic is silent" claim was too strong

I asked the reviewer to confirm that the .has() change "cannot fire for anonymous traffic." They proved it can, and refused to rubber-stamp it:

8000-path, no headers                    -> 0 lines
8000-path, x-token only                  -> 0 lines
8000-path, EMPTY jws header, no x-token  -> 1 line, reason=missing_x_token, pathLen=256

Switching from .get() truthiness to .has() closed the silent set-but-empty blind spot, and in doing so widened the logged set: a client sending nothing but an empty x-veryfront-control-plane-jws: now produces a line.

The precise invariant, which is what should be relied on: a request that presents no signature header at all is never logged, whatever its path length.

Keeping .has() is still right. The attacker's cost is unchanged — a junk header value already produced a line before this change, so an empty one buys nothing new — and the record stays bounded at 256. B1 was about the zero-effort unbounded case, and that holds exactly. Reverting to .get() would trade this back for the silent blind spot.

The module doc comment in the merged code already states the precise version ("anonymous traffic that presented no signature header is not logged at all"), so no code change is needed. Only my prose above was loose.

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