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),
|
||||
|
||||
Reference in New Issue
Block a user