aboutsummaryrefslogtreecommitdiffstats
path: root/routes/users.ts
diff options
context:
space:
mode:
Diffstat (limited to 'routes/users.ts')
-rw-r--r--routes/users.ts116
1 files changed, 57 insertions, 59 deletions
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;