Skip to content

fix(gateway): reserve worst-case cost, price challenges at issuance, expire tokens - #3

Merged
lroolle merged 1 commit into
mainfrom
gateway-hardening-pass-3
Aug 6, 2026
Merged

fix(gateway): reserve worst-case cost, price challenges at issuance, expire tokens#3
lroolle merged 1 commit into
mainfrom
gateway-hardening-pass-3

Conversation

@lroolle

@lroolle lroolle commented Aug 6, 2026

Copy link
Copy Markdown
Member

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.

  • Challenges are priced and counted at issuance, and the counts persist across restarts (state/mint.json), so neither batching nor a deploy resets the ladder.
  • Each challenge is signed to the /48 bucket it was issued to (challenge wire v2) and can only be redeemed from there.
  • The mint endpoints throttle by that same bucket instead of the raw IPv6 address.
  • Fixed an off-by-one that gave FreeMints+1 cheap mints.

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 old estimate ignored — against both budgets; 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 — 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 were refunded — send-and-abort was a free drain on the key. They are now charged an input-side estimate.
  • The upstream /user/balance is polled every 15 minutes, so the gateway stops claiming credit the account no longer has.
  • NaN/Inf budgets are rejected at boot.

Identities were immortal and unbounded

  • Tokens expire (7 days, DSGATE_TOKEN_TTL_DAYS); the CLI re-enrols quietly at day 6, so honest users never see it.
  • Per-token rate (6/min) and concurrency (2) valves — one subject cannot park the whole service behind the global in-flight cap.
  • 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 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

  • 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).
  • The shipped Caddy example now overwrites 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

…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>
@lroolle
lroolle merged commit ec0f5ce into main Aug 6, 2026
8 checks passed
@lroolle
lroolle deleted the gateway-hardening-pass-3 branch August 6, 2026 14:44
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