aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-13 20:44:48 -0700
committerAdam Soltys <[email protected]>2026-09-13 20:44:48 -0700
commitd8a361bdcfaa8cc5b39d9675e74b0e1d2e42be2c (patch)
tree9709aa57f0e9138a23fef25ae3cb528e4c30068f /routes
parent287fdb2b14b48d769e43a223c5c33049ff470880 (diff)
downloadcoinos-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.ts11
-rw-r--r--routes/invoices.ts14
-rw-r--r--routes/payments.ts13
-rw-r--r--routes/users.ts116
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;