diff --git a/src/app.js b/src/app.js index 1d41b0a..d2836e3 100644 --- a/src/app.js +++ b/src/app.js @@ -23,6 +23,7 @@ const { serializeUserCookie, clearUserCookie, } = require("./lib/auth"); +const { FixedWindowRateLimiter } = require("./lib/rate-limit"); function sanitizeReturnTo(value, fallback = "/app") { if (!value || typeof value !== "string") { @@ -53,6 +54,19 @@ function buildApp({ config, initialState = null, onMutation = null }) { creditConfig: config.credit, 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() { 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) { if (userId) { return null; @@ -150,6 +192,7 @@ function buildApp({ config, initialState = null, onMutation = null }) { const safeHeaders = headers || {}; const safeQuery = query || {}; const userId = getAuthenticatedUserId(safeHeaders); + const clientAddress = clientAddressFromHeaders(safeHeaders); if (method === "GET" && path === "/health") { return json(200, { ok: true }); @@ -195,6 +238,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { } if (method === "POST" && path === "/auth/dev-login") { + const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); + if (rateLimited) { + return rateLimited; + } + const form = parseFormUrlEncoded(rawBody); const requestedUserId = String(form.userId || "").trim(); @@ -232,6 +280,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { } if (method === "POST" && path === "/app/actions/topup") { + const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app"); + if (rateLimited) { + return rateLimited; + } + const authResponse = ensureAuth(userId, "/app"); if (authResponse) { return authResponse; @@ -249,6 +302,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { } 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"); if (authResponse) { return authResponse; @@ -291,6 +349,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { } 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 authResponse = ensureAuth(userId, `/audio/${assetId}`); if (authResponse) { @@ -316,10 +379,18 @@ function buildApp({ config, initialState = null, onMutation = null }) { } if (method === "POST" && path === "/api/webhooks/x") { + const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`); + if (rateLimited) { + return rateLimited; + } return handleXWebhook(safeHeaders, rawBody); } if (method === "POST" && path === "/api/webhooks/polar") { + const rateLimited = enforceJsonRateLimit(webhookLimiter, `webhook:${clientAddress}`); + if (rateLimited) { + return rateLimited; + } 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")) { + const rateLimited = enforceJsonRateLimit(actionLimiter, `action:${userId || clientAddress}`); + if (rateLimited) { + return rateLimited; + } + if (!userId) { return json(401, { error: "auth_required" }); } diff --git a/src/config.js b/src/config.js index 8609562..3691cec 100644 --- a/src/config.js +++ b/src/config.js @@ -20,6 +20,11 @@ const config = { stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"), xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-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: { baseCredits: intFromEnv("BASE_CREDITS", 1), includedChars: intFromEnv("INCLUDED_CHARS", 25000), diff --git a/test/app.test.js b/test/app.test.js index 590080f..651d5c1 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -6,19 +6,43 @@ const { buildApp } = require("../src/app"); const { hmacSHA256Hex } = require("../src/lib/signature"); function createApp(options = {}) { - return buildApp({ - config: { - xWebhookSecret: "x-secret", - polarWebhookSecret: "polar-secret", - credit: { - baseCredits: 1, - includedChars: 25000, - stepChars: 10000, - stepCredits: 1, - maxCharsPerArticle: 120000, - }, + const baseConfig = { + xWebhookSecret: "x-secret", + polarWebhookSecret: "polar-secret", + rateLimits: { + webhookPerMinute: 120, + authPerMinute: 30, + actionPerMinute: 60, }, - ...options, + credit: { + baseCredits: 1, + includedChars: 25000, + stepChars: 10000, + stepCredits: 1, + maxCharsPerArticle: 120000, + }, + }; + + const overrideConfig = options.config || {}; + const mergedConfig = { + ...baseConfig, + ...overrideConfig, + rateLimits: { + ...baseConfig.rateLimits, + ...(overrideConfig.rateLimits || {}), + }, + 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.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/); +}); diff --git a/test/config.test.js b/test/config.test.js index ab8218d..f4e652b 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -7,16 +7,19 @@ test("config uses defaults when env is missing", () => { const previous = { PORT: process.env.PORT, STATE_FILE_PATH: process.env.STATE_FILE_PATH, + WEBHOOK_RPM: process.env.WEBHOOK_RPM, }; delete process.env.PORT; delete process.env.STATE_FILE_PATH; + delete process.env.WEBHOOK_RPM; delete require.cache[require.resolve("../src/config")]; const { config } = require("../src/config"); assert.equal(config.port, 3000); assert.equal(config.stateFilePath, "./data/state.json"); + assert.equal(config.rateLimits.webhookPerMinute, 120); if (previous.PORT === undefined) { delete process.env.PORT; @@ -29,22 +32,31 @@ test("config uses defaults when env is missing", () => { } else { 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", () => { const previous = { PORT: process.env.PORT, STATE_FILE_PATH: process.env.STATE_FILE_PATH, + WEBHOOK_RPM: process.env.WEBHOOK_RPM, }; process.env.PORT = "8080"; process.env.STATE_FILE_PATH = "/data/prod-state.json"; + process.env.WEBHOOK_RPM = "77"; delete require.cache[require.resolve("../src/config")]; const { config } = require("../src/config"); assert.equal(config.port, 8080); assert.equal(config.stateFilePath, "/data/prod-state.json"); + assert.equal(config.rateLimits.webhookPerMinute, 77); if (previous.PORT === undefined) { delete process.env.PORT; @@ -57,4 +69,9 @@ test("config reads state path and numeric env overrides", () => { } else { 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; + } });