Repository navigation
Conversation
PostgreSQL refuses a NUL in text and jsonb, and jsonb a lone surrogate, so either one in a /settle body failed the store write with a 500 before money moved. Both are now a 400 on /verify and /settle, wherever they sit in the body, keys included. A test also pins that /verify sends no CORS headers, as /settle already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Behind Cloudflare Tunnel the client IP is CF-Connecting-IP, which Cloudflare sets on every request and refuses from a client (measured through a quick tunnel). CLIENT_IP_HEADER names it; unset, the client is the socket address. The seller's server calls /verify and /settle for all of its buyers, and settles after serving, so the budgets are per seller: 600 each by default, and /settle's never below /verify's. The pubnet refusal now waits for 0024, 0025 and 0050. BREAKING CHANGE: TRUSTED_PROXY_HOPS is removed and refused at startup; set CLIENT_IP_HEADER instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/verify, /settle and /discovery/* each get a budget per client and window, counted in RateLimitStore before the body is read; past it the route answers 429 with Retry-After. An IPv6 client counts per /56. A store error is a 500. A client over budget is answered from the process until its window ends, so a flood doesn't write to the store on every request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The facilitator README says who may call which route and what the limits are; the spine and the M1 epics take the client IP header in place of proxy hops. The task records where each decision lives in the code and the flood measurement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iting-and-http-hardening
Windows are aligned to the clock, so a minute boundary between two requests of a test reset the count and turned an expected 429 into a 400. An hour's window makes that all but impossible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client can send twice its budget across a window's end, and every blocked client is released at once. Decision 6 no longer names an option of the middleware this branch dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header's value went into the store key unchecked: a header sent twice arrived joined, and a long value failed the key index with a 500 after a store round trip. Anything but one address is now a 500 with client_ip_invalid, without the value and without a store hit. Requests already in flight when a client ran out of budget each logged rate_limited; now only the first does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Header lookups ignore case, so lowercasing the name did nothing, and a wrong name already fails loudly on the first request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cloudflare gives every cross-zone Worker request the same CF-Connecting-IP, so sellers on Workers share one rate-limit budget. Task 0060 records it as the reason to land API keys before the public testnet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Left unset behind the tunnel, every client was cloudflared's address and shared one budget, with nothing logged. The setting is now required: a header name, or socket for the connection's own address with no proxy in front, as in local runs and tests. BREAKING CHANGE: CLIENT_IP_HEADER must be set; use socket where no proxy sits in front of the service. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iting-and-http-hardening # Conflicts: # docs/architecture/m1-spine.md
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.
Summary
Rate limits on
/verify,/settleand/discovery/*, keyed by the client IP from a configured header (CF-Connecting-IPbehind Cloudflare Tunnel), plus a 400 for bodies PostgreSQL can't store. Task:lore/1-tasks/active/0023_FEATURE_rate-limiting-and-http-hardening.md. Its "Decisions so far" table has 13 rows, each with its code and test./verifyfrom one client within 60 s{"error":"too_many_requests"},Retry-After: <s>/settlefrom the same client/verifyover budget, then/settle/settlestill answered (one route only)GET /discovery/resourcesGET /supportedmany timesX-Forwarded-For: 1.2.3.4rotating,CLIENT_IP_HEADER=socketCLIENT_IP_HEADERunsetCF-Connecting-IP: 203.0.113.7, 203.0.113.8(sent twice) or 8 000 charactersclient_ip_invalidlogged without the value, no store hitCF-Connecting-IP: 2001:db8:0:1::1, then2001:db8:0:ff::5CLIENT_IP_HEADER=cf-connecting-ip, request without the headerclient_ip_missingloggedinternal_error,http_errorlogged/settlebody withpayTo: "a\u0000b"or"a\ud800b"bad_request, nothing settled"😀"(a surrogate pair)TRUSTED_PROXY_HOPS=1in the envRATE_LIMIT_VERIFY=100,RATE_LIMIT_SETTLE=99NETWORK=stellar:pubnetThe rule the rows follow: every request to a limited route counts against the client's budget for that route, and the client is the configured header's IP, never
X-Forwarded-For.Defaults: 600
/verifyand 600/settleper 60 s (were 120 and 60), 120/discovery/*. The seller's server calls both routes for all of its buyers, and@x402/expresssettles after the seller's handler has run, so a budget per IP is a budget per seller. A 429 on/settlethrows away work the seller already did. Fees stay bounded by the spend budgets (AD-18).Evidence
Before:
rate-limit.test.ts(10 cases), the NUL and surrogate cases inverify-route.test.tsandsettle-route.test.ts, and the newconfig.test.tscases: 19 red before the code (vitest run: 8 failed in config, 11 in the route and limiter files).After:
TEST_DATABASE_URL=… vitest runinapps/facilitator: 395 tests passed, PostgreSQL suite included (PostgreSQL 14, local).tsc --noEmit,eslint .andprettier --check .are clean.The client IP through a real tunnel (quick tunnel, cloudflared 2026.10.3, an echo server behind it):
X-Forwarded-Forcf-connecting-ip: <real>,x-forwarded-for: <real>X-Forwarded-For: 1.2.3.4, 5.6.7.8x-forwarded-for: 1.2.3.4, 5.6.7.8,<real>,cf-connecting-ip: <real>CF-Connecting-IP: 9.9.9.9error code: 1000A flood against PostgreSQL (local,
hitpool of 3, budget 10, one client over 100 connections, 10 other clients on their own):No errors either way. The filter takes the flood's writes off the database; it isn't needed locally to keep other clients answered.
Considered and rejected
trust proxy(TRUSTED_PROXY_HOPS=1): measured to give the same IP through the tunnel. Dropped for the header that ADR 0011 names in the open PR docs(lore-0039): host the facilitator on the company Hetzner server #24 (ondevelopit still describes an ALB withTRUSTED_PROXY_HOPS=1, which this PR refuses: merge docs(lore-0039): host the facilitator on the company Hetzner server #24 first), because a wrong count either merges every client (0) or trusts a forged entry (2)./settlefails without the database anyway.express-rate-limit's middleware: itsStoreneeds an adapter withdecrementandresetKey, one instance per limiter, its headers off and its logger rerouted. That is more code than the counting it would replace. Only itsipKeyGeneratoris used./settlerefusing what/verifypassed.Merge Danger
Door: two-way
Revert restores the old behaviour. The
rate_limitstable already exists (0022); no migration.Blast Radius: facilitator
TRUSTED_PROXY_HOPSset refuses to start, by design.deploy/testnet.env.examplenow setsCLIENT_IP_HEADER=cf-connecting-ip;mainnet.env.examplehas it blank, since mainnet's proxy is open (0038) and pubnet is refused.CLIENT_IP_HEADERis required in every env:cf-connecting-ipbehind Cloudflare,socketwith no proxy. Both env examples carry it; mainnet's is left blank until 0038 picks its proxy.rate_limitedlog line carries the client IP, once per client and window.🤖 Generated with Claude Code