Security review fixes — one PR for internal-docs#1774 (HIGH 1, HIGH 2, MED 3, MED 4 landed) - #64
Open
0xZKnw wants to merge 11 commits into
Open
Security review fixes — one PR for internal-docs#1774 (HIGH 1, HIGH 2, MED 3, MED 4 landed)#640xZKnw wants to merge 11 commits into
0xZKnw wants to merge 11 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… ledger The cumulative Travel Rule threshold and the rolling-24h Alpha cap were summed from client-authored bridgeActivity rows (amount, decimals and status all client-supplied). A user could under-report, mark failed, or simply never write the row, and once the short-lived attestation reservation lapsed the caps read back near zero — defeating the cumulative AML threshold. Count the attestation holds the server itself signs instead. A hold is valued at its signed ceiling using canonical on-chain decimals from the deployment registry (never the client's, which could shrink the charged USD while the ceiling still authorizes the full amount on-chain), counts from the moment it is signed, and is freed only when the chain proves its nonce went unused past the contract-enforced deadline. Fail-safe throughout: an unreadable RPC keeps the hold counted, so a real deposit is never dropped from a cap. No schema change: amountUsd = 0 is the released tombstone and each row's charge state is synthesized from (amountUsd, expiresAt). Also make the POCH per-day cap atomic under the same per-user advisory lock as the passport path. POCH volume can still be under-stated because the clean-hands attestation binds no on-chain amount; a hard bound there requires a signed maxAmount added to the attestation (a TokenPortal change). The security-critical valuation and hold-resolution logic is isolated in a dependency-free module (deposit-ledger.ts) with unit tests runnable via `node --test --experimental-strip-types src/lib/deposit-ledger.test.ts`.
…n network The SIWE allow-list hard-coded both shield.human.tech and testnet.shield.human.tech, so a mainnet deployment honoured a signature the user had approved for testnet. The signed domain is the only thing a user sees before approving, so accepting a sibling environment's host breaks the one boundary that signature has. The accepted host is now derived from the network the deployment actually serves, which is committed per-branch in deployments.json, so no deployment needs an env var set to stay reachable. The localhost dev exception used a prefix test on the request Host header and was not gated on NODE_ENV, so localhost.attacker.example matched it in production and was then added to the server's own allow-list. Local addresses are now matched on the exact parsed hostname, outside production only, and the Host header is no longer consulted anywhere in these two routes. The same Host trust in isAllowedKeyDerivationDomain is removed. The signed SIWE uri is attacker-chosen, so it is now checked against the allow-list rather than used to widen it. AUTH_EXPECTED_DOMAIN becomes purely additive with an empty default; listing another environment's host there would reopen the same hole, and .env.example no longer suggests it. Refs holonym-foundation/internal-docs#1774 (MED 4)
…dings Both /check routes documented themselves as returning eligibility "without issuing any attestation or incrementing nonces", then called enforceAddressBinding, which creates a permanent row. The L2 half of that pair is never proven — it comes from SIWE resources, which the caller chooses — and there is no unbind path, so a read-shaped request could permanently consume an L2 address belonging to someone else and leave them unable to ever bridge to their own account. The pre-checks now report a conflicting binding without creating one; the two signing routes still bind, where the caller is deliberately asking for an attestation. This narrows the surface but does not close it: an attacker holding their own valid credential can still reach a signing route. Fully closing it needs the L2 address proven, or the exclusivity on the L2 side relaxed, both of which change the connect flow or the schema. Refs holonym-foundation/internal-docs#1774 (HIGH 2, partial)
…address The compliance caps counted attestation holds per User row, and a User is @@unique([l1Address, l2Address]) — so the same L1 address got a fresh allowance for every L2 address it paired with. Count them per L1 address instead, summing every User row that shares it, and take the per-request advisory lock on the L1 address so concurrent requests from one L1 across different L2s still serialize. That removes the only thing the 1:1 address binding was backstopping, which matters because the binding was matched on both halves while SIWE proves only the L1 one — the L2 address comes from caller-chosen SIWE resources. Pairing a throwaway L1 with a stranger's Aztec account therefore created a permanent row, with no unbind path, that locked the stranger out of the bridge for good; /api/attestation/status also disclosed the claiming L1 back to them. Bindings are now looked up by L1 address alone, so an unproven L2 collision can neither block nor disclose. A user whose own EVM wallet is already bound elsewhere is still blocked, unchanged — that half is proven. Refs #1774
The two decisions this branch changed are made by Prisma queries, so the pure suite in deposit-ledger.test.ts cannot reach them. 14 cases against a throwaway Postgres, driving the exported surface and the real /api/attestation/status handler (JWT included) rather than restating the queries. Six are labelled REGRESSION and fail on the parent commit: the lockout via an unproven L2 claim, the same claim blocking the pre-flight, the claiming L1 disclosed back to the victim, and the three cap totals that a second L2 address used to split. The other eight assert what must NOT move — an L1 bound to another L2 still blocks with the same message, the L1-side conflict still reaches the UI, binding stays idempotent, races resolve to one winner — and pass on both commits. Not wired into any automated run: it needs a database, and there is no runner for one here. The header carries the exact repro. Refs #1774
The key is a path segment of every Alchemy request URL, so the AxiosError raised on failure carries it in `config.url`. Both proxy routes logged that error object directly and echoed the upstream error body back to the caller, leaving the key one serialization away from the log store on every timeout or rejection. Summarize the failure instead — upstream status, transport code, and a message with any occurrence of the key masked — and return the generic error shape the other routes already use. Nothing read `details`. Refs #1774
0xZKnw
force-pushed
the
fix/deposit-cap-onchain-ledger
branch
from
July 23, 2026 10:51
dc11f95 to
325cc6b
Compare
Each is small on its own, so they land together. 7 — client IP. Both the nonce rate limit and the audit trail read the left-most `x-forwarded-for` entry, which is whatever the caller sent: the proxy appends the real peer, it does not replace the list. So the limit was evadable by rotating a header and the recorded IP was attacker-chosen. A shared helper now reads the right-most entry, falls back to `x-real-ip` for runs with no proxy, and drops anything longer than an address can be — the column was previously unbounded. 8 — mint-tokens took the recipient from the request body, so one account could mint the testnet supply into unlimited addresses. It mints to the caller's own wallet now, capped per user per minute. 9 — the faucet's handler body sat unreachable below its 503, unauthenticated and unthrottled, one deleted line from an open drain on the faucet wallet. Removed; the route stays disabled and history keeps the implementation. 10 — the operations PATCH verified ownership in one statement and then wrote by primary key in another. Not exploitable today because the owner column is immutable, but the write now carries the same condition it was checked against. 11 — HS256 is only as strong as its secret, and a short one is recoverable offline from a single issued token; signing now refuses under 32 characters. The default session also drops from 7 days to 1: there is no revocation path, so the lifetime is the only bound on a stolen token. `JWT_EXPIRES_IN` still overrides it. Refs #1774
Shortening it was a product call, not a security requirement — the secret length check is the part of the finding that stands on its own. JWT_EXPIRES_IN still sets it per deployment. Refs #1774
The only textual conflict is validation.test.ts, which both sides added: the resolution keeps testnet's vitest suite whole and appends this branch's getClientIp cases, ported from node:test. Git cannot see the rest. testnet added unit and e2e suites that pin the pre-fix behaviour of the code this branch deliberately changes, so they go red here and are rewritten in the next commit.
…viour The caps no longer read client-authored BridgeActivity rows, so every fixture that seeded one is migrated to the hold ledger the caps actually count. The netting cases go with it: one nonce is one hold row, charged once at its signed ceiling, so there is nothing left to net against. Two suites asserted the lockout this branch removes -- a wallet claiming an Aztec address it never proved barred the owner for good. They now pin the replacement: the first claimant keeps its binding, the second stays unbound, and the status route discloses neither. An expired hold no longer frees budget on the clock; only an on-chain nonce read does. The e2e upstream stub gains that RPC, defaulting to unreadable so existing cases are unaffected, which lets all three outcomes be driven -- unreadable keeps the charge, proven-unused frees it, proven-consumed commits it for good. The three node:test files are ported to vitest, so the security coverage runs in CI instead of only by hand. The DB-backed one moves to the e2e suite as deposit-caps.e2e.test.ts, covering the case only it had: one wallet across two Aztec accounts shares one cap. Also lengthens the e2e JWT_SECRET past the minimum this branch enforces, which was failing every login in the suite.
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.
Single PR for the whole security review, as requested. The findings are written up in holonym-foundation/internal-docs#1774 — 2 HIGH / 3 MEDIUM / 5 LOW, re-verified against
testneton Jul 22 (none of them were already fixed there).Fixes land as separate commits so each one can be read on its own. This table is the current state and is updated as commits land — it is not a promise that everything below is already in the diff.
b0560c1b0560c1Hosttrusted4dcbc4cALCHEMY_API_KEYreaches logs via the full AxiosError325cc6b25e82a6+36e48c3(no migration needed after all)3c4613dIf you would rather review this as several smaller PRs, say so and I will split it — the commits are already separable.
HIGH 1 + MED 3 — compliance caps enforced against server-signed holds
Commit
b0560c1. Same class as #27, different vector.The cumulative $1,000 Travel Rule threshold and the rolling-24h Alpha cap were summed from client-authored
bridgeActivityrows:amountL1,tokenDecimalsL1andstatusare all client-supplied, and the only server-authoritative guard — the attestation reservation — expires in ≤30 min while the caps span 24h / lifetime. So a qualified user could request an attestation → deposit on-chain → make the row read as $0 (mark itfailed, under-report the amount, or never write it) → wait for the reservation to lapse → repeat, driving cumulative volume past the AML threshold. No funds are stolen; it is an AML/compliance bypass on a live mainnet bridge.The caps now count the attestation holds the server itself signs, never client deposit rows:
tokenDecimals. An inflated value would shrink the charged USD while the signed ceiling still authorises the full amount on-chain.passportNoncesmapping; a passport signature is verified for the depositor on-chain (TokenPortal._validateAttestations,_amount <= maxAmount+deadline), so a hold read as unused past deadline provably can never settle.amountUsd = 0is the "released" tombstone, and each row's charge state is synthesised from the existing(amountUsd, expiresAt)columns. Deploy is code-only.MED 4 — SIWE and key-derivation domains pinned to the deployment's own network
Commit
4dcbc4c. This turned out to be three defects rather than one.The SIWE allow-list hard-coded both
shield.human.techandtestnet.shield.human.tech, so a mainnet deployment honoured a signature the user had approved for testnet. The signed domain is the only thing a user sees before approving in their wallet, so accepting a sibling environment's host removes the one boundary that signature carries: a user can be shown a low-stakes testnet domain and have the resulting signature spend its meaning on mainnet.AZTEC_ENV, resolved from thedeployments.jsonthat is committed per branch (testnethere,mainnetonmain). No deployment has to set an env var to stay reachable, so this cannot lock anyone out. I could not read the deployed env vars to confirmAUTH_EXPECTED_DOMAINwas set, so the fix was built not to depend on it.Hostheader is no longer consulted in either route. The localhost dev exception used a prefix test on it and was not gated onNODE_ENV— solocalhost.attacker.examplematched in production and was then added to the server's own allow-list. Local addresses are now matched on the exact parsed hostname, outside production only.Hosttrust insideisAllowedKeyDerivationDomainis removed.uriis attacker-chosen, so it is now checked against the allow-list instead of being used to widen it. This overlaps the HIGH 2 surface; it is included because it depends on no product decision, and leaving it open in the very file being hardened would be incoherent. Happy to split it out if you would rather keep HIGH 2 whole.AUTH_EXPECTED_DOMAINbecomes purely additive with an empty default, and.env.exampleno longer suggests listing both environments — that example is what would reopen the hole.HIGH 2 — the binding is keyed on the half SIWE actually proves
Commits
25e82a6and36e48c3. I first wrote this up as needing a schema change; it doesn't.SIWE proves the L1 address. The L2 address is read from caller-chosen SIWE
resourcesand is never proven.enforceAddressBindingmatched on both halves and wrote a permanent row with no unbind path — so a throwaway L1 paired with someone else's Aztec account locked that account out of the bridge for good./api/attestation/statusthen handed the victim the attacker's L1 address, which the hook comment describes as privacy-safe on the grounds that "both sides are the user's own connected addresses" — not true once the L2 half was claimed by a stranger.Two commits:
25e82a6— the two/checkpre-flights no longer bind. Their own doc comments promised no side effects and they were creating permanent rows; a bare JWT no longer reaches a write.36e48c3— a binding is now looked up byl1Addressalone. An unproven L2 collision can neither block a user nor disclose anything about who claimed it. If the DB'sl2Address @uniquestill rejects the insert, that is swallowed: the row simply isn't recorded, which is the safe outcome for the half we can't prove.Nothing the product does is lost. The conflict this drives in the UI — "your EVM wallet is linked to Aztec account X, switch to it" (issues #98/#120/#124/#130) — is the L1-side conflict, and it is untouched: your own proven wallet being bound elsewhere still blocks, with the same copy. What disappears is only the L2-side conflict, which was the attack.
Why this no longer needs
l2Address @uniquerelaxed. The constraint's job was to stop one L1 spawning several cap buckets. The caps no longer depend on it: after the change above they are counted per L1 address, summing everyUserrow that shares it, with the advisory lock taken on the L1 address so concurrent requests from one L1 across different L2s still serialize. That re-keying is a strict tightening on its own — aUseris@@unique([l1Address, l2Address]), so per-row counting was handing the same L1 a fresh allowance for each L2 it paired with.The constraint stays in the schema, unused by the application logic. Dropping it is a tidy-up you can do whenever, not a prerequisite — no migration, deploy is code-only.
Still worth pulling when you have DB access, because it decides whether anyone is owed a fix rather than just a patch: do conflicting bindings already exist in production? An L2 address bound to an L1 that has never deposited is a victim, and those rows are now inert but still there.
MED 5 — the Alchemy key can no longer reach the log store
Commit
325cc6b.The key is a path segment of every Alchemy request URL, so the
AxiosErrorraised on failure carries it inconfig.url. Both proxy routes passed that object straight toconsole.error, and echoed the upstream error body back to the caller in adetailsfield. Any timeout or upstream rejection therefore left the key one serialization away from wherever the logs land.detailsis gone from both 500 responses; they return the same generic shape the other routes already use. Nothing read the field.Authorizationheader, and I have no key to test the alternative with, so I did not change the wire format of a live route on a guess. The repo already uses the header pattern for Passport (X-API-KEY) if you want that followed up.LOW 7–11 — the remaining hardening
Commit
3c4613d. Small individually, so they land together.7 — client IP. The nonce rate limit and the
clientIpaudit column both read the left-mostx-forwarded-forentry. A proxy appends the peer it saw rather than replacing the list, so the left-most value is simply what the caller sent: the limit was evadable by rotating a header, and the recorded IP was attacker-chosen. One shared helper now reads the right-most entry — which assumes exactly one trusted proxy in front, stated in the comment — falls back tox-real-ipwhere no proxy sets the list, and drops anything longer than an address can be. The column was previously unbounded.8 — mint-tokens. The recipient came from the request body, so a single account could mint the testnet supply into unlimited addresses. It now mints to the caller's own wallet, capped per user per minute.
9 — faucet. The handler body sat unreachable below its
503, unauthenticated and unthrottled: re-enabling it was one deleted line away from an open drain on the faucet wallet. Removed. The route stays disabled and git history keeps the implementation.10 — operations PATCH. Ownership was verified in one statement and the write keyed on the primary key in another. Not exploitable today — the owner column is immutable — so this is defense in depth: the write now carries the condition it was checked against, and a non-match 404s.
11 — JWT. Signing refuses a secret under 32 characters; HS256 is only as strong as its key, and a short one is recoverable offline from a single issued token. I also shortened the default session from 7 days to 1 and then reverted it (
e3eea8e) — with no revocation path the lifetime is the only bound on a stolen token, but how often people re-sign is a product call rather than a security requirement.JWT_EXPIRES_INsets it per deployment if you want it lower.Before merging, check that the deployed
JWT_SECRETis at least 32 characters. If it is shorter, signing throws and verification returns null, so nobody can log in. I can't read the deployed value. Rotating it is also a fine answer — it invalidates existing sessions, which is the revocation this finding says we don't have.One thing that needs a decision from you
Fully closing the daily ($25k) POCH cap needs a contract change. The clean-hands attestation binds no on-chain amount and no deadline —
TokenPortal._validateAttestationschecks_amount <= maxAmountfor the passport branch only. So for a POCH deposit the counted figure is the client's self-reportedamount: this PR serialises POCH requests and counts honest usage, but a caller can still under-state a POCH deposit and slip under the cap. Options, in preference order:maxAmount+_amount <= maxAmountcheck to the clean-hands branch (mirror the passport branch). Smallest change — the hold ledger in this PR then closes POCH exactly like the passport path.Testing
AxiosError— what the old handler did — does contain the key:tsc --noEmitclean, andnext build --webpacksucceeds on this branch — 28 routes generated, no errors. So the red Vercel check is not coming from this code; the same check fails on Account chip: dynamic connect-state chip + dropdown (first pass) #72/Shield UX round 6: declutter fuel card into messages/tooltips + mini-bar status #67/Shield UX round 5: cleaner nav (no dividers), Activity 20/80 footer + more rows #66 whiletestnetitself deploys green.isLocalDevHostwas proven against the old implementation on 9 cases in an isolated harness — including thatlocalhost.attacker.examplewas accepted before and is rejected now. It is not a committed test: the function lives in a module that imports@/config(path alias + JSON), whichnode --testcannot resolve. So it is a one-off proof, not a guarded regression, and I would rather flag that than let the testing section imply otherwise.06c0a27), covering the decisions the pure suite can't reach because Prisma makes them: which identity a cap counts against, which half of the pair a binding matches on, and which rows a PATCH may write. It drives the exported surface and the real/api/attestation/statushandler, JWT included, rather than restating the queries. Repro is in the file header.REGRESSIONand fail on the parent commit — the lockout, the pre-flight blocking on it, the claiming L1 disclosed back to the victim, and the three cap totals a second L2 address used to split. I ran the suite against25e82a6to confirm that: 8 passed / 6 failed there, and all 16 pass here.Notes for review
deposit-ledger.tsis a new dependency-free module, so the security-critical valuation and release logic is auditable and testable without a DB or a chain. The glue inaddress-binding.tsis a thin layer over it.domainAllowlist.tsis now the single source of truth for which hosts this deployment answers on; the same allow-list was previously duplicated in two files that had drifted apart.