Skip to content

Commit 22a4f1e

Browse files
committed
Harden email subscription throttle keys
1 parent 1627a99 commit 22a4f1e

2 files changed

Lines changed: 62 additions & 4 deletions

File tree

lib/api/account.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"use strict";
2+
const crypto = require("crypto");
23

34
const SENSITIVE_FAILURE_DELAY_MS = 2 * 1000;
45
const SENSITIVE_FAILURE_RETENTION_MS = 10 * 60 * 1000;
@@ -28,9 +29,23 @@ module.exports = function registerAccountRoutes(ctx) {
2829

2930
function timeNow() { return typeof now === "function" ? now() : Date.now(); }
3031

31-
function sensitiveKey(username) {
32-
const value = typeof username === "string" ? username.trim() : "";
33-
return value ? value : null;
32+
function sensitivePart(value) {
33+
if (typeof value === "string") return value.trim();
34+
if (typeof value === "number" || typeof value === "boolean") return String(value);
35+
return "";
36+
}
37+
38+
function sensitiveKey(kind, username, from, to, enabled) {
39+
const userValue = sensitivePart(username);
40+
if (!userValue) return null;
41+
42+
const hash = crypto.createHash("sha256");
43+
for (const part of [kind, userValue, sensitivePart(from), sensitivePart(to), sensitivePart(enabled)]) {
44+
hash.update(String(Buffer.byteLength(part)));
45+
hash.update(":");
46+
hash.update(part);
47+
}
48+
return hash.digest("hex");
3449
}
3550

3651
function pruneSensitiveFailures(time) {
@@ -54,6 +69,7 @@ module.exports = function registerAccountRoutes(ctx) {
5469
function acquireSensitiveAttempt(key) {
5570
if (!key) return null;
5671
const time = timeNow();
72+
pruneSensitiveFailures(time);
5773
const entry = sensitiveFailures.get(key);
5874
if (entry && entry.nextAllowedAt > time) return throttlePayload(entry.nextAllowedAt - time);
5975
sensitiveFailures.set(key, {
@@ -124,7 +140,7 @@ module.exports = function registerAccountRoutes(ctx) {
124140
if (body) return sendJson(res, 401, { success: false, msg: "No \"username\" parameter was found" });
125141
return;
126142
}
127-
const throttleKey = sensitiveKey(body.username);
143+
const throttleKey = sensitiveKey("subscribeEmail", body.username, body.from, body.to, body.enabled);
128144
const throttle = acquireSensitiveAttempt(throttleKey);
129145
if (throttle) return sendJson(res, 429, throttle);
130146

tests/api/public_and_auth.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,48 @@ test.describe("api public and auth", { concurrency: false }, () => {
347347
});
348348
});
349349

350+
test("email subscription throttle does not let a mismatched email block a valid update", async () => {
351+
let updateCalls = 0;
352+
const mysql = createMysql(async function handler(sql, params) {
353+
if (sql.startsWith("UPDATE users SET enable_email = ?, email = ? WHERE username = ? AND email = ?")) {
354+
updateCalls += 1;
355+
assert.equal(params[2], "wallet");
356+
if (params[3] === "attacker@example.com") return { affectedRows: 0 };
357+
assert.deepEqual(params, [1, "new@example.com", "wallet", "old@example.com"]);
358+
return { affectedRows: 1 };
359+
}
360+
throw new Error("Unexpected SQL: " + sql + " params=" + JSON.stringify(params));
361+
});
362+
363+
await withRuntime({
364+
blockTemplate: createBlockTemplate(),
365+
config: createConfig(),
366+
database: createDatabase({ caches: {} }),
367+
mysql: mysql,
368+
now: () => 0,
369+
support: createSupport()
370+
}, async (port) => {
371+
const attackerFailure = await requestJson(port, "POST", "/user/subscribeEmail", {
372+
username: "wallet",
373+
enabled: 1,
374+
from: "attacker@example.com",
375+
to: "bad@example.com"
376+
});
377+
assert.equal(attackerFailure.statusCode, 401);
378+
assert.deepEqual(attackerFailure.json, { error: "FROM email does not match" });
379+
380+
const validUpdate = await requestJson(port, "POST", "/user/subscribeEmail", {
381+
username: "wallet",
382+
enabled: 1,
383+
from: "old@example.com",
384+
to: "new@example.com"
385+
});
386+
assert.equal(validUpdate.statusCode, 200);
387+
assert.deepEqual(validUpdate.json, { msg: "Email preferences were updated" });
388+
assert.equal(updateCalls, 2);
389+
});
390+
});
391+
350392
test("public threshold updates still work and missing worker cache rows fail safely", async () => {
351393
const mysql = createMysql(async function handler(sql) {
352394
if (sql.startsWith("SELECT id FROM users WHERE username = ? AND payout_threshold_lock = '1'")) return [];

0 commit comments

Comments
 (0)