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,
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" });
}

View File

@@ -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),