diff --git a/.env.example b/.env.example index 272d85c..7cf4508 100644 --- a/.env.example +++ b/.env.example @@ -126,7 +126,7 @@ POOL_ADMIN_ADDRESSES= # X402_BZZ_USD_RATE=0.50 # Manual BZZ/USD rate (default: $0.50) # X402_MARKUP_PERCENT=50.0 # Markup percentage on cost (default: 50%) # X402_MIN_PRICE_USD=0.01 # Minimum charge per request in USD (default: $0.01) -# X402_MAX_STAMP_BZZ=5.0 # Max single stamp purchase in BZZ (default: 5.0) +# X402_MAX_STAMP_BZZ # Set below, under spending limits — it is enforced now # X402_RATE_LIMIT_PER_IP=10 # Requests per minute for paying users (default: 10) # # Access control: IP-based allow/block lists @@ -226,6 +226,17 @@ POOL_ALLOWANCE_STATE_FILE=data/pool_allowance.json # takes. X402_POOL_MARKUP_PERCENT=100 +# Hard ceiling on what a single stamp purchase or extend may cost the gateway. +# Zero or negative disables it. Enforced on POST /stamps/ and +# PATCH /stamps/{id}/extend alike. +X402_MAX_STAMP_BZZ=5.0 + +# Daily BZZ a single caller (by client IP) may spend through those two +# endpoints. -1 disables the bound. Both endpoints spend the operator's money +# for whoever asks, and only the pool had a bound before this. +STAMP_DAILY_BZZ_PER_CALLER=0.5 +STAMP_SPEND_BUDGET_STATE_FILE=data/stamp_spend_budget.json + # Whether a settled x402 payment may bypass the pool's daily allowance. # On a testnet the currency is free from a faucet, so a payment there proves # nothing and the bypass is withheld — the allowance still applies. Set this diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 793c4f1..a48ce6d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,6 +62,8 @@ jobs: POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }} X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }} X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }} + X402_MAX_STAMP_BZZ=${{ vars.X402_MAX_STAMP_BZZ || '5.0' }} + STAMP_DAILY_BZZ_PER_CALLER=${{ vars.STAMP_DAILY_BZZ_PER_CALLER || '0.5' }} STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }} STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }} STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }} @@ -125,6 +127,8 @@ jobs: POOL_DEFAULT_DAILY_ALLOWANCE=${{ vars.POOL_DEFAULT_DAILY_ALLOWANCE || '-1' }} X402_POOL_MARKUP_PERCENT=${{ vars.X402_POOL_MARKUP_PERCENT || '100' }} X402_ALLOW_TESTNET_PAID_BYPASS=${{ vars.X402_ALLOW_TESTNET_PAID_BYPASS || 'false' }} + X402_MAX_STAMP_BZZ=${{ vars.X402_MAX_STAMP_BZZ || '5.0' }} + STAMP_DAILY_BZZ_PER_CALLER=${{ vars.STAMP_DAILY_BZZ_PER_CALLER || '0.5' }} STAMP_POOL_CHECK_INTERVAL_SECONDS=${{ vars.STAMP_POOL_CHECK_INTERVAL_SECONDS || '900' }} STAMP_POOL_MIN_TTL_HOURS=${{ vars.STAMP_POOL_MIN_TTL_HOURS || '24' }} STAMP_POOL_TOPUP_HOURS=${{ vars.STAMP_POOL_TOPUP_HOURS || '168' }} diff --git a/CLAUDE.md b/CLAUDE.md index cc27ca4..07b258b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,11 +143,11 @@ CORS (browser access): - `GET /` (and `/health`): Health check. Always includes a `bee_node` section (from Bee `/topology` + `/status` + `/health` + `/addresses` + `/chainstate`, fetched concurrently, 15s cached): identity/build `overlay`, `version`, `api_version`, `bee_status`; connectivity `mode`, `connected_peers`, `population`, `depth`, `reachability`, `network_availability` (Available/Unavailable/Unknown — Bee sets this from outbound-dial results; Unavailable = OS network/host-unreachable on dials); reserve/radius `storage_radius`, `committed_depth`, `reserve_size`, `reserve_size_within_radius`, `pullsync_rate`, `batch_commitment`; chain sync `last_synced_block`, `chain_tip`, `chain_sync_lag_blocks`; plus `warming_up`, `healthy`, `warnings`. Any endpoint that fails yields `null` for its fields rather than losing the whole section. Overall `status` → `degraded` when `network_availability` is `Unavailable` (node can't reach the storer network → uploads may 201 without propagating) — advisory warnings (low peer count `< LOW_PEER_WARN_THRESHOLD`, chain lag `> CHAIN_LAG_WARN_BLOCKS`, non-ok Bee status) never flip `healthy` or `status`. x402 wallet section added when `X402_ENABLED`. #### Stamp Management -- `POST /api/v1/stamps/`: Purchase new postage stamps (records purchase time for propagation tracking) +- `POST /api/v1/stamps/`: Purchase new postage stamps (records purchase time for propagation tracking). **Bounded** by `X402_MAX_STAMP_BZZ` per request and `STAMP_DAILY_BZZ_PER_CALLER` per caller per day — see below. - `GET /api/v1/stamps/`: List stamps (default: local only). Supports `?global=true` for all stamps, `?wallet=0x...` for wallet-filtered view (x402) - `GET /api/v1/stamps/{stamp_id}`: Retrieve specific stamp batch details including propagation timing - `GET /api/v1/stamps/{stamp_id}/check`: Check stamp health for uploads (errors, warnings, can_upload status, propagation status) -- `PATCH /api/v1/stamps/{stamp_id}/extend`: Extend existing stamps with additional funds +- `PATCH /api/v1/stamps/{stamp_id}/extend`: Extend existing stamps with additional funds. Subject to the same two bounds. Note this route is **not** payment-gated: `is_protected_endpoint` matches on method and `PROTECTED_ENDPOINTS` lists only POST paths, so a PATCH never sees x402 or the free-tier rate limit. It also tops up any batch on the node, including ones the caller does not own. - `POST /api/v1/stamps/for-owner` (Flow B #228/#230): create a postage batch owned by an arbitrary address via `GnosisChainClient.create_batch` (PostageStamp.createBatch on Gnosis), so the owner can sign its own stamps off-node. Body: `owner` (0x, never assumed = payer), `size`/`depth`, `duration_hours`, `immutable`. Returns `batchID` (64-hex, no 0x) + `txHash` + propagation info; records the batch in the ownership registry (`source="created_for_owner"`, informational — on-chain ownership is source of truth). **Spends the gateway's Gnosis funds**, so: OFF by default (`STAMP_PURCHASE_FOR_OTHERS_ENABLED`, router 404s when off); owner **allow-list** (`STAMP_FOR_OTHERS_REQUIRE_WHITELIST` + `_OWNER_WHITELIST`); hard caps `STAMP_FOR_OTHERS_MAX_DEPTH` / `_MAX_BZZ` / `_MAX_DURATION_HOURS` — ALL enforced before any on-chain spend. Plus a signer-wallet **preflight** (#231): refuses `503 SIGNER_INSUFFICIENT_FUNDS` if the gateway can't fund the batch (gas/xBZZ), checked after the caps and before createBatch. **x402 (#229):** mounted WITH the x402 dependency, so when `X402_ENABLED` the caller pays via the `/stamps/` protected prefix (priced from the actual depth/duration by reading the body in `_calculate_price_for_request`); free-tier creation is OFF by default (`STAMP_FOR_OTHERS_FREE_TIER_ENABLED`, else `402 FREE_TIER_DISABLED`). Payer (x402) ≠ owner (`body.owner`). Emits `gateway_for_owner_batches_total{status}` + `_bzz_spent_total` and audits each creation. See `docs/buy-batch-for-owner-guide.md`. **Stamp list query parameters**: @@ -160,6 +160,22 @@ CORS (browser access): - `estimatedReadyAt`: ISO 8601 timestamp when stamp should be usable (null for external stamps) - `propagationStatus`: `"ready"` / `"propagating"` / `"unknown"` (null if undetermined) +**Spending limits on the stamp endpoints** (`app/services/spend_budget.py`, #102): +Both `POST /stamps/` and `PATCH /stamps/{id}/extend` spend the gateway's BZZ for whoever asks, and neither had a bound — the pool got a daily allowance and these did not, which made them the cheaper way to spend the operator's money. Measured on staging, an anonymous free-tier request reached the point of the gateway costing a **243,074 BZZ** batch and was refused only because the wallet could not cover it: the balance was the limit. + +Two bounds now apply, answering different questions: + +- `X402_MAX_STAMP_BZZ` (default 5.0, zero disables) caps a **single request**, so no one call takes a large share of the wallet however it is shaped. This setting existed from the start and was referenced nowhere — a cap that appears in configuration and enforces nothing, which is worse than an absent one because it reads as protection during review. +- `STAMP_DAILY_BZZ_PER_CALLER` (default 0.5, `-1` disables) caps a **caller over a day**, so the first bound cannot simply be applied repeatedly. + +The budget counts **BZZ, not batches**, unlike the pool allowance. The pool hands out fixed inventory so counting batches per size bounds the spend; these endpoints take a depth and a duration and cost `amount × 2^depth`, so a count would let a caller stay inside its allowance and still spend arbitrarily by asking for bigger batches. + +The key is the **client IP**, not `Origin`. The callers here are CLIs, SDKs and the MCP plugin, which send no `Origin` at all and would collapse into one shared bucket. An IP is not an identity — shared behind NAT, cheap to change — and this is the same bargain `bandwidth_free_tier.py` already makes. It bounds casual and accidental spending, which is what actually happened twice, without pretending to prevent deliberate spending. + +Both limits are enforced **before** the wallet balance check, so the refusal does not depend on how much money happens to be left. Charged only after the money is actually spent, so a purchase Bee refuses costs the caller nothing. A settled x402 payment bypasses the daily budget but **not** the per-request ceiling — the gateway fronts the BZZ either way — and the bypass is withheld on a test network for the same reason as the pool's. + +`PLUR_PER_BZZ` and `plur_to_bzz` now live once, in `app/services/swarm_api.py`, and everything else imports them. There were five copies of the constant and two of the function — `app/x402/pricing.py`, `app/x402/preflight.py`, `app/services/gnosis_chain.py`, `app/api/endpoints/stamps_for_owner.py` and `swarm_api` itself. They all agreed, but nothing made them agree, and one drifting would have produced wrong money arithmetic in one place and not the others. The x402 modules re-export both, so existing imports and tests are unaffected. + **Stamp ownership enforcement** (`app/services/stamp_ownership.py`, when `X402_ENABLED`): Every batch a caller can obtain is registered to them — pool acquire, direct purchase, and for-owner all call `register_stamp`. Batches the pool buys for its own inventory are registered as `POOL_OWNER` (`"pool"`) at purchase and on sync, and `check_access` **refuses** them: a caller receives one by acquiring it, which re-registers it to them. A batch absent from the registry is also refused; `STAMP_OWNERSHIP_ALLOW_UNTRACKED=true` restores the old permissive default and exists solely to recover from a lost registry file. Before #312 the pool's inventory was untracked and the untracked default was *allow*, so anyone could store data on batches the gateway had paid for — one production batch reached 50% utilisation without ever being acquired. @@ -380,6 +396,8 @@ The gateway exposes a `/metrics` endpoint (Prometheus text format) when `METRICS - `gateway_downloads_total{status}` - `gateway_stamp_purchases_total{size, status}` - `gateway_pool_acquires_total{size, status}` +- `gateway_stamp_spend_refusals_total{operation, limit}` — purchases and extends refused by a spending limit (`limit` = `per_request` or `daily_budget`) +- `gateway_stamp_spend_bzz_total{operation, charged}` — BZZ committed through the stamp endpoints (`charged` = `budget` or `paid`) - `gateway_notary_signatures_total{status}` - `gateway_x402_payments_total{mode}` (paid/free/rejected) - `gateway_rate_limit_hits_total` @@ -392,6 +410,7 @@ The gateway exposes a `/metrics` endpoint (Prometheus text format) when `METRICS - `gateway_stamp_pool_available{size}`, `gateway_stamps_total` - `gateway_stamp_min_ttl_seconds`, `gateway_uptime_seconds` - `gateway_bandwidth_credit_accounts`, `gateway_bandwidth_credit_bytes_total` (when `CHUNK_UPLOAD_ENABLED`) +- `gateway_stamp_spend_callers`, `gateway_stamp_spend_bzz_today` — callers holding a spend balance today, and the BZZ charged to budgets so far. Polled rather than accumulated, because the day rolls over inside the tracker and a counter would keep climbing past midnight UTC. **Info**: `gateway_info{version, environment, x402_enabled, pool_enabled, notary_enabled, chunk_upload_enabled}` diff --git a/README.md b/README.md index cb1cf10..9bbbedd 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ Swarm Connect is a FastAPI-based API gateway that provides comprehensive access #### 🛡️ Security & Rate Limiting - **Upload Size Limits**: Configurable maximum upload size (default: 10 MB) with clear 413 errors - **Global Rate Limiting**: Per-IP sliding window rate limiter with burst capacity (default: 60 req/min + 10 burst) +- **Spending Limits**: Stamp purchases and extensions are bounded per request and per caller per day, so no caller can drain the gateway's BZZ - **Input Validation**: Strict regex validation on stamp IDs (64-char hex) and references (64-128 char hex) - **Error Sanitization**: Internal details (IPs, ports, file paths) are never exposed in error responses - **Server Header Suppression**: `Server` header removed to prevent version fingerprinting @@ -840,7 +841,7 @@ Perform a comprehensive health check on a stamp to determine if it can be used f ### Upload Size Limits -File uploads are limited to **10 MB** by default. This applies to both `/api/v1/data/` and `/api/v1/data/manifest` endpoints. +File uploads are limited to **10 MB** by default. This applies to both `/api/v1/data/` and `/api/v1/data/manifest` endpoints, and a file of exactly the limit is accepted — the multipart envelope that wraps it does not count against the limit. ```bash # Configure in .env (value in megabytes) @@ -852,6 +853,35 @@ Uploads exceeding the limit receive a **413** response: {"code": "FILE_TOO_LARGE", "message": "Upload exceeds maximum size of 10 MB.", "max_size_mb": 10} ``` +### Spending Limits + +`POST /api/v1/stamps/` and `PATCH /api/v1/stamps/{id}/extend` spend the gateway operator's BZZ on behalf of the caller. Two limits bound that, answering different questions: + +```bash +# Configure in .env +X402_MAX_STAMP_BZZ=5.0 # Most a single request may cost (0 disables) +STAMP_DAILY_BZZ_PER_CALLER=0.5 # Most one caller may spend per day (-1 disables) +``` + +The first stops any one request taking a large share of the wallet however it is shaped — batch cost scales with `amount x 2^depth`, so the accepted depth and duration ranges span orders of magnitude. The second stops the first simply being applied repeatedly. + +A request over the per-request ceiling receives **400**: +```json +{"code": "STAMP_COST_EXCEEDS_LIMIT", "message": "...", "cost_bzz": 12.5, "limit_bzz": 5.0} +``` + +A caller who has spent their daily budget receives **429**, with the reset time and what remains: +```json +{"code": "DAILY_SPEND_BUDGET_EXHAUSTED", "message": "...", "remaining_bzz": 0.02, + "daily_budget_bzz": 0.5, "resets_at": "2026-09-09T24:00:00Z"} +``` + +Both are checked before the wallet balance, so the answer does not depend on how much money happens to be left, and charged only after the money is actually spent — a purchase the Swarm node refuses costs the caller nothing. + +The budget counts **BZZ rather than batches**, because these endpoints take a depth and a duration: a count would let a caller stay inside their allowance and still spend arbitrarily by asking for larger batches. It is keyed on the **client IP**, since the callers here are CLIs, SDKs and MCP clients that send no `Origin`. An IP is not an identity — it is shared behind NAT and cheap to change — so this bounds casual and accidental spending rather than preventing deliberate spending. A caller who needs more can pay: a settled x402 payment bypasses the daily budget, though not the per-request ceiling. + +Counters `gateway_stamp_spend_refusals_total` and `gateway_stamp_spend_bzz_total` make refusals and committed spend visible in Prometheus, so a limit set too low shows up on a dashboard rather than in a complaint. + ### Rate Limiting Global per-IP rate limiting protects against abuse. Uses a sliding window algorithm with burst capacity. diff --git a/app/api/endpoints/data.py b/app/api/endpoints/data.py index 38c3ad6..41ac2d3 100644 --- a/app/api/endpoints/data.py +++ b/app/api/endpoints/data.py @@ -40,6 +40,11 @@ ) logger = logging.getLogger(__name__) + +# Slack allowed on Content-Length to cover the multipart envelope that wraps an +# uploaded file. 8 KB is far more than a boundary plus part headers need, and +# far less than any size that would matter for the limit itself. +MULTIPART_ENVELOPE_ALLOWANCE = 8 * 1024 router = APIRouter() @@ -274,10 +279,25 @@ async def upload_data( raise HTTPException(status_code=400, detail=detail) stamp_validate_ms = (time.perf_counter() - stamp_start) * 1000 - # Check upload size limit + # Check upload size limit. + # + # Content-Length covers the whole multipart envelope — boundary, part + # headers, trailer — not just the file. Comparing it against the file + # limit put the real ceiling a few hundred bytes below the documented + # one, so a file of exactly MAX_UPLOAD_SIZE_MB was always rejected with + # 413 while the check below, which measures the file itself, would have + # accepted it. The two checks were applying one limit to two different + # quantities. + # + # The allowance is generous relative to a real envelope (a boundary and + # one set of part headers is a few hundred bytes) because this check is + # only a coarse guard: it cannot reject early, since FastAPI parses the + # multipart form during dependency resolution and the body is already + # in memory by the time this line runs. The exact limit is enforced on + # the file's own length below. max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 content_length = request.headers.get("content-length") - if content_length and int(content_length) > max_size: + if content_length and int(content_length) > max_size + MULTIPART_ENVELOPE_ALLOWANCE: raise HTTPException( status_code=413, detail={ @@ -704,10 +724,25 @@ async def upload_manifest( raise HTTPException(status_code=400, detail=detail) stamp_validate_ms = (time.perf_counter() - stamp_start) * 1000 - # Check upload size limit + # Check upload size limit. + # + # Content-Length covers the whole multipart envelope — boundary, part + # headers, trailer — not just the file. Comparing it against the file + # limit put the real ceiling a few hundred bytes below the documented + # one, so a file of exactly MAX_UPLOAD_SIZE_MB was always rejected with + # 413 while the check below, which measures the file itself, would have + # accepted it. The two checks were applying one limit to two different + # quantities. + # + # The allowance is generous relative to a real envelope (a boundary and + # one set of part headers is a few hundred bytes) because this check is + # only a coarse guard: it cannot reject early, since FastAPI parses the + # multipart form during dependency resolution and the body is already + # in memory by the time this line runs. The exact limit is enforced on + # the file's own length below. max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 content_length = request.headers.get("content-length") - if content_length and int(content_length) > max_size: + if content_length and int(content_length) > max_size + MULTIPART_ENVELOPE_ALLOWANCE: raise HTTPException( status_code=413, detail={ diff --git a/app/api/endpoints/stamps.py b/app/api/endpoints/stamps.py index c907d8f..a4320cf 100644 --- a/app/api/endpoints/stamps.py +++ b/app/api/endpoints/stamps.py @@ -7,9 +7,16 @@ from app.core.config import settings from app.services import swarm_api +from app.services.swarm_api import plur_to_bzz from app.services.stamp_ownership import stamp_ownership_manager from app.services.stamp_tracker import record_purchase -from app.services.metrics import stamp_purchases_total +from app.services.spend_budget import spend_budget_tracker +from app.x402.middleware import get_client_ip +from app.services.metrics import ( + stamp_purchases_total, + stamp_spend_refusals_total, + stamp_spend_bzz_total, +) from app.api.models.stamp import ( StampDetails, StampPurchaseRequest, @@ -26,6 +33,83 @@ logger = logging.getLogger(__name__) +def _enforce_spend_limits(request: Request, cost_bzz: float, operation: str) -> Optional[str]: + """Bound what one request, and one caller in a day, may spend. + + Both stamp endpoints spend the gateway's BZZ for whoever asks. Two limits + apply, and they answer different questions: + + - `X402_MAX_STAMP_BZZ` bounds a SINGLE request, so no one call can take a + large share of the wallet however it is shaped. + - `STAMP_DAILY_BZZ_PER_CALLER` bounds a caller over a day, so the first + limit cannot simply be applied repeatedly. + + Returns the caller key to charge once the money is actually spent, or None + when the spend is not charged to anyone (a settled payment). Raises rather + than returning a failure, because every caller of this must stop. + """ + max_single = settings.X402_MAX_STAMP_BZZ + if max_single > 0 and cost_bzz > max_single: + logger.warning( + "Refusing %s costing %.6f BZZ, above the per-request limit of %.6f", + operation, cost_bzz, max_single, + ) + stamp_spend_refusals_total.labels(operation=operation, limit="per_request").inc() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "STAMP_COST_EXCEEDS_LIMIT", + "message": ( + f"This {operation} would cost {cost_bzz:.6f} BZZ, above the " + f"per-request limit of {max_single:.6f} BZZ. Ask for a smaller " + f"depth or a shorter duration." + ), + "cost_bzz": round(cost_bzz, 6), + "limit_bzz": max_single, + }, + ) + + # A settled payment is not drawn from the giveaway budget — the caller has + # funded it. Withheld on a test network for the same reason as the pool: + # testnet currency is free from a faucet, so honouring it there would + # replace a bounded giveaway with an unbounded one. + if getattr(request.state, "x402_mode", None) == "paid": + if settings.paid_bypass_is_honoured(): + stamp_spend_bzz_total.labels(operation=operation, charged="paid").inc(cost_bzz) + return None + logger.warning( + "Payment for %s settled on %s, which is a test network: the daily " + "spend budget still applies.", operation, settings.X402_NETWORK, + ) + + caller = get_client_ip(request) + allowed, info = spend_budget_tracker.check(caller, cost_bzz) + if not allowed: + logger.info( + "Daily spend budget exhausted for %s: %.6f of %.6f BZZ used, request needs %.6f", + caller, info["spent_bzz"], info["daily_budget_bzz"], cost_bzz, + ) + stamp_spend_refusals_total.labels(operation=operation, limit="daily_budget").inc() + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={ + "code": "DAILY_SPEND_BUDGET_EXHAUSTED", + "message": ( + f"This {operation} would cost {cost_bzz:.6f} BZZ and only " + f"{info['remaining_bzz']:.6f} BZZ remains of today's " + f"{info['daily_budget_bzz']:.6f} BZZ allowance. It resets at " + f"{info['resets_at']}. A smaller or shorter batch may still fit." + ), + "cost_bzz": info["request_cost_bzz"], + "daily_budget_bzz": info["daily_budget_bzz"], + "spent_bzz": info["spent_bzz"], + "remaining_bzz": info["remaining_bzz"], + "resets_at": info["resets_at"], + }, + ) + return caller + + def _bee_error_detail(exc: httpx.HTTPError): """Extract (status_code, message) from a failed Bee request. @@ -401,6 +485,13 @@ async def purchase_stamp( total_cost = swarm_api.calculate_stamp_total_cost(amount, effective_depth) funds_check = await swarm_api.check_sufficient_funds(total_cost) + # Bound the spend BEFORE the funds check, so the answer does not depend + # on how much money happens to be left. Refusing a 243,074 BZZ request + # for "insufficient funds" told the caller the wallet was the only limit, + # which was true and is the defect this closes. + cost_bzz = plur_to_bzz(total_cost) + charge_to = _enforce_spend_limits(request, cost_bzz, "stamp purchase") + if not funds_check["sufficient"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -418,6 +509,14 @@ async def purchase_stamp( label=stamp_request.label ) + # Charged only now: a purchase that failed downstream must not cost the + # caller their budget. + if charge_to is not None: + spend_budget_tracker.consume(charge_to, cost_bzz) + stamp_spend_bzz_total.labels( + operation="stamp purchase", charged="budget" + ).inc(cost_bzz) + # Record purchase time for propagation tracking record_purchase(batch_id) @@ -490,6 +589,7 @@ async def purchase_stamp( summary="Extend an Existing Swarm Postage Stamp" ) async def extend_stamp( + request: Request, stamp_id: str = Path(..., description="The Batch ID of the stamp to extend.", example="a1b2c3d4e5f6...", pattern=r"^[a-fA-F0-9]{64}$"), extension_request: StampExtensionRequest = ... ) -> Any: @@ -550,6 +650,14 @@ async def extend_stamp( total_cost = swarm_api.calculate_stamp_total_cost(amount, stamp_depth) funds_check = await swarm_api.check_sufficient_funds(total_cost) + # Extend is NOT in PROTECTED_ENDPOINTS — is_protected_endpoint matches on + # method, and this route is PATCH while only POST paths are listed — so + # there is no payment gate and no free-tier rate limit in front of it. + # It also tops up any batch on the node, including ones the caller does + # not own. The budget is therefore the only thing bounding it. + cost_bzz = plur_to_bzz(total_cost) + charge_to = _enforce_spend_limits(request, cost_bzz, "stamp extension") + if not funds_check["sufficient"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -566,6 +674,12 @@ async def extend_stamp( amount=amount ) + if charge_to is not None: + spend_budget_tracker.consume(charge_to, cost_bzz) + stamp_spend_bzz_total.labels( + operation="stamp extension", charged="budget" + ).inc(cost_bzz) + return StampExtensionResponse( batchID=batch_id, message="Postage stamp extended successfully" diff --git a/app/api/endpoints/stamps_for_owner.py b/app/api/endpoints/stamps_for_owner.py index 8e8ea89..b0dd46d 100644 --- a/app/api/endpoints/stamps_for_owner.py +++ b/app/api/endpoints/stamps_for_owner.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) router = APIRouter() -PLUR_PER_BZZ = 10 ** 16 +from app.services.swarm_api import PLUR_PER_BZZ # noqa: F401 @router.post( diff --git a/app/core/config.py b/app/core/config.py index f213778..309befb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -51,7 +51,23 @@ class Settings(BaseSettings): X402_CHEQUEBOOK_WARN_THRESHOLD: float = 5.0 # Warn if chequebook < threshold # === x402 Limits === + # Hard ceiling on what a single purchase or extend may cost the gateway. + # + # This setting existed from the start and was referenced nowhere — a cap + # that appears in configuration and enforces nothing, which is worse than an + # absent one because it reads as protection during review. It is enforced as + # of #102, on POST /stamps/ and PATCH /stamps/{id}/extend alike. + # Zero or negative disables it. X402_MAX_STAMP_BZZ: float = 5.0 # Max single stamp purchase in BZZ + + # Daily BZZ a single caller may spend through the stamp endpoints, keyed on + # client IP. -1 disables the bound. See app/services/spend_budget.py for why + # this counts money rather than batches, and why the key is the IP. + # + # 0.5 BZZ is roughly 25 small 24-hour batches a day, which is far more than + # any observed legitimate caller and far less than the wallet. + STAMP_DAILY_BZZ_PER_CALLER: float = 0.5 + STAMP_SPEND_BUDGET_STATE_FILE: str = "data/stamp_spend_budget.json" X402_RATE_LIMIT_PER_IP: int = 10 # Requests per minute per IP (for paying users) # === x402 Free Tier Settings === diff --git a/app/services/gnosis_chain.py b/app/services/gnosis_chain.py index 795c959..df27ad9 100644 --- a/app/services/gnosis_chain.py +++ b/app/services/gnosis_chain.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) BUCKET_DEPTH = 16 # fixed by the Swarm protocol -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +from app.services.swarm_api import PLUR_PER_BZZ # noqa: F401 # Verified contract/token addresses per chain (ethersphere/go-storage-incentives-abi). CHAIN_DEFAULTS = { diff --git a/app/services/metrics.py b/app/services/metrics.py index 02c5c89..ac5a31d 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -115,6 +115,36 @@ "gateway_bandwidth_topup_bytes_total", "Total bytes of bandwidth credit sold via top-ups" ) +# ── Spending limits (#102) ────────────────────────────────────────────────── +# +# Refusals are the signal that matters. The daily budget is a number chosen +# without usage data, so the only way to tell "correctly bounding abuse" from +# "turning away a legitimate integration" is to watch how often it fires and +# whether it is always the same caller. Without these, we would learn about a +# limit set too low from a complaint rather than a dashboard. +# +# No caller identity is exposed as a label: an IP is high-cardinality and would +# multiply the series, and it is personal data going to a third-party metrics +# store. The logs already name the caller for anyone diagnosing a specific case. +stamp_spend_refusals_total = Counter( + "gateway_stamp_spend_refusals_total", + "Stamp purchases and extensions refused by a spending limit", + ["operation", "limit"], +) +stamp_spend_bzz_total = Counter( + "gateway_stamp_spend_bzz_total", + "BZZ committed through the stamp endpoints, by whether it was charged to a budget", + ["operation", "charged"], +) +stamp_spend_callers = Gauge( + "gateway_stamp_spend_callers", + "Distinct callers holding a non-zero spend balance today", +) +stamp_spend_bzz_today = Gauge( + "gateway_stamp_spend_bzz_today", + "Total BZZ charged to daily budgets today, across all callers", +) + # ── Bandwidth credit gauges (updated by background poller) ─────────────────── bandwidth_credit_accounts = Gauge( @@ -284,6 +314,19 @@ async def _poll_balances(): except Exception as e: logger.debug(f"Metrics: failed to get bandwidth credit state: {e}") + # Daily spend budgets on the stamp endpoints (#102). + # + # Polled from the tracker rather than incremented at the call site, + # because the day rolls over inside it: a counter would keep + # climbing while the underlying balances reset at midnight UTC. + try: + from app.services.spend_budget import spend_budget_tracker + spent = spend_budget_tracker.snapshot()["spent"] + stamp_spend_callers.set(len(spent)) + stamp_spend_bzz_today.set(sum(spent.values())) + except Exception as e: + logger.debug(f"Metrics: failed to get spend budget state: {e}") + # Gnosis signer wallet balances (buy-batch-for-owner feature) if settings.STAMP_PURCHASE_FOR_OTHERS_ENABLED: try: diff --git a/app/services/spend_budget.py b/app/services/spend_budget.py new file mode 100644 index 0000000..336715b --- /dev/null +++ b/app/services/spend_budget.py @@ -0,0 +1,159 @@ +# app/services/spend_budget.py +"""Daily cap on how much of the gateway's BZZ one caller can spend. + +Two endpoints spend the operator's money on behalf of whoever asks: + +- `POST /api/v1/stamps/` buys a postage batch. +- `PATCH /api/v1/stamps/{id}/extend` tops one up. + +Neither had a spending bound. The pool got a daily allowance (#320, #326) and +these did not, which made them the cheaper way to spend the operator's money. +`X402_MAX_STAMP_BZZ` existed in configuration and was referenced nowhere, so the +only thing standing between a caller and the whole wallet was the wallet running +out. Measured on staging, an anonymous free-tier request reached the point of +the gateway costing a 243,074 BZZ batch and was refused for insufficient funds +rather than by any policy. + +## Why a BZZ budget rather than a count of batches + +The pool hands out fixed inventory, so counting batches per size bounds the +spend. These endpoints take a depth and a duration and cost `amount x 2^depth`, +which is continuous and spans orders of magnitude — a count would let a caller +stay inside its allowance and still spend arbitrarily by asking for bigger +batches. Counting the money directly is the only bound that means the same thing +whatever shape the request takes. + +## Why the client IP + +The pool keys on `Origin` because its consumer is a browser app. The callers +here are CLIs, SDKs and the MCP plugin, which send no `Origin` at all, so it +would collapse every one of them into a single shared bucket. The IP is what +distinguishes them. + +An IP is not an identity: it is shared behind NAT and cheap to change with a +proxy. This is the same bargain the free tier already makes elsewhere in the +gateway (`bandwidth_free_tier.py` bounds chunk uploads the same way). It bounds +casual and accidental spending — which is what actually happened, twice — and +raises the cost of deliberate spending without pretending to prevent it. A +caller who wants a real allowance pays, and paying bypasses this entirely. +""" +import json +import logging +import os +from datetime import datetime, timezone +from threading import Lock +from typing import Dict, Optional, Tuple + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Sentinel for "no limit", matching pool_allowance so the two read alike. +UNLIMITED = -1.0 + + +def _today() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +class SpendBudgetTracker: + """Tracks BZZ spent per caller per UTC day.""" + + def __init__(self, state_file: Optional[str] = None): + self._lock = Lock() + self._state_file = state_file + self._day = _today() + self._spent: Dict[str, float] = {} + self._load() + + # --- persistence ------------------------------------------------------- + # + # Persisted for the same reason as the pool allowance: without it a + # crash-looping gateway grants a fresh budget on every restart, which is + # precisely the shape of the incident this exists to prevent. + + def _path(self) -> str: + return self._state_file or settings.STAMP_SPEND_BUDGET_STATE_FILE + + def _load(self) -> None: + try: + path = self._path() + if not os.path.exists(path): + return + with open(path) as f: + data = json.load(f) + if data.get("day") == self._day: + self._spent = {k: float(v) for k, v in (data.get("spent") or {}).items()} + logger.info("Loaded spend budget state for %s: %s", self._day, self._spent) + except Exception as e: + # Never fail startup over a counter. + logger.warning("Could not load spend budget state: %s", e) + + def _save(self) -> None: + try: + path = self._path() + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + json.dump({"day": self._day, "spent": self._spent}, f) + os.replace(tmp, path) + except Exception as e: + logger.warning("Could not persist spend budget state: %s", e) + + # --- budget ------------------------------------------------------------ + + def _roll_day(self) -> None: + today = _today() + if today != self._day: + logger.info("Spend budget day rolled %s -> %s, resetting", self._day, today) + self._day = today + self._spent = {} + self._save() + + def budget(self) -> float: + return settings.STAMP_DAILY_BZZ_PER_CALLER + + def check(self, caller: str, cost_bzz: float) -> Tuple[bool, dict]: + """Whether this caller may spend `cost_bzz`, and the numbers behind it. + + Does not consume — call `consume` once the money has actually been + spent, so a purchase that fails downstream does not cost the caller + their budget. + + The check is against the cost of THIS request, not merely whether any + budget remains: a caller with 0.01 BZZ left must not be allowed to start + a 5 BZZ purchase. + """ + limit = self.budget() + with self._lock: + self._roll_day() + spent = self._spent.get(caller, 0.0) + + remaining = UNLIMITED if limit == UNLIMITED else max(0.0, limit - spent) + info = { + "caller": caller, + "daily_budget_bzz": limit, + "spent_bzz": round(spent, 6), + "remaining_bzz": remaining if limit == UNLIMITED else round(remaining, 6), + "request_cost_bzz": round(cost_bzz, 6), + "resets_at": f"{_today()}T24:00:00Z", + } + if limit == UNLIMITED: + return True, info + return (spent + cost_bzz) <= limit, info + + def consume(self, caller: str, cost_bzz: float) -> None: + with self._lock: + self._roll_day() + self._spent[caller] = self._spent.get(caller, 0.0) + cost_bzz + self._save() + + def snapshot(self) -> dict: + with self._lock: + self._roll_day() + return {"day": self._day, "spent": dict(self._spent)} + + +spend_budget_tracker = SpendBudgetTracker() diff --git a/app/services/swarm_api.py b/app/services/swarm_api.py index e951bda..4a86b9f 100644 --- a/app/services/swarm_api.py +++ b/app/services/swarm_api.py @@ -1227,6 +1227,27 @@ def calculate_stamp_total_cost(amount: int, depth: int) -> int: return amount * (2 ** depth) +# BZZ is denominated in PLUR on chain. +# +# Defined here, in the lowest layer, and imported by everything else that needs +# it. There were five separate copies of this constant and two of the function +# before #102 — app/x402/pricing.py, app/x402/preflight.py, +# app/services/gnosis_chain.py, app/api/endpoints/stamps_for_owner.py and this +# module. They all agreed, but nothing made them agree, and a single one drifting +# would have produced wrong money arithmetic in one place and not the others. +# +# Named rather than inlined so callers needing a cost in BZZ can convert it +# themselves rather than reading it out of check_sufficient_funds' response: a +# partial mock of that function omitting a key should not be able to turn a +# spending limit into a 500. +PLUR_PER_BZZ = 10 ** 16 + + +def plur_to_bzz(plur: int) -> float: + """Convert an on-chain PLUR amount to BZZ.""" + return plur / PLUR_PER_BZZ + + async def check_sufficient_funds(required_plur: int) -> Dict[str, Any]: """ Checks if the wallet has sufficient BZZ funds for a stamp purchase. @@ -1249,9 +1270,8 @@ async def check_sufficient_funds(required_plur: int) -> Dict[str, Any]: wallet_info = await get_wallet_info() wallet_balance_plur = int(wallet_info.get("bzzBalance", 0)) - plur_per_bzz = 10 ** 16 - wallet_balance_bzz = wallet_balance_plur / plur_per_bzz - required_bzz = required_plur / plur_per_bzz + wallet_balance_bzz = plur_to_bzz(wallet_balance_plur) + required_bzz = plur_to_bzz(required_plur) sufficient = wallet_balance_plur >= required_plur shortfall_bzz = 0.0 if sufficient else required_bzz - wallet_balance_bzz diff --git a/app/x402/preflight.py b/app/x402/preflight.py index 192b618..a14b908 100644 --- a/app/x402/preflight.py +++ b/app/x402/preflight.py @@ -20,15 +20,12 @@ logger = logging.getLogger(__name__) # Conversion constants -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +# Single source in app/services/swarm_api; re-exported here because callers +# and tests import it from this module. +from app.services.swarm_api import PLUR_PER_BZZ, plur_to_bzz # noqa: F401 WEI_PER_XDAI = 10 ** 18 # 1 xDAI = 10^18 wei -def plur_to_bzz(plur: int) -> float: - """Convert PLUR to BZZ.""" - return plur / PLUR_PER_BZZ - - def wei_to_xdai(wei: int) -> float: """Convert wei to xDAI.""" return wei / WEI_PER_XDAI diff --git a/app/x402/pricing.py b/app/x402/pricing.py index d143fd8..291c8e1 100644 --- a/app/x402/pricing.py +++ b/app/x402/pricing.py @@ -26,15 +26,12 @@ logger = logging.getLogger(__name__) # Conversion constants -PLUR_PER_BZZ = 10 ** 16 # 1 BZZ = 10^16 PLUR +# Single source in app/services/swarm_api; re-exported here because callers +# and tests import it from this module. +from app.services.swarm_api import PLUR_PER_BZZ, plur_to_bzz # noqa: F401 BYTES_PER_GB = 10 ** 9 # 1 GB = 10^9 bytes (for bandwidth pricing) -def plur_to_bzz(plur: int) -> float: - """Convert PLUR to BZZ.""" - return plur / PLUR_PER_BZZ - - def bzz_to_usd(bzz: float, rate: Optional[float] = None) -> float: """ Convert BZZ to USD using configured or provided exchange rate. diff --git a/tests/conftest.py b/tests/conftest.py index 94a7c2a..2acbb8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,3 +10,40 @@ # from interfering with test assertions. Rate limiter unit tests # test the component directly without relying on middleware. os.environ["RATE_LIMIT_ENABLED"] = "false" + +# The daily spend budget lives in a module-level singleton with persisted state, +# so without this every purchase and extend in the suite charges the same caller +# ("testclient") and the budget is exhausted partway through — turning unrelated +# tests into 429s depending on how many ran before them. +# +# Reset per test, and pointed at a temporary file so a test run never writes to +# the real state path. Tests that exercise the budget itself construct their own +# tracker and are unaffected. +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_spend_budget(tmp_path, monkeypatch): + from app.services import spend_budget + + from app.core.config import settings + + # Unlimited, so the limit never decides the outcome of a test about + # something else — several suites purchase at the top of the valid amount + # range, which costs far more than any plausible budget. check() and + # consume() still run on every purchase and extend, so the plumbing stays + # covered; only the refusal is off. Tests about the budget set their own. + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", -1.0) + + # Same reasoning for the per-request ceiling. Several suites deliberately + # purchase at the top of the valid depth and amount ranges to test that + # validation accepts them; at the production default of 5 BZZ those requests + # legitimately cost more than the cap allows, and the test would then be + # asserting the cap rather than the validation it was written for. + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) # 0 disables the cap + + tracker = spend_budget.SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + monkeypatch.setattr(spend_budget, "spend_budget_tracker", tracker) + import app.api.endpoints.stamps as stamps_ep + monkeypatch.setattr(stamps_ep, "spend_budget_tracker", tracker) + yield tracker diff --git a/tests/test_spend_budget.py b/tests/test_spend_budget.py new file mode 100644 index 0000000..66b07e2 --- /dev/null +++ b/tests/test_spend_budget.py @@ -0,0 +1,631 @@ +"""Bounds on what a caller can spend of the gateway's BZZ. + +`POST /api/v1/stamps/` and `PATCH /api/v1/stamps/{id}/extend` both spend the +operator's money for whoever asks. Neither had a spending bound: measured on the +staging gateway, an anonymous free-tier request reached the point of the gateway +costing a 243,074 BZZ batch and was refused only because the wallet could not +cover it. The wallet balance was the limit. + +Extend is the softer of the two — `is_protected_endpoint` matches on method, and +only POST routes are listed, so a PATCH is not payment-gated at all and does not +even meet the free-tier rate limit. It also tops up any batch on the node, +including ones the caller does not own. + +Two limits, answering different questions: a per-request ceiling so no single +call takes a large share of the wallet, and a per-caller daily budget so the +first cannot simply be applied repeatedly. +""" +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.main import app +from app.services.spend_budget import SpendBudgetTracker, UNLIMITED +from prometheus_client import REGISTRY + +STAMP_ID = "a" * 64 + +# depth 17 at this price is a fraction of a BZZ; the tests set costs explicitly +# via the chainstate price where the exact figure matters. +CHAINSTATE = {"currentPrice": "24000", "block": 1, "chainTip": 1, "totalAmount": "1"} +FUNDS_OK = {"sufficient": True, "wallet_balance_bzz": 100.0, + "required_bzz": 0.01, "shortfall_bzz": 0.0} + + +@pytest.fixture +def tracker(tmp_path, monkeypatch): + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "spend_budget_tracker", t) + import app.api.endpoints.stamps as stamps_ep + monkeypatch.setattr(stamps_ep, "spend_budget_tracker", t) + return t + + +class TestBudgetArithmetic: + def test_a_request_that_would_overrun_is_refused_before_it_starts(self, tracker, monkeypatch): + """Not "is any budget left" but "does THIS request fit". + + A caller with 0.01 BZZ remaining must not be allowed to begin a 5 BZZ + purchase; checking only for a non-zero remainder would let them. + """ + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + tracker.consume("1.2.3.4", 0.99) + assert not tracker.check("1.2.3.4", 0.5)[0] + assert tracker.check("1.2.3.4", 0.005)[0] + + def test_spending_exactly_the_budget_is_allowed(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + assert tracker.check("1.2.3.4", 1.0)[0] + tracker.consume("1.2.3.4", 1.0) + assert not tracker.check("1.2.3.4", 0.000001)[0] + + def test_callers_have_separate_budgets(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + tracker.consume("1.2.3.4", 1.0) + assert not tracker.check("1.2.3.4", 0.1)[0] + assert tracker.check("5.6.7.8", 0.1)[0], "one caller must not spend another's budget" + + def test_unlimited_is_the_pre_existing_behaviour(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + tracker.consume("1.2.3.4", 10_000.0) + assert tracker.check("1.2.3.4", 10_000.0)[0] + + def test_a_restart_does_not_grant_a_fresh_budget(self, tmp_path, monkeypatch): + """A crash loop must not hand out a full budget per restart — that is the + shape of the incident this exists to prevent.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = str(tmp_path / "spend.json") + first = SpendBudgetTracker(state_file=path) + first.consume("1.2.3.4", 0.9) + + second = SpendBudgetTracker(state_file=path) + assert not second.check("1.2.3.4", 0.5)[0] + assert second.check("1.2.3.4", 0.05)[0] + + def test_state_from_a_previous_day_is_ignored(self, tmp_path, monkeypatch): + import json + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + path.write_text(json.dumps({"day": "1999-01-01", "spent": {"1.2.3.4": 999.0}})) + assert SpendBudgetTracker(state_file=str(path)).check("1.2.3.4", 0.9)[0] + + def test_an_unreadable_state_file_does_not_break_startup(self, tmp_path, monkeypatch): + """Never fail to start over a counter.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + path.write_text("{not json") + assert SpendBudgetTracker(state_file=str(path)).check("1.2.3.4", 0.5)[0] + + +class TestPerRequestCeiling: + """X402_MAX_STAMP_BZZ was in configuration from the start and referenced + nowhere — a cap that reads as protection during review and enforces + nothing.""" + + def _purchase(self, depth=20, duration=8760): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post( + "/api/v1/stamps/", + json={"depth": depth, "duration_hours": duration}, + ) + + def test_an_expensive_request_is_refused_on_policy(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + r = self._purchase() + assert r.status_code == 400 + d = r.json()["detail"] + assert d["code"] == "STAMP_COST_EXCEEDS_LIMIT" + assert d["limit_bzz"] == 0.001 + assert d["cost_bzz"] > d["limit_bzz"] + + def test_the_refusal_does_not_depend_on_the_wallet_balance(self, tracker, monkeypatch): + """The old answer was "insufficient funds", which told the caller the + wallet was the only limit. It was, and that is the defect.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + rich = {"sufficient": True, "wallet_balance_bzz": 10 ** 9, + "required_bzz": 0.01, "shortfall_bzz": 0.0} + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=rich)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 20, "duration_hours": 8760}) + assert r.status_code == 400 + assert r.json()["detail"]["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + def test_zero_disables_the_ceiling(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._purchase().status_code == 201 + + def test_a_cheap_request_is_unaffected(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 5.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._purchase(depth=17, duration=24).status_code == 201 + + +class TestPurchaseEndpoint: + def _purchase(self, depth=17, duration=24): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post( + "/api/v1/stamps/", + json={"depth": depth, "duration_hours": duration}, + ) + + def test_the_budget_stops_repeated_purchases(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.05) + + allowed = 0 + for _ in range(40): + if self._purchase().status_code != 201: + break + allowed += 1 + else: + pytest.fail("the budget never refused a purchase") + + assert allowed > 0, "the budget refused the very first purchase" + assert tracker.snapshot()["spent"] + + def test_the_refusal_says_what_a_caller_can_do(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + r = self._purchase() + assert r.status_code == 429 + d = r.json()["detail"] + assert d["code"] == "DAILY_SPEND_BUDGET_EXHAUSTED" + assert d["resets_at"] + assert d["remaining_bzz"] == 0 + assert "resets" in d["message"] + assert str(d["daily_budget_bzz"]) in d["message"], "the number is not taken from config" + + def test_a_failed_purchase_does_not_spend_the_budget(self, tracker, monkeypatch): + """Bee refusing must not cost the caller their allowance.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 10.0) + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(side_effect=RuntimeError("bee said no"))): + TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + assert tracker.snapshot()["spent"] == {}, "a failed purchase charged the caller" + + +class TestExtendEndpoint: + """Extend is not in PROTECTED_ENDPOINTS — is_protected_endpoint matches on + method and only POST paths are listed — so it has no payment gate and no + free-tier rate limit. The budget is the only thing bounding it.""" + + def _extend(self, duration=24): + existing = [{"batchID": STAMP_ID, "depth": 17, "batchTTL": 86400}] + with patch("app.services.swarm_api.get_all_stamps_processed", + new=AsyncMock(return_value=existing)), \ + patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.extend_postage_stamp", + new=AsyncMock(return_value=STAMP_ID)): + return TestClient(app).patch( + f"/api/v1/stamps/{STAMP_ID}/extend", + json={"duration_hours": duration}, + ) + + def test_extend_is_bounded_too(self, tracker, monkeypatch): + """Bounding purchase alone would leave the cheaper path open.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + r = self._extend() + assert r.status_code == 429 + assert r.json()["detail"]["code"] == "DAILY_SPEND_BUDGET_EXHAUSTED" + + def test_extend_draws_on_the_same_budget_as_purchase(self, tracker, monkeypatch): + """Separate budgets would double what a caller can spend.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert self._extend().status_code == 200 + spent = tracker.snapshot()["spent"] + assert list(spent) == ["testclient"], spent + assert spent["testclient"] > 0 + + +class TestPaidCallersBypass: + def test_a_settled_payment_is_not_drawn_from_the_giveaway_budget(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + # Drive the real helper with the payment state the dependency would set. + import app.api.endpoints.stamps as stamps_ep + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {} + self.client = None + + # None means "charge nobody" — the caller funded it themselves. + assert stamps_ep._enforce_spend_limits(_Req(), 5.0, "stamp purchase") is None + assert tracker.snapshot()["spent"] == {"testclient": 0.001}, "the payer was charged" + + def test_a_testnet_payment_does_not_bypass(self, tracker, monkeypatch): + """Testnet currency is free from a faucet, so a payment settled there is + not evidence anyone paid — the same reasoning as the pool bypass.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base-sepolia") + monkeypatch.setattr(settings, "X402_ALLOW_TESTNET_PAID_BYPASS", False) + tracker.consume("1.2.3.4", 0.001) + + import app.api.endpoints.stamps as stamps_ep + from fastapi import HTTPException + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {"X-Forwarded-For": "1.2.3.4"} + self.client = None + + with pytest.raises(HTTPException) as e: + stamps_ep._enforce_spend_limits(_Req(), 0.5, "stamp purchase") + assert e.value.status_code == 429 + + def test_the_per_request_ceiling_applies_to_paid_callers_too(self, tracker, monkeypatch): + """The gateway fronts the BZZ either way, so one transaction's exposure + is bounded regardless of who is paying.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 1.0) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + + import app.api.endpoints.stamps as stamps_ep + from fastapi import HTTPException + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode="paid") + self.headers = {} + self.client = None + + with pytest.raises(HTTPException) as e: + stamps_ep._enforce_spend_limits(_Req(), 5.0, "stamp purchase") + assert e.value.status_code == 400 + assert e.value.detail["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + +def _counter(name, **labels): + """Current value of a labelled counter, or 0 before it is first touched.""" + v = REGISTRY.get_sample_value(name, labels) + return 0.0 if v is None else v + + +class TestMetrics: + """The budget is a number chosen without usage data. + + Refusals are how we tell "correctly bounding abuse" from "turning away a + legitimate integration", so they have to be visible somewhere other than the + logs. These pin that the counters actually move. + """ + + def _purchase(self): + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + return TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + + def test_a_budget_refusal_is_counted(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="daily_budget") + assert self._purchase().status_code == 429 + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="daily_budget") + assert after == before + 1 + + def test_a_ceiling_refusal_is_counted_separately(self, tracker, monkeypatch): + """The two limits mean different things — one number for both would not + say whether the cap is too low or the budget is.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="per_request") + assert self._purchase().status_code == 400 + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp purchase", limit="per_request") + assert after == before + 1 + + def test_committed_bzz_is_counted(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert self._purchase().status_code == 201 + after = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert after > before, "the BZZ committed was not recorded" + + def test_a_refused_purchase_records_no_spend(self, tracker, monkeypatch): + """A refusal must not look like money going out the door.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + + before = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert self._purchase().status_code == 400 + after = _counter("gateway_stamp_spend_bzz_total", + operation="stamp purchase", charged="budget") + assert after == before + + def test_no_caller_identity_leaks_into_a_label(self, tracker, monkeypatch): + """An IP is high-cardinality and is personal data going to a + third-party metrics store. The logs name the caller; the metrics + must not.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + tracker.consume("testclient", 0.001) + self._purchase() + + body = TestClient(app).get("/metrics").text + for line in body.splitlines(): + if line.startswith("gateway_stamp_spend"): + assert "testclient" not in line, line + + +class TestDayRollover: + """"Daily" is the whole contract. + + If the reset failed, a caller would be locked out permanently after their + first day rather than for the rest of it — a limit that never releases is a + different product from one that resets, and nothing else in the suite + exercised a live rollover: the other test loads yesterday's file at startup, + which is a different code path. + """ + + def test_a_running_tracker_resets_when_the_day_turns(self, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + t.consume("1.2.3.4", 1.0) + assert not t.check("1.2.3.4", 0.5)[0] + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + + allowed, info = t.check("1.2.3.4", 1.0) + assert allowed, "the budget did not reset when the day turned" + assert info["spent_bzz"] == 0 + assert info["resets_at"].startswith("2099-01-01") + + def test_the_reset_is_persisted_not_just_in_memory(self, tmp_path, monkeypatch): + """Otherwise a restart just after midnight would reload yesterday's + spend and re-apply it to the new day.""" + import json + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + path = tmp_path / "spend.json" + t = SpendBudgetTracker(state_file=str(path)) + t.consume("1.2.3.4", 1.0) + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + t.check("1.2.3.4", 0.1) + + on_disk = json.loads(path.read_text()) + assert on_disk["day"] == "2099-01-01" + assert on_disk["spent"] == {} + + def test_snapshot_also_rolls_the_day(self, tmp_path, monkeypatch): + """The metrics gauges read through snapshot(); a stale day there would + report yesterday's total as today's.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + t.consume("1.2.3.4", 0.5) + + from app.services import spend_budget + monkeypatch.setattr(spend_budget, "_today", lambda: "2099-01-01") + assert t.snapshot() == {"day": "2099-01-01", "spent": {}} + + +class TestDurability: + def test_a_request_survives_an_unwritable_state_file(self, tmp_path, monkeypatch): + """Losing the counter is recoverable; failing the request is not. A full + or read-only disk must not stop the gateway selling stamps.""" + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + + def boom(*a, **k): + raise OSError("read-only file system") + + monkeypatch.setattr("builtins.open", boom) + t.consume("1.2.3.4", 0.1) # must not raise + monkeypatch.undo() + + # The in-memory count still moved, so the budget holds for this process. + assert t.check("1.2.3.4", 0.95)[0] is False + + def test_concurrent_requests_cannot_overspend(self, tmp_path, monkeypatch): + """This is a money path and the tracker is shared across threads. + + Without the lock, interleaved read-modify-write on the same caller loses + updates, and the recorded spend comes out lower than what was actually + committed — which is the direction that costs money. + """ + import threading + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", -1.0) + t = SpendBudgetTracker(state_file=str(tmp_path / "spend.json")) + + def spend(): + for _ in range(50): + t.consume("1.2.3.4", 0.01) + + threads = [threading.Thread(target=spend) for _ in range(8)] + for th in threads: + th.start() + for th in threads: + th.join() + + assert t.snapshot()["spent"]["1.2.3.4"] == pytest.approx(8 * 50 * 0.01) + + +class TestBothLimitsApplyToBothEndpoints: + """The two endpoints were bounded in one change, so it is easy for a later + edit to fix or break one and not the other. These pin the symmetry.""" + + def _extend(self): + existing = [{"batchID": STAMP_ID, "depth": 17, "batchTTL": 86400}] + with patch("app.services.swarm_api.get_all_stamps_processed", + new=AsyncMock(return_value=existing)), \ + patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.extend_postage_stamp", + new=AsyncMock(return_value=STAMP_ID)): + return TestClient(app).patch(f"/api/v1/stamps/{STAMP_ID}/extend", + json={"duration_hours": 8760}) + + def test_the_per_request_ceiling_applies_to_extend(self, tracker, monkeypatch): + """Only the daily budget was covered on this endpoint before.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + r = self._extend() + assert r.status_code == 400 + assert r.json()["detail"]["code"] == "STAMP_COST_EXCEEDS_LIMIT" + + def test_the_refusal_names_the_operation(self, tracker, monkeypatch): + """A caller seeing "stamp purchase" on an extend has been told the wrong + thing about what they just did.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + assert "extension" in self._extend().json()["detail"]["message"] + + def test_a_refused_extend_is_counted_under_its_own_operation(self, tracker, monkeypatch): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0000001) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + before = _counter("gateway_stamp_spend_refusals_total", + operation="stamp extension", limit="per_request") + self._extend() + after = _counter("gateway_stamp_spend_refusals_total", + operation="stamp extension", limit="per_request") + assert after == before + 1 + + +class TestOnlyASettledPaymentBypasses: + """`paid` is one of several x402 modes. Treating anything non-None as paid + would hand the bypass to every free-tier caller, which is the population the + budget exists for.""" + + def _helper_with_mode(self, mode): + import app.api.endpoints.stamps as stamps_ep + from types import SimpleNamespace + + class _Req: + def __init__(self): + self.state = SimpleNamespace(x402_mode=mode) + self.headers = {} + self.client = None + + return stamps_ep, _Req() + + @pytest.mark.parametrize("mode", ["free", "free-tier", "rejected", None]) + def test_a_non_paid_mode_is_still_charged(self, tracker, monkeypatch, mode): + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", UNLIMITED) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + stamps_ep, req = self._helper_with_mode(mode) + assert stamps_ep._enforce_spend_limits(req, 0.1, "stamp purchase") is not None, \ + f"mode {mode!r} was treated as a settled payment" + + def test_the_handler_passes_the_live_request_to_the_limits(self, tracker, monkeypatch): + """The other paid tests drive the helper with a stand-in request, so + they would still pass if the handler forgot to pass the real one and the + payment state never reached the check. + + Mutating app middleware to simulate a settled payment was tried and + leaked into later tests in the same file. This wraps the real helper + instead: it sets the payment state on whatever request the handler + actually passed, then calls through, so both the wiring and the bypass + are exercised without touching global app state. + """ + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + import app.api.endpoints.stamps as stamps_ep + real = stamps_ep._enforce_spend_limits + seen = {} + + def as_paid(request, cost_bzz, operation): + seen["request"] = request + request.state.x402_mode = "paid" + return real(request, cost_bzz, operation) + + monkeypatch.setattr(stamps_ep, "_enforce_spend_limits", as_paid) + + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + + from starlette.requests import Request as StarletteRequest + assert isinstance(seen.get("request"), StarletteRequest), \ + "the handler did not pass the live request to the spending check" + assert r.status_code == 201, r.text + assert tracker.snapshot()["spent"] == {"testclient": 0.001}, \ + "a settled payment was charged to the giveaway budget" + + def test_an_exhausted_budget_still_refuses_without_a_payment(self, tracker, monkeypatch): + """The control for the test above: same setup, no payment state set, so + a pass there cannot be the budget quietly failing to apply.""" + monkeypatch.setattr(settings, "X402_MAX_STAMP_BZZ", 0.0) + monkeypatch.setattr(settings, "STAMP_DAILY_BZZ_PER_CALLER", 0.001) + monkeypatch.setattr(settings, "X402_NETWORK", "base") + tracker.consume("testclient", 0.001) + + with patch("app.services.swarm_api.get_chainstate", + new=AsyncMock(return_value=CHAINSTATE)), \ + patch("app.services.swarm_api.check_sufficient_funds", + new=AsyncMock(return_value=FUNDS_OK)), \ + patch("app.services.swarm_api.purchase_postage_stamp", + new=AsyncMock(return_value="b" * 64)): + r = TestClient(app).post("/api/v1/stamps/", + json={"depth": 17, "duration_hours": 24}) + assert r.status_code == 429 diff --git a/tests/test_upload_size_limit.py b/tests/test_upload_size_limit.py index 3959fbe..9cac451 100644 --- a/tests/test_upload_size_limit.py +++ b/tests/test_upload_size_limit.py @@ -48,31 +48,76 @@ def test_upload_exceeding_limit_returns_413(self, mock_settings, mock_upload): @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") @patch('app.api.endpoints.data.settings') def test_upload_at_exact_limit_succeeds(self, mock_settings, mock_upload): - """File just under the size limit should be accepted.""" + """A file of exactly MAX_UPLOAD_SIZE_MB is accepted. + + This test used to send 1 MB against a 2 MB limit and call that "exact", + with a comment explaining that multipart overhead meant the file had to + be "well under the limit". That comment described the bug: Content-Length + includes the multipart envelope, and it was being compared against the + limit that applies to the file, so the advertised ceiling was unreachable + by a few hundred bytes. Testing a value nowhere near the boundary is what + let it survive. + """ mock_settings.MAX_UPLOAD_SIZE_MB = 2 - # 1 MB file — under 2 MB limit (multipart encoding adds overhead - # to Content-Length, so file must be well under the limit) - data = b"x" * (1 * 1024 * 1024) + data = b"x" * (2 * 1024 * 1024) response = client.post( f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", files={"file": ("exact.bin", io.BytesIO(data), "application/octet-stream")} ) - assert response.status_code == 200 + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_one_byte_over_the_limit_is_still_rejected(self, mock_settings, mock_upload): + """The envelope allowance must not become slack in the limit itself. + + Content-Length gets an 8 KB allowance so the envelope does not count + against the file, but the file's own length is still measured exactly. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 1) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_a_long_filename_does_not_eat_into_the_limit(self, mock_settings, mock_upload): + """The envelope varies with the filename, so the ceiling must not. + + A caller uploading a file at the limit should not be rejected because + the name they chose is long. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("a" * 200 + ".bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 200, response.text @patch('app.api.endpoints.data.settings') def test_content_length_header_rejection(self, mock_settings): - """Content-Length header exceeding limit should cause fast reject.""" + """A declared Content-Length well over the limit is rejected. + + The declared length must exceed the limit by more than the envelope + allowance, or this asserts nothing: a value one byte over now falls + inside the slack that exists so the multipart wrapper does not count + against the file. + """ mock_settings.MAX_UPLOAD_SIZE_MB = 1 max_bytes = 1 * 1024 * 1024 - # Send small actual data but large Content-Length header data = b"x" * 100 response = client.post( f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", files={"file": ("test.bin", io.BytesIO(data), "application/octet-stream")}, - headers={"content-length": str(max_bytes + 1)} + headers={"content-length": str(max_bytes * 2)} ) - # TestClient may override content-length, so accept either 413 or 200 - assert response.status_code in [200, 413] + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" class TestManifestUploadSizeLimit: @@ -105,6 +150,96 @@ def test_manifest_within_limit_succeeds(self, mock_validate, mock_count, mock_up assert response.status_code == 200 + @patch('app.api.endpoints.data.upload_collection_to_swarm', return_value="ref456") + @patch('app.api.endpoints.data.count_tar_files', return_value=1) + @patch('app.api.endpoints.data.validate_tar') + @patch('app.api.endpoints.data.settings') + def test_manifest_at_exact_limit_succeeds(self, mock_settings, mock_validate, + mock_count, mock_upload): + """The manifest endpoint carried the same defect as the data endpoint. + + Both compare the request's Content-Length — which covers the multipart + envelope — against the limit that applies to the file itself. Only the + data endpoint had a boundary test, so the manifest side was fixed + without anything proving it. An archive at exactly the ceiling must be + accepted. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("exact.tar", io.BytesIO(data), "application/x-tar")} + ) + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_collection_to_swarm', return_value="ref456") + @patch('app.api.endpoints.data.count_tar_files', return_value=1) + @patch('app.api.endpoints.data.validate_tar') + @patch('app.api.endpoints.data.settings') + def test_manifest_one_byte_over_is_still_rejected(self, mock_settings, mock_validate, + mock_count, mock_upload): + """The envelope allowance must not become slack in the limit.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 1) + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.tar", io.BytesIO(data), "application/x-tar")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + @patch('app.api.endpoints.data.settings') + def test_manifest_content_length_far_over_is_rejected(self, mock_settings): + """Declared length beyond the allowance short-circuits on Content-Length.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 1 + response = client.post( + f"/api/v1/data/manifest?stamp_id={VALID_STAMP_ID}", + files={"file": ("t.tar", io.BytesIO(b"x" * 100), "application/x-tar")}, + headers={"content-length": str(2 * 1024 * 1024)} + ) + assert response.status_code == 413 + + +class TestEnvelopeAllowance: + """What the allowance is and is not. + + It exists so the multipart wrapper does not count against the file. It must + not become slack in the limit itself, and it must not vary with anything the + caller controls. + """ + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_a_long_field_name_does_not_shrink_the_ceiling(self, mock_settings, mock_upload): + """The envelope grows with the field name as well as the filename.""" + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("f" * 180 + ".bin", io.BytesIO(data), "application/octet-stream")}, + data={"unused" * 20: "y" * 500}, + ) + assert response.status_code == 200, response.text + + @patch('app.api.endpoints.data.upload_data_to_swarm', return_value="ref123") + @patch('app.api.endpoints.data.settings') + def test_the_allowance_is_not_extra_capacity(self, mock_settings, mock_upload): + """A file inside the 8 KB allowance but over the limit is still refused. + + This is the failure mode of the fix: widening Content-Length by 8 KB + would raise the real ceiling by 8 KB if the exact check were not also + applied to the file's own length. + """ + mock_settings.MAX_UPLOAD_SIZE_MB = 2 + data = b"x" * (2 * 1024 * 1024 + 4096) + response = client.post( + f"/api/v1/data/?stamp_id={VALID_STAMP_ID}", + files={"file": ("over.bin", io.BytesIO(data), "application/octet-stream")} + ) + assert response.status_code == 413 + assert response.json()["detail"]["code"] == "FILE_TOO_LARGE" + + class TestConfigurableLimit: """Tests that the limit is configurable via settings."""