fix(gateway): reserve worst-case cost, price challenges at issuance, expire tokens - #3
Merged
Merged
Conversation
…expire tokens Third hardening pass, working the 2026-08-06 review's launch blockers. Four structural holes, each of which made a documented guarantee false: **Escalation priced the wrong event.** Difficulty was computed from *redeemed* mints, so an attacker could collect a day's worth of base-difficulty challenges up front and solve them at leisure — the ladder never engaged. Challenges are now priced and counted at issuance, signed to the /48 bucket they were issued to (challenge wire v2), and the counts persist across restarts so a deploy is not an amnesty. The mint endpoints also throttle by that same bucket instead of the raw IPv6 address, and an off-by-one that gave FreeMints+1 cheap mints is fixed. **The budget was a horizon, not a ceiling.** Requests were admitted unbilled and charged after the model answered, so MAX_INFLIGHT requests could all clear the breaker a dollar before it fired. Admit now reserves the request's worst case — one input token per body byte, plus max_tokens and a chain-of-thought allowance the estimate previously ignored — against both budgets, and Charge/Refund/Release reconcile it. The old 4-bytes-per-token estimate was not an upper bound; this one is. **Accounting failed open.** Journal open/write/fsync errors were ignored, so a full disk meant spend a restart would refund. The ledger now refuses admissions while it cannot write (503, self-healing on reopen), and replay is line-oriented so one torn line no longer eats every entry after it. Client aborts before upstream headers are charged an input-side estimate instead of refunded — send-and-abort was a free drain on the key. The upstream balance is polled every 15 minutes so the gateway stops claiming credit the account no longer has, and NaN budgets are rejected at boot. **Identities were immortal and unbounded.** Tokens now expire (7 days; the CLI re-enrols quietly at day 6, so honest users never notice), each token gets its own rate and concurrency valves so one subject cannot park the whole service, every limiter map has a hard size bound that fails closed instead of OOMing the container, the read-only endpoints are rate limited, /models is answered from a 30-second cache, and server-side tools (web_search) are refused on /responses — their per-search cost never appears in the usage object, so no ceiling could have held it. Also: the upstream HTTP client no longer follows redirects (Go strips Authorization cross-host but not x-api-key — a redirecting upstream would have been handed the key), and the shipped Caddy example now overwrites X-Forwarded-For, which Caddy appends to by default. All gateway tests pass with -race; the interop test confirms the CLI enrols unchanged against the v2 challenge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third hardening pass on the free-tier gateway, working the 2026-08-06 security review's launch blockers. Four structural holes, each of which made a documented guarantee false:
Escalation priced the wrong event
Difficulty was computed from redeemed mints, so an attacker could collect a day's worth of base-difficulty challenges up front and solve them at leisure — the ladder never engaged.
state/mint.json), so neither batching nor a deploy resets the ladder.FreeMints+1cheap mints.The budget was a horizon, not a ceiling
Requests were admitted unbilled and charged after the model answered, so
MAX_INFLIGHTrequests could all clear the breaker a dollar before it fired.Admitnow reserves the request's worst case — one input token per body byte, plusmax_tokensand a chain-of-thought allowance the old estimate ignored — against both budgets;Charge/Refund/Releasereconcile it. The old 4-bytes-per-token estimate was not an upper bound; this one is.Accounting failed open
/user/balanceis polled every 15 minutes, so the gateway stops claiming credit the account no longer has.Identities were immortal and unbounded
DSGATE_TOKEN_TTL_DAYS); the CLI re-enrols quietly at day 6, so honest users never see it.6/min) and concurrency (2) valves — one subject cannot park the whole service behind the global in-flight cap./modelsis served from a 30-second cache, and server-side tools (web_search) are refused on/responses— their per-search cost never appears in the usage object, so no ceiling could have held it.Also
Authorizationcross-host but notx-api-key; a redirecting upstream would have been handed the key).X-Forwarded-For(Caddy appends by default) and the deploy docs call out nginx's default access log versus the "no IPs logged" promise.All gateway tests pass with
-race; the interop test confirms the CLI enrols unchanged against the v2 challenge wire format.🤖 Generated with Claude Code