feat: enforce route-level rate limits for webhook auth and user actions
This commit is contained in:
76
src/app.js
76
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" });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -6,10 +6,14 @@ const { buildApp } = require("../src/app");
|
||||
const { hmacSHA256Hex } = require("../src/lib/signature");
|
||||
|
||||
function createApp(options = {}) {
|
||||
return buildApp({
|
||||
config: {
|
||||
const baseConfig = {
|
||||
xWebhookSecret: "x-secret",
|
||||
polarWebhookSecret: "polar-secret",
|
||||
rateLimits: {
|
||||
webhookPerMinute: 120,
|
||||
authPerMinute: 30,
|
||||
actionPerMinute: 60,
|
||||
},
|
||||
credit: {
|
||||
baseCredits: 1,
|
||||
includedChars: 25000,
|
||||
@@ -17,8 +21,28 @@ function createApp(options = {}) {
|
||||
stepCredits: 1,
|
||||
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.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/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user