Skip to content

fix(auth): throttle a failed exchange, never one that would succeed (#802) - #806

Merged
davidfarah2003 merged 1 commit into
mainfrom
fix/802-throttle-after-credential
Aug 24, 2026
Merged

fix(auth): throttle a failed exchange, never one that would succeed (#802)#806
davidfarah2003 merged 1 commit into
mainfrom
fix/802-throttle-after-credential

Conversation

@davidfarah2003

@davidfarah2003 davidfarah2003 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What

The public exchange face no longer refuses a request that would have succeeded.

The defect

implementations/auth/src/service.ts enforced the refused-exchange throttle before reading the body, so it fired before any credential existed to evaluate:

if (policy.throttled(ctx, peer))
  return send(res, 429, { error: "too many refused exchanges - wait a minute and retry" });
const body = await readJsonBody(req);

The default peer key on the public face is the socket address, and docs/run-a-mesh.md tells the operator to front the listener with a reverse proxy while framing --exchange-trusted-proxy as an optional hazard. Follow that guidance without the flag and every client collapses into one bucket, so 30 unauthenticated garbage POSTs deny the public mint path for a rolling minute — holders of valid IdP JWTs and actor tokens included. Loopback is unaffected, so local health looks fine while the published face is down.

Not a mint bypass: no token is issued that should not be. This is availability of the public identity plane, in the topology the docs recommend.

The fix

Throttling exists to slow probing, and a valid credential is not probing. The gate is still evaluated up front (cheap, prunes the window) but enforced only where a credential actually failed:

  • a throttled peer presenting a valid credential mints
  • a throttled peer presenting a bad one is answered 429 instead of the specific reason, because the reason is what makes probing cheap

Nothing that would have succeeded is refused, and the budget still does its real job.

Proof, red-first against a built defect

Both halves fail independently, so both were proven independently:

Mutation Result
enforce the throttle before readJsonBody (the shipped defect) valid-token cell RED, 49/1
strip the 429 short-circuit only reason-withholding cell RED, 47/3
fix in place 50/0

That second mutation matters: without that assertion, "fixing" the DoS would silently gut the throttle, since a throttled peer would leak the refusal reason and probing becomes cheap again.

Note the proof required rebuilding between mutation and run — this suite imports the built @cotal-ai/auth, so a source-only mutation is invisible and grades everything green. My first attempt made exactly that mistake and reported false passes.

Coverage this closes

Smoke G previously ran only with --exchange-trusted-proxy, so it asked whether a throttled peer is refused and whether other peers are isolated — never whether a legitimate caller behind that same key still works. That is why 47 green cells sat on top of an unauthenticated DoS. Two new cells cover it.

Partially addresses #799 (the suite's missing observed-failure evidence) for these cells specifically.

Closes #802.


Base retargeted to main. This PR was opened against feat/remote-exchange (old base tip b91796c8, previous head 4ceea934). That branch is a strict ancestor of main with zero unique commits and sat 202 commits behind it, so grading this change against it measured a snapshot that no longer exists. The single commit was rebased onto main unchanged: the diff is still the same 3 files (+67/-3), and every added/removed line is byte-for-byte identical to the pre-rebase diff — only three hunk headers in service.ts shifted by 8 lines.

Also fixes the tail count this PR should have carried: it adds 3 cells to section G, so EXPECTED goes 47 → 50. pnpm smoke:remote-exchange:live reported 50 passed, 0 failed.

@davidfarah2003
davidfarah2003 changed the base branch from feat/remote-exchange to main August 24, 2026 00:55
@davidfarah2003
davidfarah2003 force-pushed the fix/802-throttle-after-credential branch from 4ceea93 to 18e5548 Compare August 24, 2026 00:55
The refused-exchange budget was enforced before readJsonBody, so a full bucket
refused every request from that peer key - valid credentials included.

On the public face the default peer key is the socket address, and run-a-mesh.md
tells the operator to front the listener with a reverse proxy while framing
--exchange-trusted-proxy as an optional hazard. Follow that guidance without the
flag and every client collapses into one bucket, so thirty unauthenticated
garbage POSTs deny the public mint path for a rolling minute, holders of valid
IdP JWTs and actor tokens included. Loopback is unaffected, so local health looks
fine while the published face is down.

Throttling exists to slow probing, and a valid credential is not probing. The
gate is still evaluated up front, but enforced only where a credential actually
failed: a throttled peer with a good credential mints, and a throttled peer with
a bad one is answered 429 instead of the specific reason, because the reason is
what makes probing cheap.

Both halves are proven red-first against a built defect: enforcing before the
body turns the valid-token cell red (49/1), and stripping the 429 short-circuit
turns the reason-withholding cell red (47/3). Fixed: 50/0.
@davidfarah2003
davidfarah2003 force-pushed the fix/802-throttle-after-credential branch from 18e5548 to fd8aaa5 Compare August 24, 2026 01:25
@davidfarah2003
davidfarah2003 merged commit 6d03de0 into main Aug 24, 2026
14 of 16 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.

Public exchange face: 30 unauthenticated POSTs lock out valid mints in the documented reverse-proxy topology

1 participant