Summary
In the Flask "upto" session (deferred-settlement) mode, the per-request cost is computed and charged after the response has already been streamed to the client, and if the charge would exceed the session's signed spend cap it is discarded entirely rather than saturated to the remaining budget. A single oversized request therefore delivers arbitrarily expensive inference that is never settled.
Found while reviewing #1.
Where
python/x402/http/middleware/flask.py — _handle_session_mode (pre-check), StreamingSessionResponse.close → _accumulate_session_cost (post-hoc charge)
python/x402/session.py — SessionStore.add_cost
python/x402/redis_session.py — _ADD_COST_SCRIPT
Details
The request gate only checks session.is_exhausted (i.e. accumulated_cost >= max_amount from prior requests). The cost of the current request is known only after generation and is added in StreamingSessionResponse.close(). When it would overflow the cap, add_cost drops the whole charge:
# session.py
if session.accumulated_cost + cost > session.max_amount:
return False # entire cost discarded — response already served
_accumulate_session_cost merely logs "Could not add cost ...". Because _settle_session early-returns on accumulated_cost <= 0, a session whose only request overflowed the cap is never settled at all.
Attack
- Open a session with a small cap (e.g. enough for one cheap call).
- Send one request with maximum context and
max_tokens. Gate sees accumulated_cost=0 < max → allowed. Operator pays the upstream provider for the full generation.
- At close,
cost >> max_amount → add_cost returns False → accumulated_cost stays 0 → nothing is ever settled.
Result: arbitrarily expensive inference for free. (The Redis path behaves identically — the atomic Lua script keeps the accounting correct but the work is already delivered.)
Impact
Direct, unbounded operator loss (real provider spend not recovered on-chain). High severity for any deployment running session mode with a live facilitator.
Suggested fixes
- Minimum: saturate the charge to the cap instead of dropping it —
accumulated_cost = min(accumulated_cost + cost, max_amount) — so the operator always collects up to the full signed amount, and mark the session exhausted/settle-due.
- Better: cap the effective
max_tokens of the request to what the remaining budget affords before serving, so cost cannot exceed budget.
- Consider treating a
False return from add_cost in _accumulate_session_cost as a signal to close/settle the session rather than a warning to ignore.
Notes
Pre-existing architectural issue in the upto session model, not introduced by #1 (which improves the settlement-loss side). Filing separately as a high-priority follow-up. Closely related to the concurrency cap-bypass issue (filed alongside).
Summary
In the Flask "upto" session (deferred-settlement) mode, the per-request cost is computed and charged after the response has already been streamed to the client, and if the charge would exceed the session's signed spend cap it is discarded entirely rather than saturated to the remaining budget. A single oversized request therefore delivers arbitrarily expensive inference that is never settled.
Found while reviewing #1.
Where
python/x402/http/middleware/flask.py—_handle_session_mode(pre-check),StreamingSessionResponse.close→_accumulate_session_cost(post-hoc charge)python/x402/session.py—SessionStore.add_costpython/x402/redis_session.py—_ADD_COST_SCRIPTDetails
The request gate only checks
session.is_exhausted(i.e.accumulated_cost >= max_amountfrom prior requests). The cost of the current request is known only after generation and is added inStreamingSessionResponse.close(). When it would overflow the cap,add_costdrops the whole charge:_accumulate_session_costmerely logs"Could not add cost ...". Because_settle_sessionearly-returns onaccumulated_cost <= 0, a session whose only request overflowed the cap is never settled at all.Attack
max_tokens. Gate seesaccumulated_cost=0 < max→ allowed. Operator pays the upstream provider for the full generation.cost >> max_amount→add_costreturnsFalse→accumulated_coststays0→ nothing is ever settled.Result: arbitrarily expensive inference for free. (The Redis path behaves identically — the atomic Lua script keeps the accounting correct but the work is already delivered.)
Impact
Direct, unbounded operator loss (real provider spend not recovered on-chain). High severity for any deployment running session mode with a live facilitator.
Suggested fixes
accumulated_cost = min(accumulated_cost + cost, max_amount)— so the operator always collects up to the full signed amount, and mark the session exhausted/settle-due.max_tokensof the request to what the remaining budget affords before serving, so cost cannot exceed budget.Falsereturn fromadd_costin_accumulate_session_costas a signal to close/settle the session rather than a warning to ignore.Notes
Pre-existing architectural issue in the upto session model, not introduced by #1 (which improves the settlement-loss side). Filing separately as a high-priority follow-up. Closely related to the concurrency cap-bypass issue (filed alongside).