diff options
| author | Adam Soltys <[email protected]> | 2026-08-25 08:17:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-25 08:18:22 -0700 |
| commit | 13d906b4239e7520359df76943dece656e10e240 (patch) | |
| tree | 2f011913e86c9286c11bf7710d65bbc4fd959d62 | |
| parent | 15c3aba306f35953f965acb9f83a83b5fff81dbe (diff) | |
| download | coinos-server-13d906b4239e7520359df76943dece656e10e240.tar.gz coinos-server-13d906b4239e7520359df76943dece656e10e240.zip | |
take/authorize: kill-switch + validate fund-claim amounts
The /take fund-claim path could pay out unbacked balance: the only ceiling on a
take is sats(fiat / rates[currency]) from the authorization, and a
crypto-denominated authorization (rate ~1) or a non-finite/zero rate turns that
ceiling astronomically large, so a take is no longer bounded by the authorized
fiat value. Combined with the fund debit/credit running per-call, an authorizer
account could be drained far beyond intent.
- Add a `fund:disabled` redis kill-switch to both /authorize and /take that
fails the fund/authorize/take mechanism closed while it is audited.
- Reject non-finite/zero rates, non-fiat (crypto) authorization currencies, and
non-positive/NaN fiat and amounts, so the fiat cap can no longer be defeated.
- Require the authorizer account to exist before debiting it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
| -rw-r--r-- | routes/payments.ts | 41 |
1 files changed, 37 insertions, 4 deletions
diff --git a/routes/payments.ts b/routes/payments.ts index f8a8adba..6505917d 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -296,17 +296,33 @@ export default { // }, async authorize(req, res) { + // Kill-switch for the fund/authorize/take mechanism (SECURITY 2026-08-25: + // the /take fund-claim path could pay out unbacked balance). Set redis + // `fund:disabled` to fail all + // fund authorizations closed while the mechanism is audited/rewritten. + if (await g("fund:disabled")) fail("Fund transfers temporarily disabled"); + const { id: uid } = req.user; const { id, fiat, currency, amount } = req.body; const managers = await db.sMembers(`fund:${id}:managers`); if (managers.length && !managers.includes(uid)) fail("Unauthorized"); + // The authorization's fiat/currency is the ONLY ceiling on how much a later + // /take can pull from the authorizer (cap = sats(fiat / rates[currency])). + // Reject a NaN/zero/non-finite rate or a non-positive fiat so the ceiling + // can never be turned into an astronomically large or non-finite value. + const rates = await g("rates"); + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) fail("Invalid currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) fail("Invalid amount"); + const authorization = { uid, currency, - fiat, - amount, + fiat: fiatAmount, + amount: Number.parseInt(amount) || 0, }; await s(`authorization:${id}`, authorization); @@ -319,8 +335,11 @@ export default { user, } = req; try { + // Kill-switch — see authorize(). + if (await g("fund:disabled")) fail("Fund transfers temporarily disabled"); + amount = Number.parseInt(amount); - if (amount < 0) fail("Invalid amount"); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); const rates = await g("rates"); @@ -335,7 +354,20 @@ export default { const authorization = await g(`authorization:${id}`); if (authorization && !authorization.claimed) { const { currency, fiat } = authorization; - amount = Math.min(amount, sats(fiat / rates[currency])); + // Bound the take to the authorized fiat value using a VALIDATED fiat + // rate. A non-finite/zero rate or a crypto-denominated authorization + // (rate ~1) would otherwise make sats(fiat / rate) an astronomically + // large ceiling, defeating the cap (the drain vector). + const rate = Number(rates?.[currency]); + const fiatAmount = Number(fiat); + if (!Number.isFinite(rate) || rate <= 0) + fail("Invalid authorization currency"); + if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) + fail("Invalid authorization amount"); + const cap = sats(fiatAmount / rate); + if (!Number.isFinite(cap) || cap <= 0) fail("Invalid authorization"); + amount = Math.min(amount, cap); + if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount"); // Atomic single-use claim. The read above is not a lock: concurrent // POST /take for the same authorization all pass the `!claimed` gate and, @@ -353,6 +385,7 @@ export default { // every later /take skips the funding block. try { const sender = await getUser(authorization.uid); + if (!sender) fail("authorizer not found"); const { hash } = await generate({ invoice: { amount, type: "lightning" }, |
