sign CSRF with cookie, Login rate-limit key hardened against identifier-only lockout

This commit is contained in:
Zimeng Xiong
2026-02-07 18:52:00 -08:00
parent fd013de325
commit 70103e18fb
6 changed files with 104 additions and 24 deletions
@@ -11,6 +11,7 @@ describe("Auth Enabled Toggle Authorization", () => {
const userAgent = "vitest-auth-enabled";
let prisma: PrismaClient;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
let regularUserToken: string;
@@ -79,7 +80,7 @@ describe("Auth Enabled Toggle Authorization", () => {
signOptions
);
const agent = request.agent(app);
agent = request.agent(app);
const csrfRes = await agent
.get("/csrf-token")
.set("User-Agent", userAgent);
@@ -92,7 +93,7 @@ describe("Auth Enabled Toggle Authorization", () => {
});
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
const response = await request(app)
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -102,7 +103,7 @@ describe("Auth Enabled Toggle Authorization", () => {
});
it("rejects non-admin auth-enabled toggle", async () => {
const response = await request(app)
const response = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${regularUserToken}`)
@@ -120,7 +121,7 @@ describe("Auth Enabled Toggle Authorization", () => {
expect(warmStatusResponse.status).toBe(200);
expect(warmStatusResponse.body?.authEnabled).toBe(true);
const toggleResponse = await request(app)
const toggleResponse = await agent
.post("/auth/auth-enabled")
.set("User-Agent", userAgent)
.set("Authorization", `Bearer ${adminUserToken}`)
@@ -267,6 +267,7 @@ describe("Import compatibility (legacy exports)", () => {
const userAgent = "vitest-import-compat";
let prisma: ReturnType<typeof getTestPrisma>;
let app: any;
let agent: any;
let csrfHeaderName: string;
let csrfToken: string;
@@ -278,7 +279,8 @@ describe("Import compatibility (legacy exports)", () => {
// Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma.
({ app } = await import("../index"));
const csrfRes = await request(app).get("/csrf-token").set("User-Agent", userAgent);
agent = request.agent(app);
const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent);
csrfHeaderName = csrfRes.body.header;
csrfToken = csrfRes.body.token;
expect(typeof csrfHeaderName).toBe("string");
@@ -301,7 +303,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false,
});
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -323,7 +325,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: true,
});
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -359,7 +361,7 @@ describe("Import compatibility (legacy exports)", () => {
includeTrashDrawing: false,
});
const verify = await request(app)
const verify = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -369,7 +371,7 @@ describe("Import compatibility (legacy exports)", () => {
expect(verify.body.drawings).toBe(2);
expect(verify.body.collections).toBe(1);
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -386,7 +388,7 @@ describe("Import compatibility (legacy exports)", () => {
db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`);
db.close();
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -398,7 +400,7 @@ describe("Import compatibility (legacy exports)", () => {
it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await request(app)
const res = await agent
.post("/import/excalidash/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -410,7 +412,7 @@ describe("Import compatibility (legacy exports)", () => {
it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => {
const archive = await createExcalidashArchiveWithDuplicateDrawingIds();
const res = await request(app)
const res = await agent
.post("/import/excalidash")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -422,7 +424,7 @@ describe("Import compatibility (legacy exports)", () => {
it("rejects legacy verify when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy/verify")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
@@ -434,7 +436,7 @@ describe("Import compatibility (legacy exports)", () => {
it("rejects legacy import when DB has duplicate drawing IDs", async () => {
const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds();
const res = await request(app)
const res = await agent
.post("/import/sqlite/legacy")
.set("User-Agent", userAgent)
.set(csrfHeaderName, csrfToken)
+41 -4
View File
@@ -81,6 +81,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT };
let loginAttemptLimiter: ReturnType<typeof rateLimit> | null = null;
let loginLimiterInitPromise: Promise<void> | null = null;
let loginIdentifierKeyIndex = new Map<string, Set<string>>();
const parseLoginRateLimitConfig = (
systemConfig: Awaited<ReturnType<typeof ensureSystemConfig>>
@@ -114,8 +115,31 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
};
const resolveRateLimitIp = (req: Request): string =>
(req.ip || req.connection.remoteAddress || "unknown").slice(0, 255);
const trackIdentifierRateLimitKey = (identifier: string, key: string): void => {
if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) {
const oldestIdentifier = loginIdentifierKeyIndex.keys().next().value;
if (typeof oldestIdentifier === "string") {
loginIdentifierKeyIndex.delete(oldestIdentifier);
}
}
const existing = loginIdentifierKeyIndex.get(identifier) ?? new Set<string>();
if (existing.size >= 50) {
const oldestKey = existing.values().next().value;
if (typeof oldestKey === "string") {
existing.delete(oldestKey);
}
}
existing.add(key);
loginIdentifierKeyIndex.set(identifier, existing);
};
const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => {
const store = new MemoryStore();
loginIdentifierKeyIndex = new Map<string, Set<string>>();
const limiter = rateLimit({
windowMs: cfg.windowMs,
max: cfg.max,
@@ -131,8 +155,12 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
store,
keyGenerator: (req) => {
const identifier = resolveAuthIdentifier(req as Request);
if (identifier) return `login:${identifier}`;
const ip = (req as Request).ip || "unknown";
const ip = resolveRateLimitIp(req as Request);
if (identifier) {
const key = `login:${identifier}:ip:${ip}`;
trackIdentifierRateLimitKey(identifier, key);
return key;
}
return `login-ip:${ip}`;
},
});
@@ -171,9 +199,18 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
const resetLoginAttemptKey = async (identifier: string): Promise<void> => {
await ensureLoginAttemptLimiter();
const key = `login:${identifier}`;
const normalizedIdentifier = identifier.trim().toLowerCase();
const keys = loginIdentifierKeyIndex.get(normalizedIdentifier);
try {
await loginAttemptLimiter?.resetKey(key);
if (!keys || keys.size === 0) {
// Backward-compatible fallback for pre-change key format.
await loginAttemptLimiter?.resetKey(`login:${normalizedIdentifier}`);
return;
}
for (const key of keys) {
await loginAttemptLimiter?.resetKey(key);
}
loginIdentifierKeyIndex.delete(normalizedIdentifier);
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.debug("Rate limit reset skipped:", error);
+41
View File
@@ -0,0 +1,41 @@
import express from "express";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { registerCsrfProtection } from "./csrf";
describe("CSRF token issuance", () => {
it("binds first-issued tokens to cookie client identity", async () => {
const app = express();
app.use(express.json());
registerCsrfProtection({
app,
isAllowedOrigin: () => true,
maxRequestsPerWindow: 100,
});
app.post("/drawings", (_req, res) => {
res.status(200).json({ ok: true });
});
const agent = request.agent(app);
const csrfRes = await agent
.get("/csrf-token")
.set("User-Agent", "csrf-test-agent-a");
expect(csrfRes.status).toBe(200);
const headerName = csrfRes.body.header as string;
const token = csrfRes.body.token as string;
expect(typeof headerName).toBe("string");
expect(typeof token).toBe("string");
const postRes = await agent
.post("/drawings")
.set("User-Agent", "csrf-test-agent-b")
.set(headerName, token)
.send({ name: "test" });
expect(postRes.status).toBe(200);
expect(postRes.body.ok).toBe(true);
});
});
+3 -4
View File
@@ -10,7 +10,6 @@ import {
CSRF_CLIENT_COOKIE_NAME,
getCsrfClientCookieValue,
getCsrfValidationClientIds,
getLegacyClientId,
} from "../security/csrfClient";
const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
@@ -53,7 +52,7 @@ export const registerCsrfProtection = ({
const getClientIdForTokenIssue = (
req: express.Request,
res: express.Response
): { clientId: string; strategy: "cookie" | "legacy-bootstrap" } => {
): { clientId: string; strategy: "cookie" } => {
const existingCookieValue = getCsrfClientCookieValue(req);
if (existingCookieValue) {
return {
@@ -65,8 +64,8 @@ export const registerCsrfProtection = ({
const generatedCookieValue = crypto.randomUUID().replace(/-/g, "");
setCsrfClientCookie(req, res, generatedCookieValue);
return {
clientId: getLegacyClientId(req),
strategy: "legacy-bootstrap",
clientId: `cookie:${generatedCookieValue}`,
strategy: "cookie",
};
};