feat: enforce route-level rate limits for webhook auth and user actions

This commit is contained in:
Codex
2026-02-18 13:02:44 +00:00
parent a9ef1e5e23
commit 989b5cf048
4 changed files with 187 additions and 12 deletions

View File

@@ -23,6 +23,7 @@ const {
serializeUserCookie, serializeUserCookie,
clearUserCookie, clearUserCookie,
} = require("./lib/auth"); } = require("./lib/auth");
const { FixedWindowRateLimiter } = require("./lib/rate-limit");
function sanitizeReturnTo(value, fallback = "/app") { function sanitizeReturnTo(value, fallback = "/app") {
if (!value || typeof value !== "string") { if (!value || typeof value !== "string") {
@@ -53,6 +54,19 @@ function buildApp({ config, initialState = null, onMutation = null }) {
creditConfig: config.credit, creditConfig: config.credit,
initialState: initialState && initialState.engine ? initialState.engine : null, initialState: initialState && initialState.engine ? initialState.engine : null,
}); });
const rateLimits = config.rateLimits || {};
const webhookLimiter = new FixedWindowRateLimiter({
limit: rateLimits.webhookPerMinute || 120,
windowMs: 60_000,
});
const authLimiter = new FixedWindowRateLimiter({
limit: rateLimits.authPerMinute || 30,
windowMs: 60_000,
});
const actionLimiter = new FixedWindowRateLimiter({
limit: rateLimits.actionPerMinute || 60,
windowMs: 60_000,
});
function persistMutation() { function persistMutation() {
if (!onMutation) { if (!onMutation) {
@@ -70,6 +84,34 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
} }
function clientAddressFromHeaders(headers) {
const raw = headers["x-forwarded-for"] || headers["x-real-ip"] || headers["cf-connecting-ip"] || "unknown";
return String(raw).split(",")[0].trim() || "unknown";
}
function enforceJsonRateLimit(limiter, key) {
const decision = limiter.hit(key);
if (decision.allowed) {
return null;
}
return json(429, {
error: "rate_limited",
retryAfterSec: decision.retryAfterSec,
});
}
function enforceRedirectRateLimit(limiter, key, path) {
const decision = limiter.hit(key);
if (decision.allowed) {
return null;
}
return redirect(withQuery(path, {
flash: `Too many requests. Retry in ${decision.retryAfterSec}s`,
}));
}
function ensureAuth(userId, returnTo) { function ensureAuth(userId, returnTo) {
if (userId) { if (userId) {
return null; return null;
@@ -150,6 +192,7 @@ function buildApp({ config, initialState = null, onMutation = null }) {
const safeHeaders = headers || {}; const safeHeaders = headers || {};
const safeQuery = query || {}; const safeQuery = query || {};
const userId = getAuthenticatedUserId(safeHeaders); const userId = getAuthenticatedUserId(safeHeaders);
const clientAddress = clientAddressFromHeaders(safeHeaders);
if (method === "GET" && path === "/health") { if (method === "GET" && path === "/health") {
return json(200, { ok: true }); return json(200, { ok: true });
@@ -195,6 +238,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path === "/auth/dev-login") { if (method === "POST" && path === "/auth/dev-login") {
const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login");
if (rateLimited) {
return rateLimited;
}
const form = parseFormUrlEncoded(rawBody); const form = parseFormUrlEncoded(rawBody);
const requestedUserId = String(form.userId || "").trim(); const requestedUserId = String(form.userId || "").trim();
@@ -232,6 +280,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path === "/app/actions/topup") { if (method === "POST" && path === "/app/actions/topup") {
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
if (rateLimited) {
return rateLimited;
}
const authResponse = ensureAuth(userId, "/app"); const authResponse = ensureAuth(userId, "/app");
if (authResponse) { if (authResponse) {
return authResponse; return authResponse;
@@ -249,6 +302,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path === "/app/actions/simulate-mention") { if (method === "POST" && path === "/app/actions/simulate-mention") {
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app");
if (rateLimited) {
return rateLimited;
}
const authResponse = ensureAuth(userId, "/app"); const authResponse = ensureAuth(userId, "/app");
if (authResponse) { if (authResponse) {
return authResponse; return authResponse;
@@ -291,6 +349,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) { if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) {
const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", ""));
if (rateLimited) {
return rateLimited;
}
const assetId = path.slice("/audio/".length, -"/unlock".length); const assetId = path.slice("/audio/".length, -"/unlock".length);
const authResponse = ensureAuth(userId, `/audio/${assetId}`); const authResponse = ensureAuth(userId, `/audio/${assetId}`);
if (authResponse) { if (authResponse) {
@@ -316,10 +379,18 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path === "/api/webhooks/x") { if (method === "POST" && path === "/api/webhooks/x") {
const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`);
if (rateLimited) {
return rateLimited;
}
return handleXWebhook(safeHeaders, rawBody); return handleXWebhook(safeHeaders, rawBody);
} }
if (method === "POST" && path === "/api/webhooks/polar") { if (method === "POST" && path === "/api/webhooks/polar") {
const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`);
if (rateLimited) {
return rateLimited;
}
return handlePolarWebhook(safeHeaders, rawBody); return handlePolarWebhook(safeHeaders, rawBody);
} }
@@ -347,6 +418,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
} }
if (method === "POST" && path.startsWith("/api/audio/") && path.endsWith("/unlock")) { if (method === "POST" && path.startsWith("/api/audio/") && path.endsWith("/unlock")) {
const rateLimited = enforceJsonRateLimit(actionLimiter, `action:${userId || clientAddress}`);
if (rateLimited) {
return rateLimited;
}
if (!userId) { if (!userId) {
return json(401, { error: "auth_required" }); return json(401, { error: "auth_required" });
} }

View File

@@ -20,6 +20,11 @@ const config = {
stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"), stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"),
xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret", xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret",
polarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET || "dev-polar-secret", polarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET || "dev-polar-secret",
rateLimits: {
webhookPerMinute: intFromEnv("WEBHOOK_RPM", 120),
authPerMinute: intFromEnv("AUTH_RPM", 30),
actionPerMinute: intFromEnv("ACTION_RPM", 60),
},
credit: { credit: {
baseCredits: intFromEnv("BASE_CREDITS", 1), baseCredits: intFromEnv("BASE_CREDITS", 1),
includedChars: intFromEnv("INCLUDED_CHARS", 25000), includedChars: intFromEnv("INCLUDED_CHARS", 25000),

View File

@@ -6,10 +6,14 @@ const { buildApp } = require("../src/app");
const { hmacSHA256Hex } = require("../src/lib/signature"); const { hmacSHA256Hex } = require("../src/lib/signature");
function createApp(options = {}) { function createApp(options = {}) {
return buildApp({ const baseConfig = {
config: {
xWebhookSecret: "x-secret", xWebhookSecret: "x-secret",
polarWebhookSecret: "polar-secret", polarWebhookSecret: "polar-secret",
rateLimits: {
webhookPerMinute: 120,
authPerMinute: 30,
actionPerMinute: 60,
},
credit: { credit: {
baseCredits: 1, baseCredits: 1,
includedChars: 25000, includedChars: 25000,
@@ -17,8 +21,28 @@ function createApp(options = {}) {
stepCredits: 1, stepCredits: 1,
maxCharsPerArticle: 120000, maxCharsPerArticle: 120000,
}, },
};
const overrideConfig = options.config || {};
const mergedConfig = {
...baseConfig,
...overrideConfig,
rateLimits: {
...baseConfig.rateLimits,
...(overrideConfig.rateLimits || {}),
}, },
...options, credit: {
...baseConfig.credit,
...(overrideConfig.credit || {}),
},
};
const appOptions = { ...options };
delete appOptions.config;
return buildApp({
config: mergedConfig,
...appOptions,
}); });
} }
@@ -224,3 +248,56 @@ test("emits persistence snapshots on mutating actions", () => {
assert.equal(typeof latest.updatedAt, "string"); assert.equal(typeof latest.updatedAt, "string");
assert.equal(typeof latest.engine, "object"); assert.equal(typeof latest.engine, "object");
}); });
test("rate limits repeated webhook calls", () => {
const app = createApp({
config: {
rateLimits: {
webhookPerMinute: 1,
},
},
});
const first = call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
body: JSON.stringify({}),
});
const second = call(app, {
method: "POST",
path: "/api/webhooks/x",
headers: { "x-forwarded-for": "1.2.3.4", "x-signature": "sha256=deadbeef" },
body: JSON.stringify({}),
});
assert.equal(first.status, 401);
assert.equal(second.status, 429);
});
test("rate limits repeated login attempts from same IP", () => {
const app = createApp({
config: {
rateLimits: {
authPerMinute: 1,
},
},
});
const first = call(app, {
method: "POST",
path: "/auth/dev-login",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
});
const second = call(app, {
method: "POST",
path: "/auth/dev-login",
headers: { "x-forwarded-for": "5.5.5.5" },
body: "userId=alice&returnTo=%2Fapp",
});
assert.equal(first.status, 303);
assert.equal(second.status, 303);
assert.match(second.headers.location, /Too%20many%20requests/);
});

View File

@@ -7,16 +7,19 @@ test("config uses defaults when env is missing", () => {
const previous = { const previous = {
PORT: process.env.PORT, PORT: process.env.PORT,
STATE_FILE_PATH: process.env.STATE_FILE_PATH, STATE_FILE_PATH: process.env.STATE_FILE_PATH,
WEBHOOK_RPM: process.env.WEBHOOK_RPM,
}; };
delete process.env.PORT; delete process.env.PORT;
delete process.env.STATE_FILE_PATH; delete process.env.STATE_FILE_PATH;
delete process.env.WEBHOOK_RPM;
delete require.cache[require.resolve("../src/config")]; delete require.cache[require.resolve("../src/config")];
const { config } = require("../src/config"); const { config } = require("../src/config");
assert.equal(config.port, 3000); assert.equal(config.port, 3000);
assert.equal(config.stateFilePath, "./data/state.json"); assert.equal(config.stateFilePath, "./data/state.json");
assert.equal(config.rateLimits.webhookPerMinute, 120);
if (previous.PORT === undefined) { if (previous.PORT === undefined) {
delete process.env.PORT; delete process.env.PORT;
@@ -29,22 +32,31 @@ test("config uses defaults when env is missing", () => {
} else { } else {
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH; process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
} }
if (previous.WEBHOOK_RPM === undefined) {
delete process.env.WEBHOOK_RPM;
} else {
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
}
}); });
test("config reads state path and numeric env overrides", () => { test("config reads state path and numeric env overrides", () => {
const previous = { const previous = {
PORT: process.env.PORT, PORT: process.env.PORT,
STATE_FILE_PATH: process.env.STATE_FILE_PATH, STATE_FILE_PATH: process.env.STATE_FILE_PATH,
WEBHOOK_RPM: process.env.WEBHOOK_RPM,
}; };
process.env.PORT = "8080"; process.env.PORT = "8080";
process.env.STATE_FILE_PATH = "/data/prod-state.json"; process.env.STATE_FILE_PATH = "/data/prod-state.json";
process.env.WEBHOOK_RPM = "77";
delete require.cache[require.resolve("../src/config")]; delete require.cache[require.resolve("../src/config")];
const { config } = require("../src/config"); const { config } = require("../src/config");
assert.equal(config.port, 8080); assert.equal(config.port, 8080);
assert.equal(config.stateFilePath, "/data/prod-state.json"); assert.equal(config.stateFilePath, "/data/prod-state.json");
assert.equal(config.rateLimits.webhookPerMinute, 77);
if (previous.PORT === undefined) { if (previous.PORT === undefined) {
delete process.env.PORT; delete process.env.PORT;
@@ -57,4 +69,9 @@ test("config reads state path and numeric env overrides", () => {
} else { } else {
process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH; process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH;
} }
if (previous.WEBHOOK_RPM === undefined) {
delete process.env.WEBHOOK_RPM;
} else {
process.env.WEBHOOK_RPM = previous.WEBHOOK_RPM;
}
}); });