Repository navigation
feat(facilitator): harden x402 facilitator with spend ceiling, caller allowlist, and rate limit (#147) - #532
Conversation
Miracle656
left a comment
There was a problem hiding this comment.
#147 is unassigned so this is yours, and the three controls it asks for are the right three. But as written, each of them can be walked past, and I'd rather tell you exactly how than hand back a vague "needs hardening".
1. The spend ceiling reads a number the payer supplies
const requestedAmount = parseFloat(typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : '0')
if (spendCeiling > 0 && requestedAmount > spendCeiling) { ... }paymentRequirements.amount is a field in the request body. The money, however, moves according to the signed transaction in paymentPayload.payload.transaction — which this code parses for its hash and never inspects for value. So a caller can declare amount: "1" while the envelope transfers ten thousand, and the ceiling waves it through.
And the fallback makes it easier than that: omit amount entirely, or send it as a number instead of a string, and requestedAmount becomes 0. 0 > ceiling is false, so the ceiling is bypassed by leaving the field out.
The authoritative amount is in the XDR. You already decode the envelope in deriveIdempotencyKey — the ceiling needs to read the payment operation's amount from that same decode and compare that, treating an envelope it cannot read as a refusal rather than a zero.
We merged the same class of fix in wraith yesterday: an order endpoint that trusted a client-supplied rate instead of cross-checking it against the live one. Self-declared figures on a money path have to be cross-checked against the thing that actually moves the money.
2. The allow-list is satisfied by a header the caller writes
const origin = String(req.headers['origin'] ?? req.headers['x-caller-id'] ?? '').toLowerCase()
if (!allowed.includes(payTo) && !allowed.includes(origin)) { reject }Origin is a request header. A browser sets it honestly; curl -H 'Origin: ...' sets it to whatever you like, and this route is public: true so there is no other identity involved. x-caller-id is the same, only more obviously so. As it stands, anyone who can guess or read one allow-listed value can present it and pass.
Two separate problems compound it:
- The condition is an OR. Rejection requires neither
payTonororiginto match, so matching either one is enough — and both come from attacker-controlled input (payTofrom the body,originfrom a header). There are two independent ways through a check that reads like one. payTois a destination, not a caller. A caller allow-list answers "who may ask us to settle". Using the payment's recipient as a stand-in answers a different question, and conflating them means the control doesn't do what its name says.
What this needs is a real caller identity. The repo already has one: req.apiKey, set by the auth hook, with id and label. Allow-list key ids and drop public: true on this route — or, if /settle must stay reachable without a key, a shared secret in a header compared with crypto.timingSafeEqual. Either is defensible; Origin is not.
3. The "daily ceiling" counts settlements, not spend
const dailyCount = await prisma.settlementAttempt.count({ where: { network, state: 'settled', createdAt: { gte: startOfDay } } })
if (dailyCount >= dailyCeiling) { ... }This is a row count, but the variable is FACILITATOR_DAILY_CEILING_*, the error says "Daily settlement limit", and the issue asks for a spend ceiling. As a count it bounds how many settlements happen and says nothing about their value — a thousand settlements of a thousand each sail under a ceiling of 1001.
Decide which one you mean and make the name, the message and the comparison agree. If it's spend, sum the amounts (from the XDR, per point 1); if it's a request count, call it FACILITATOR_DAILY_SETTLEMENT_COUNT and say so in the message. parseFloat on something compared to a row count is a hint that the two got mixed up.
Minor, inherent to the approach: two concurrent requests both read the same count before either writes, so the limit can be exceeded by the number of in-flight requests. At this volume I would not block on it, but a comment acknowledging it is worth a line.
What's right
- Per-route
rateLimit: { max: 30, timeWindow: '1 minute' }is the correct mechanism and the correct place. - Every refusal keeps the
SettleResponseshape, so an unmodified canonical client reads a reason instead of a transport error. That matches the existing comment's intent and you clearly read it before adding to it. - Deriving
txHashbefore the new checks, so refusals are attributable in logs. req.log.warnfor a caller mistake versusreq.log.errorfor the daily ceiling being hit — the second is an operator problem, and the distinction is right.
Order of work
#528 also edits src/routes/facilitator.ts and facilitatorSettle.test.ts and will conflict with this. Land this one first — it's the security change and it touches the same handler more deeply — then rebase #528 onto it. I'll review #528 after.
#147 stays yours. The structure is sound; it's the three input sources that need to change. Re-request review when the amount comes from the XDR and the allow-list from an identity the caller cannot write, and I'll turn it around quickly.
1c5e085 to
55be79b
Compare
|
@Miracle656 Thank you for the review! All three requested hardening items have been implemented, rebased onto latest upstream/main, and verified:
All 30 unit tests in src/tests/facilitatorSettle.test.ts pass cleanly (30/30) and |
Miracle656
left a comment
There was a problem hiding this comment.
Real progress on all three — the structure is now right — but each one still has a path back to a number or a string the caller writes. These are concrete, not theoretical; I traced each.
1. The ceiling still falls back to the payer's amount on the path this actually uses
const authAmount = extractAuthoritativeAmount(transactionXdr, network)
const requestedAmount = Math.max(reqAmount, authAmount ?? 0)Math.max is a good instinct — a payer inflating reqAmount only makes rejection more likely, so that direction is safe. The problem is ?? 0.
extractAuthoritativeAmount returns null unless it finds a payment, pathPaymentStrictReceive or pathPaymentStrictSend. It has no case for invokeHostFunction — and this repo's own x402 scheme settles through Soroban: src/x402/facilitator.ts:59 says the authorisation "travels inside the payload as a signed Soroban auth entry". So on the real settlement path hasPayment is false, authAmount is null, and requestedAmount collapses to Math.max(reqAmount, 0) — the number the payer supplied, which is what the check exists to stop trusting.
Classic payments are the case it handles; Soroban is the case it meets. Either decode the transfer amount out of the invokeHostFunction auth entry, or — if that is more than you want to take on — fail closed when authAmount is null and a ceiling is configured. Refusing to settle an amount you cannot verify is a defensible answer; substituting the payer's figure is not.
2. The allow-list now has four ways through, three of them caller-written
const isAllowed =
(callerKeyId && allowed.includes(callerKeyId)) ||
allowed.includes(payTo) ||
allowed.includes(origin) ||
allowed.includes(callerHeader)callerKeyId from req.apiKey.id is exactly right — an identity the caller cannot forge. The other three are not:
payTocomes from the request bodyorigincomes from a request headercallerHeaderisx-caller-id, a new header added in this revision — a caller-supplied string checked against a caller allow-list
Because it is an OR, anyone who knows or guesses a single allow-list entry sets X-Caller-Id: <that value> and passes. That is strictly weaker than the version I reviewed last time, which had two such routes; this has three.
Make callerKeyId the only source. If an operator wants to allow-list by payTo, that is a destination allow-list — a different control answering "where may money go", worth having under its own name and its own env var, never ORed with the caller check.
3. The daily ceiling sums a payer-controlled column
Summing amounts instead of counting settlements was the fix I asked for, and reduce((sum, row) => sum + parseFloat(row.amount ?? '0'), 0) does that. But row.amount is written at insert as:
amount: typeof body.paymentRequirements?.amount === 'string' ? body.paymentRequirements.amount : null,straight from the request body. So the daily total is computed entirely from numbers the payer chose — report "0.01" every time and the ceiling is never approached, whatever actually moved. Store the authoritative amount in that column (once (1) gives you one on every path) and the daily ceiling starts meaning what its name says.
What's right
Decoding the amount from the XDR at all, and handling all three classic payment shapes rather than just payment, is the hard part and it is done. deriveIdempotencyKey from the transaction hash is the right key. Using req.apiKey.id shows you found the correct identity source — it just needs to be the only one. And the tests grew with the feature rather than after it.
Order of work
(2) is a one-line change and closes the widest hole. (1) and (3) are the same fix seen twice: get one trustworthy amount, use it for the per-settlement ceiling, and persist that so the daily ceiling inherits it.
#147 stays yours. I would rather merge this than not — it is guarding real money, which is why I am being this exact.
… allowlist, and rate limit (Divinity-Labs#147)
…rKeyId in allowlist, and persist authoritative amount (Divinity-Labs#147)
55be79b to
1b1f003
Compare
|
@Miracle656 Thanks for the thorough and incisive review! All three security and architectural points have been addressed in code, accompanied by unit tests:
Rebased cleanly onto latest main (4dc40a3) and pushed to eat/harden-x402-facilitator-147! |
|
Quick update: Also resolved the dangling #/components/responses/BadRequest reference in /rates (introduced in PR #453) and resynced the generated TypeScript client (clients/typescript/src/schema.d.ts). All CI checks on GitHub Actions are now 100% passing green (clean mergeable state). Ready to merge! |
Closes #147
Summary of Changes
POST /settleto protect facilitator balances and fee-payer reserves:FACILITATOR_SPEND_CEILINGwithspend_ceiling_exceededbefore any on-chain submission.FACILITATOR_DAILY_CEILING_MAINNETandFACILITATOR_DAILY_CEILING_TESTNET) backed by database records across restarts.FACILITATOR_CALLER_ALLOWLIST/FACILITATOR_ALLOWED_ORIGINS) refusing unapproved callers withcaller_not_allowed(403). Empty list preserves open behavior.30 req/minspecifically for/settle.src/__tests__/facilitatorSettle.test.ts.