diff options
| author | Adam Soltys <[email protected]> | 2026-09-13 20:44:48 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-13 20:44:48 -0700 |
| commit | d8a361bdcfaa8cc5b39d9675e74b0e1d2e42be2c (patch) | |
| tree | 9709aa57f0e9138a23fef25ae3cb528e4c30068f /routes | |
| parent | 287fdb2b14b48d769e43a223c5c33049ff470880 (diff) | |
| download | coinos-server-d8a361bdcfaa8cc5b39d9675e74b0e1d2e42be2c.tar.gz coinos-server-d8a361bdcfaa8cc5b39d9675e74b0e1d2e42be2c.zip | |
security: drop the admin endpoint surface and land pending hardening
Removes the admin-gated endpoints and the account flags they set, plus a
batch of hardening that had accumulated uncommitted in the tree. These were
deployed and verified together, so they land as one change.
admin surface:
- drop POST /hidepay, POST /unlimit and GET /users, their handlers, and
the now-unused admin preValidation export
- `unlimited` was only ever written, never read; `hidepay` only appeared
in output projections. Both flags go, along with their whitelist,
public-fields and type entries.
request log:
- redactBody now recurses. It only masked top-level keys, so a body that
nests credentials one level down wrote them to the log in plaintext,
and pin fields were not on the list at all.
payments:
- sendOnchain/build gate `aid` through requireAccount, so a caller can
only spend from an account it owns
- sendOnchain signs only a tx we composed, matched against a short-lived
per-account build receipt
- raise xpay retry_for to 60s; route-finding preferred a drained zero-fee
hop and burned the whole budget on it. check()'s reversal window keeps
its 2x margin.
ecash / invoices / webhooks:
- resolve a token's mint through the IP-pinning fetcher instead of plain
fetch, closing an unauthenticated SSRF path into the internal network
- reject non-positive amounts before the balance write
- /sign requires the caller to own the address, rather than signing under
any address the hot wallet controls
- webhook delivery no longer disables TLS verification; the body carries
the merchant's shared secret
registration:
- refuse a username the v3 registrar holds unless the caller's own nostr
key is the holder (lib/names.ts)
liquid:
- honour the deposit kill switch at invoice generation and at credit
elements image:
- pin 23.3.4 and verify the release tarball checksum
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0112TGi9GGNWqji1XQ5jZRzc
Diffstat (limited to 'routes')
| -rw-r--r-- | routes/ecash.ts | 11 | ||||
| -rw-r--r-- | routes/invoices.ts | 14 | ||||
| -rw-r--r-- | routes/payments.ts | 13 | ||||
| -rw-r--r-- | routes/users.ts | 116 |
4 files changed, 94 insertions, 60 deletions
diff --git a/routes/ecash.ts b/routes/ecash.ts index e1e70490..4a1806a3 100644 --- a/routes/ecash.ts +++ b/routes/ecash.ts @@ -98,10 +98,19 @@ export default { user, } = req; try { - amount = Math.round(amount / 1000); const ref = preimage; const { lightning: type } = PaymentType; if (user.username !== "mint") fail("unauthorized"); + + amount = Math.round(amount / 1000); + // A negative (or NaN) amount here is a direct ledger-inflation primitive: + // db.debit subtracts it, so -N ADDS N to the balance, and the stored + // record gets amount: -(-N). The caller gate above limits this to the + // mint service account, but that account is not a trust boundary we want + // the ledger's integrity resting on — mirror the check debit() makes in + // lib/payments.ts before any balance write. + if (!Number.isFinite(amount) || amount <= 0) + fail("Amount must be greater than zero"); const { id: uid, currency } = user; const ourfee = await db.debit( `balance:${uid}`, diff --git a/routes/invoices.ts b/routes/invoices.ts index 61cf203b..9ba45af6 100644 --- a/routes/invoices.ts +++ b/routes/invoices.ts @@ -94,6 +94,20 @@ export default { async sign(req, res) { try { const { address, message, type = "bitcoin" } = req.body; + + // Sign only with an address the CALLER owns. Users have no per-user keys + // in this custodial design — every address belongs to the server hot + // wallet — so without this any authenticated account could have the node + // sign an arbitrary message under any address the wallet controls + // (another user's deposit address, a change address, a cold-storage + // address published as ours), which is a proof-of-control forgery + // primitive. The legitimate flow (coinos.io/sign, for Ocean offers that + // need a signable on-chain address) mints a fresh invoice address for + // the caller and posts that back, so it always satisfies this check. + const invoice = await getInvoice(address); + if (!invoice || (invoice.uid !== req.user.id && invoice.aid !== req.user.id)) + fail("unauthorized"); + const node = rpc(config[type]); if (config[type].walletpass) diff --git a/routes/payments.ts b/routes/payments.ts index 487885bd..00230c62 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -561,6 +561,19 @@ export default { if (type === PaymentType.liquid && asset !== config.liquid.btc) continue; + // Kill switch: `liquid:deposits:disabled` stops every Liquid receive + // from being credited or confirmed (sends still get their + // confirmation bookkeeping below). Set during a security incident; + // clear the key to re-enable. + if ( + type === PaymentType.liquid && + category !== "send" && + (await g("liquid:deposits:disabled")) + ) { + warn("liquid deposit blocked (disabled)", txid, vout, sats(amount), address); + continue; + } + if (category === "send") { const p = await getPayment(txid); if (!p) continue; diff --git a/routes/users.ts b/routes/users.ts index 52b5441f..cbd2079d 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -8,6 +8,7 @@ import { err, l, warn } from "$lib/logging"; import { mail, templates } from "$lib/mail"; import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr"; import register from "$lib/register"; +import { assertNameFree } from "$lib/names"; import { emit } from "$lib/sockets"; import upload from "$lib/upload"; import { bail, fail, fields, getUser, pick } from "$lib/utils"; @@ -125,42 +126,6 @@ export default { } }, - async list(req, res) { - const { user } = req; - if (!user.admin) fail("unauthorized"); - - const users = []; - - for await (const k of db.scanIterator({ MATCH: "balance:*" })) { - const uid = k.split(":")[1]; - const user = await getUser(uid); - - if (!user) { - await db.del(`balance:${uid}`); - continue; - } - - user.balance = await g(k); - - const payments = await db.lRange(`${uid}:payments`, 0, -1); - - let total = 0; - for (const pid of payments) { - const p = await gf(`payment:${pid}`); - if (!p) continue; - total += p.amount; - if (p.amount < 0) - total -= (p.fee || 0) + (p.ourfee || 0) + (p.tip || 0); - else total += p.tip || 0; - } - - user.expected = total; - users.push(user); - } - - res.send(users); - }, - async get(req, res) { let { params: { key }, @@ -193,6 +158,51 @@ export default { if (!body.user) fail("no user object provided"); let { user } = body; + // Per-IP registration cap. Nothing else throttles /register (the + // recaptcha check only runs on login; the fastify limiters cover + // /login and /send), so one VPN exit created ~500 empty accounts at + // 3/min on 2026-09-08 without tripping anything. Five an hour is far + // above any household; an internal/private source IP (proxy without + // the Cloudflare header) is exempt so a misrouted request can't lock + // everyone out. Counted before validation so failed attempts count too. + if (ip && !/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1$|fc|fd)/.test(ip)) { + const k = `register:ip:${ip}`; + const n = await db.incr(k); + if (n === 1) await db.expire(k, 3600); + if (n > 5) { + warn("registration cap hit", ip, n); + fail("Too many accounts created from this address, try again later"); + } + } + + // Forged browser User-Agent. Every engine that reports AppleWebKit also + // reports the "(KHTML, like Gecko)" token right behind it, so a UA that + // claims AppleWebKit without it was assembled by hand. Across 4.8M + // logged requests this matches 2,366: the c<epoch> registration bot + // (1,203 accounts from 596 rotating residential IPs — which is exactly + // why the per-IP cap above never touched it), PetalBot, Bytespider, and + // nothing else. Real browsers carry the token; non-browser clients that + // register legitimately (python-requests, curl, damus, our own Bun SSR) + // never claim AppleWebKit at all, so none of them match. + const ua = String(headers["user-agent"] || ""); + if (ua.includes("AppleWebKit/") && !ua.includes("KHTML")) { + warn("registration blocked, forged user-agent", ip, ua.slice(0, 80)); + fail("Registration unavailable"); + } + + // The signup form has always sent a recaptcha token and /register has + // always discarded it — it isn't even in the `fields` pick below — so + // the captcha only ever gated login. Verify it when one is supplied. + // Supplied-and-invalid is the only rejection: an ABSENT token still + // passes, because half of all registrations are API clients that never + // had one and requiring it would break them. verifyRecaptcha keeps its + // own bypasses (onion host, x-api-key, allowlisted UA) and fails open + // when Google is unreachable. + if (user.recaptcha && !(await verifyRecaptcha(user.recaptcha, req))) { + warn("registration blocked, failed captcha", ip, user.username); + fail("Failed captcha"); + } + const fields = ["pubkey", "password", "username", "picture", "fresh"]; user = await register(pick(user, fields), ip); @@ -287,7 +297,12 @@ export default { const event = JSON.parse(body.event); const challenge = event.tags.find((t) => t[0] === "challenge")[1]; - const c = await g(`challenge:${challenge}`); + // Single-use: GETDEL claims the challenge atomically, so the identical + // signed event can't be replayed within the 5-minute TTL (it is also + // written to the request log, so a log reader could otherwise reuse it). + // Burn it on read rather than on success — a failed attempt costs the + // client one extra GET /challenge, and two racing replays cannot both win. + const c = await db.getDel(`challenge:${challenge}`); if (!c) fail("Invalid or expired challenge"); if (!verifyEvent(event) || event.pubkey !== pubkey) @@ -314,6 +329,8 @@ export default { err("username taken", username, currentUsername); fail("Username taken"); } else { + // A name the v3 registrar holds is taken too (see lib/names.ts). + await assertNameFree(username, user.pubkey); l("changing username", currentUsername, username); await db.del(`user:${currentUsername}`); user.username = username; @@ -499,7 +516,9 @@ export default { if (!recaptchaOk) { return res.code(401).send("failed captcha"); } - const c = await g(`challenge:${challenge}`); + // Single-use — see the GETDEL note on the other challenge consumer. The + // captcha is checked above, so a captcha failure doesn't burn a challenge. + const c = await db.getDel(`challenge:${challenge}`); const { pubkey: key, kind } = event; if (kind !== 27235) fail("Invalid event"); if (!c) fail("Invalid or expired login challenge"); @@ -813,7 +832,6 @@ export default { user: u, } = req; try { - const admin = u?.admin; let id; let user; @@ -966,26 +984,6 @@ export default { } }, - async hidepay(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.hidepay = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - - async unlimit(req, res) { - const { - body: { username }, - } = req; - const u = await getUser(username); - u.unlimited = true; - await s(`user:${u.id}`, u); - res.send({}); - }, - async account(req, res) { const { id } = req.params; const { id: uid } = req.user; |
