A claim that lives only in the payload cannot be refused cheaply by bogatyrjov1 · Pull Request #169 · backthread/backthread · GitHub
Skip to content

A claim that lives only in the payload cannot be refused cheaply - #169

Merged
bogatyrjov1 merged 1 commit into
mainfrom
arp1698b
Aug 19, 2026
Merged

bogatyrjov1 merged 1 commit into
mainfrom
arp1698b

Conversation

@bogatyrjov1

Copy link
Copy Markdown
Contributor

Follow-up to #168, from the ingress review. Not yet published — @backthread/ci@0.3.0 is tagged only after the ingress that consumes this is deployed.

What review found

The ingress cannot read a request body until it has authenticated, checked for a replay, and decompressed up to 16 MiB (8 MiB on the wire). A claim carried only in the payload therefore puts every refusal that depends on it below that read.

Concretely: anyone with a GitHub account creates a repository, adds a workflow minting an OIDC token for our audience, and posts a ~32 KiB gzip that inflates to just under 16 MiB. Before claims existed that cost us one indexed Supabase read and a repo_not_connected 404 with the body unread. With the claim in the payload it costs a Supabase read, a KV read, a 16 MiB inflate and a JSON.parse — roughly 500× amplification per request, repeatable, on the way to the same 404.

What changes

The code now also travels in x-backthread-claim, from the same single read of the environment. The ingress decides before it inflates anything, so a caller without a live claim naming their own repository gets the cheap 404 back.

The payload keeps its claim field. That is the published wire contract and the auditable record of what was sent, and removing it would be a breaking change to a field 0.2.0 already ships. The ingress cross-checks the two and refuses a disagreement with claim_header_mismatch, so the body can only ever agree with what authorised the request and never steer it.

Sending both is therefore required rather than redundant — which is why the source guard pins exactly one claimFromEnv call feeding both, and pins that the header name comes from the shared constant rather than a typed string.

Measured

@backthread/ci 363 tests, 0 fail. Typecheck clean on both projects. check-no-internal-refs OK. Floor raised 361 → 363.

Reasoning

written by the agent in-session via backthread/add-reasoning-to-prs

Decisions

  • The payload field stays even though the header is what the ingress acts on. Removing it would break the contract 0.2.0 published, and keeping it gives the request an auditable record of its own intent that the gate already validates. The cost is that a client can now contradict itself, which the ingress answers with claim_header_mismatch.

Trade-offs

  • A header is not part of the versioned payload contract, so nothing in validate.ts bounds it. The ingress applies the same grammar before using it, but the two definitions of "a plausible code" now live on either side of a boundary the version gate does not cover. Check: if CLAIM_RE is ever loosened, loosen the ingress's copy in the same release.

Review of the ingress found the shape this fixes. The ingress cannot read a
request body until it has authenticated, checked for a replay, and
decompressed up to 16 MiB — so a claim carried only in the payload puts
every refusal that depends on it below that read. Anyone with a GitHub
account could mint a token for a repository they own and post a small gzip
that inflates to 16 MiB, repeatedly, to be told the same thing one indexed
lookup already knew.

The code now travels in x-backthread-claim as well, from the same single
read of the environment, so the ingress decides before it inflates
anything. The payload keeps its copy: that is the published contract and
the auditable record of what was sent. The ingress cross-checks the two and
refuses a disagreement, so the body can only ever agree with what
authorised the request. Sending both is required, not redundant — which is
why the source guard pins ONE claimFromEnv call feeding both.
@bogatyrjov1
bogatyrjov1 merged commit 0c253d2 into main Aug 19, 2026
7 checks passed
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