fix(vault): harden pending pegin localStorage validation#1406
fix(vault): harden pending pegin localStorage validation#1406jeremy-babylonlabs wants to merge 1 commit intomainfrom
Conversation
Closes audit finding #37 (MEDIUM): unsigned tx hex stored in localStorage was passed to UTXO reservation and broadcast paths without integrity checks, leaving them vulnerable to tampering by XSS, browser extensions, or manual devtools edits. peginStorage.ts: strict shape/format validation at the read boundary. Each entry must pass checks for `id` and `peginTxHash` (32-byte hex), `timestamp` (finite non-negative number), `unsignedTxHex` (empty or non-empty even-byte hex; rejects bare `0x` and odd lengths), and every selectedUTXOs field (64-char hex txid, integer vout >= 0, value in safe integer range, even-byte scriptPubKey). Invalid entries are dropped per entry with a logger.warn — the storage key is no longer wiped from a single bad record. Also closes a DoS where a non-string id would throw inside normalizeTransactionId and clear all pending pegins. utxoReservation.ts: collectReservedUtxoRefs now derives reservation refs only from unsignedTxHex (selectedUTXOs is no longer trusted for reservation, eliminating the sidecar inconsistency window). Pending pegins whose id matches an indexed on-chain vault are skipped so the trusted indexer copy wins. extractInputUtxoRefs logs on parse failure instead of silently returning an empty array. Adds peginStorage.test.ts (20 tests) covering the validation matrix: DoS guard, legacy non-prefixed ids, bare `0x`, odd-length hex, wrong-length txid, wrong-length id and peginTxHash, malformed scriptPubKey, mixed valid/tampered batches. utxoReservation.test.ts updated to reflect the simpler reservation logic and added on-chain preference cases.
🔐 Commit Signature Verification✅ All 1 commit(s) passed verification
Summary
Required key type: Last verified: 2026-04-15 10:45 UTC |
Greptile SummaryThis PR closes audit finding #37 by hardening the localStorage read boundary for pending pegins: it adds Confidence Score: 5/5Safe to merge; all remaining findings are P2 style suggestions with no impact on the security guarantees introduced. The core security improvements (strict validation at the read boundary, dropping untrusted services/vault/src/storage/peginStorage.ts — minor Important Files Changed
Sequence DiagramsequenceDiagram
participant LS as localStorage
participant PS as peginStorage.getPendingPegins
participant V as hasValidSecurityFields
participant CR as collectReservedUtxoRefs
participant EI as extractInputUtxoRefs
participant IDX as On-chain Vaults (indexer)
PS->>LS: JSON.parse(stored)
LS-->>PS: raw unknown[]
PS->>V: hasValidSecurityFields(entry)
alt invalid shape / tampered hex
V-->>PS: false → logger.warn, skip entry
else valid
V-->>PS: true → normalize id (0x prefix)
end
PS-->>CR: PendingPeginRequest[]
CR->>IDX: vaults[] (canonical)
IDX-->>CR: Vault[] with unsignedPrePeginTx
loop each pending pegin
alt id matches on-chain vault ID
CR->>CR: skip localStorage entry entirely
else not yet indexed
CR->>EI: extractInputUtxoRefs(unsignedTxHex)
alt parse failure
EI-->>CR: [] + logger.warn
else success
EI-->>CR: UtxoRef[]
end
end
end
loop each PENDING/VERIFIED vault
CR->>EI: extractInputUtxoRefs(unsignedPrePeginTx)
EI-->>CR: UtxoRef[]
end
CR-->>CR: return reserved UtxoRef[]
|
Summary
Closes audit finding #37 (MEDIUM): unsigned tx hex stored in
localStoragewas passed to the UTXO reservation and broadcast paths without integrity checks, leaving them vulnerable to tampering by XSS, browser extensions, or manual devtools edits.Changes
storage/peginStorage.tsStrict shape/format validation at the read boundary. Each entry must pass:
idandpeginTxHash— 32-byte hex (0x-prefix optional for legacy entries).timestamp— finite non-negative number.unsignedTxHex— empty (cross-device marker) or non-empty even-byte hex; rejects bare0xand odd lengths.selectedUTXOsentry — 64-char hextxid, integervout ≥ 0,valuein safe integer range, even-bytescriptPubKey.Invalid entries are dropped individually with a
logger.warn— the storage key is no longer wiped from a single bad record. Also closes a DoS where a non-stringidwould throw insidenormalizeTransactionIdand clear all pending pegins.services/vault/utxoReservation.tscollectReservedUtxoRefsnow derives reservation refs only fromunsignedTxHex.selectedUTXOsis no longer trusted for reservation, eliminating the sidecar inconsistency window.idmatches an indexed on-chain vault are skipped so the trusted indexer copy wins.extractInputUtxoRefslogs on parse failure instead of silently returning an empty array (per CLAUDE.md's "never swallow errors silently").Tests
storage/__tests__/peginStorage.test.ts(20 tests): DoS guard, legacy non-prefixed ids, bare0x, odd-length hex, wrong-lengthtxid, wrong-lengthid/peginTxHash, malformedscriptPubKey, mixed valid/tampered batches.services/vault/__tests__/utxoReservation.test.tsupdated for the simpler reservation logic plus new on-chain preference cases.Residual risk (documented, out of scope for this PR)
collectReservedUtxoRefsstill derives refs from a localStorage-sourcedunsignedTxHexafter only a format check. A tampered-but-parseable hex could reserve UTXOs the attacker chose, causingselectUtxosForDeposit(from fix(vault): utxo reservation - prevent double spend failures #1398) to refuse a new deposit — a DoS that requires XSS / browser-extension write access. Fully closing it means passingvaultsfrom the indexer intocollectReservedUtxoRefsso indexed pegins use canonical on-chain data; that's a call-site change inuseMultiVaultDepositFlow.tsneedinguseVaultswired in. Worth a follow-up.useVaultActions.handleBroadcaststill passes localStorageselectedUTXOstoutxosToExpectedRecordas trusted prevout data. Tampering causes P2TR signing failure (BIP 341 commits prevouts), not a silent exploit — fails loud, no funds at risk.Test plan
pnpm --filter vault exec vitest run— 1055 passed, 4 skipped, 65 filespnpm --filter vault exec eslint <changed files>— 0 errors, 0 new warningspnpm --filter vault exec tsc -b --noEmit tsconfig.lib.json— cleanpnpm run build— all 7 projects + 1 dependent task green