fix: harden authentication security
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vaka.pro
2026-05-24 14:27:22 +03:00
parent 35c3554742
commit fb246e2e55
9 changed files with 371 additions and 51 deletions

View File

@@ -38,10 +38,11 @@ SESSION_SECRET=replace_with_32plus_char_random_secret
# SESSION_COOKIE_NAME=__Host-sid # SESSION_COOKIE_NAME=__Host-sid
# SESSION_COOKIE_SECURE=true # SESSION_COOKIE_SECURE=true
# SESSION_TTL_DAYS=30 # SESSION_TTL_DAYS=30
# AUTH_CLEANUP_INTERVAL_HOURS=24
# ─── Cloudflare Turnstile ──────────────────────────────────── # ─── Cloudflare Turnstile ────────────────────────────────────
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
# Local tests/dev only, never production: # Local tests/dev only, rejected in production:
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token # TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
# ─── SMTP email ────────────────────────────────────────────── # ─── SMTP email ──────────────────────────────────────────────

View File

@@ -1,12 +1,12 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.4.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.4.0", "version": "1.4.1",
"dependencies": { "dependencies": {
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.4.0", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -8,32 +8,37 @@ import authRouter from "./routes/auth";
import healthRouter from "./routes/health"; import healthRouter from "./routes/health";
import racesRouter from "./routes/races"; import racesRouter from "./routes/races";
export function createApp(): express.Express { const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
const app = express();
app.use( export function buildHelmetOptions(securityProfile: string) {
helmet({ return {
contentSecurityPolicy: contentSecurityPolicy:
config.securityProfile === "production" securityProfile === "production"
? { ? {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'"], scriptSrc: ["'self'", TURNSTILE_ORIGIN],
styleSrc: ["'self'"], styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"], imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"], connectSrc: ["'self'", TURNSTILE_ORIGIN],
frameSrc: [TURNSTILE_ORIGIN],
objectSrc: ["'none'"], objectSrc: ["'none'"],
frameAncestors: ["'none'"], frameAncestors: ["'none'"],
}, },
} }
: false, : false,
hsts: hsts:
config.securityProfile === "production" securityProfile === "production"
? { maxAge: 31_536_000, includeSubDomains: true } ? { maxAge: 31_536_000, includeSubDomains: true }
: false, : false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" }, referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
}), };
); }
export function createApp(): express.Express {
const app = express();
app.use(helmet(buildHelmetOptions(config.securityProfile)));
app.use( app.use(
cors({ cors({
origin: config.corsOrigin, origin: config.corsOrigin,

View File

@@ -58,6 +58,10 @@ function appUrl(path: string, token: string): string {
return url.toString(); return url.toString();
} }
function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
}
export async function findUserByEmail(email: string): Promise<UserRow | null> { export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>( const { rows } = await pool.query<UserRow>(
@@ -114,20 +118,25 @@ export async function sendResetEmail(email: string, token: string): Promise<void
export async function registerUser(email: string, password: string): Promise<void> { export async function registerUser(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const existing = await findUserByEmail(normalized);
if (existing) {
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
const { rows } = await client.query<UserRow>( let rows: UserRow[];
try {
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash) `INSERT INTO users (email, password_hash)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at`, RETURNING id, email, password_hash, email_verified_at`,
[normalized, passwordHash], [normalized, passwordHash],
); ));
} catch (error) {
if (isUniqueViolation(error)) {
await client.query("ROLLBACK");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
return;
}
throw error;
}
const user = rows[0]; const user = rows[0];
if (user) { if (user) {
const token = await createVerificationToken(client, user.id); const token = await createVerificationToken(client, user.id);
@@ -239,14 +248,27 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>( const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash `SELECT id, user_id, token_hash
FROM email_verification_tokens FROM email_verification_tokens
WHERE used_at IS NULL AND expires_at > NOW()`, WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
); );
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) { if (!row) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
return false; return false;
} }
await client.query("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); const consumed = await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]); await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
await client.query("SELECT pg_advisory_xact_lock(706365)"); await client.query("SELECT pg_advisory_xact_lock(706365)");
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE"); const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
@@ -300,14 +322,27 @@ export async function resetPassword(token: string, password: string): Promise<bo
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>( const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash `SELECT id, user_id, token_hash
FROM password_reset_tokens FROM password_reset_tokens
WHERE used_at IS NULL AND expires_at > NOW()`, WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
); );
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash)); const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) { if (!row) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
return false; return false;
} }
await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [row.user_id]); const consumed = await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]); await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]); await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
await client.query("COMMIT"); await client.query("COMMIT");
@@ -346,3 +381,16 @@ export async function cleanupExpiredAuthRows(): Promise<void> {
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL"); await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
} }
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
const runCleanup = () => {
void cleanupExpiredAuthRows().catch((error) => {
console.error("[auth-cleanup] Failed:", error);
});
};
runCleanup();
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
timer.unref?.();
return timer;
}

View File

@@ -34,6 +34,23 @@ const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" || process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true"; process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
useMockDb: boolean;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production" && !params.useMockDb) {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
if (raw) {
return raw;
}
return params.useMockDb ? "mock-turnstile-token" : "";
}
export const config = { export const config = {
useMockDb, useMockDb,
db: useMockDb db: useMockDb
@@ -73,9 +90,14 @@ export const config = {
}, },
turnstile: { turnstile: {
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""), secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
bypassToken: process.env.TURNSTILE_BYPASS_TOKEN?.trim() || (useMockDb ? "mock-turnstile-token" : ""), bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
useMockDb,
}),
}, },
securityProfile: process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development", authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
}; };
function parseCorsOrigins(): string | string[] { function parseCorsOrigins(): string | string[] {

View File

@@ -101,6 +101,24 @@ function createMockPool(): Pool {
return result([{ count: String(users.size) } as unknown as T]); return result([{ count: String(users.size) } as unknown as T]);
} }
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) { if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? ""); const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
@@ -114,6 +132,13 @@ function createMockPool(): Pool {
} }
if (sql.includes("INSERT INTO users")) { if (sql.includes("INSERT INTO users")) {
const email = String(p[0] ?? "").trim().toLowerCase();
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
if (existing) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const row = { const row = {
id, id,
@@ -141,16 +166,37 @@ function createMockPool(): Pool {
return result([row as unknown as T], "INSERT"); return result([row as unknown as T], "INSERT");
} }
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = verificationTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) { if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? ""); const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of verificationTokens.values()) { for (const row of verificationTokens.values()) {
if (row.user_id === userId && row.used_at == null) { if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date(); row.used_at = new Date();
} }
} }
return result<T>([], "UPDATE"); return result<T>([], "UPDATE");
} }
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) { if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now(); const now = Date.now();
return result( return result(
@@ -172,16 +218,37 @@ function createMockPool(): Pool {
return result([row as unknown as T], "INSERT"); return result([row as unknown as T], "INSERT");
} }
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = resetTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) { if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? ""); const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of resetTokens.values()) { for (const row of resetTokens.values()) {
if (row.user_id === userId && row.used_at == null) { if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date(); row.used_at = new Date();
} }
} }
return result<T>([], "UPDATE"); return result<T>([], "UPDATE");
} }
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) { if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now(); const now = Date.now();
return result( return result(
@@ -273,6 +340,44 @@ function createMockPool(): Pool {
return result<T>([], "UPDATE"); return result<T>([], "UPDATE");
} }
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of sessions.entries()) {
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
sessions.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of verificationTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
verificationTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of resetTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
resetTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("SELECT value FROM app_settings")) { if (sql.includes("SELECT value FROM app_settings")) {
const value = appSettings.get("orphan_races_claimed_by_user_id"); const value = appSettings.get("orphan_races_claimed_by_user_id");
return value ? result([{ value } as unknown as T]) : emptyResult(); return value ? result([{ value } as unknown as T]) : emptyResult();

View File

@@ -1,7 +1,9 @@
import { config } from "./config"; import { config } from "./config";
import { createApp } from "./app"; import { createApp } from "./app";
import { startAuthCleanupSchedule } from "./authService";
const app = createApp(); const app = createApp();
startAuthCleanupSchedule();
app.listen(config.apiPort, () => { app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`); console.log(`[api] Listening on http://localhost:${config.apiPort}`);

View File

@@ -1,8 +1,15 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app"; import { buildHelmetOptions, createApp } from "../src/app";
import { createResetToken, resetPassword } from "../src/authService"; import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
resetPassword,
verifyEmailToken,
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db"; import { pool } from "../src/db";
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security"; import { hashPassword, normalizeEmail } from "../src/security";
@@ -30,6 +37,14 @@ async function authAgent() {
} }
async function createVerifiedUser(email: string, password: string) { async function createVerifiedUser(email: string, password: string) {
return createUser(email, password, true);
}
async function createUnverifiedUser(email: string, password: string) {
return createUser(email, password, false);
}
async function createUser(email: string, password: string, verified: boolean) {
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>( const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash) `INSERT INTO users (email, password_hash)
@@ -37,12 +52,22 @@ async function createVerifiedUser(email: string, password: string) {
RETURNING id`, RETURNING id`,
[normalizeEmail(email), passwordHash], [normalizeEmail(email), passwordHash],
); );
if (verified) {
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [ await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id, inserted.rows[0].id,
]); ]);
}
return inserted.rows[0].id; return inserted.rows[0].id;
} }
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
[tokenHash],
);
return Number(rows[0]?.count ?? "0");
}
test("GET /api/health returns ok", async () => { test("GET /api/health returns ok", async () => {
const res = await request(app).get("/api/health").expect(200); const res = await request(app).get("/api/health").expect(200);
assert.equal(res.body.status, "ok"); assert.equal(res.body.status, "ok");
@@ -62,6 +87,61 @@ test("GET /api/ready succeeds with mock database", async () => {
assert.equal(res.body.db, "connected"); assert.equal(res.body.db, "connected");
}); });
test("production config rejects Turnstile bypass token", () => {
assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
useMockDb: false,
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
useMockDb: false,
}),
"dev-bypass",
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "mock-bypass",
securityProfile: "production",
useMockDb: true,
}),
"mock-bypass",
);
});
test("production CSP allows Turnstile without unsafe script directives", () => {
const options = buildHelmetOptions("production") as any;
const directives = options.contentSecurityPolicy.directives;
assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com"));
assert.ok(!directives.scriptSrc.includes("'unsafe-inline'"));
assert.ok(!directives.scriptSrc.includes("'unsafe-eval'"));
assert.deepEqual(directives.frameAncestors, ["'none'"]);
assert.deepEqual(directives.objectSrc, ["'none'"]);
});
test("duplicate registration keeps generic accepted response", async () => {
const payload = {
email: "duplicate-register@example.com",
password: "correct horse battery staple",
turnstileToken: "mock-turnstile-token",
};
const first = await request(app).post("/api/auth/register").send(payload).expect(202);
const second = await request(app).post("/api/auth/register").send(payload).expect(202);
assert.deepEqual(first.body, { ok: true });
assert.deepEqual(second.body, { ok: true });
});
test("GET /api/races rejects invalid year", async () => { test("GET /api/races rejects invalid year", async () => {
const { agent } = await authAgent(); const { agent } = await authAgent();
const res = await agent.get("/api/races?year=bad").expect(400); const res = await agent.get("/api/races?year=bad").expect(400);
@@ -136,6 +216,63 @@ test("new password reset token invalidates previous token", async () => {
assert.equal(await resetPassword(first, "new correct horse battery staple"), false); assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
assert.equal(await resetPassword(second, "new correct horse battery staple"), true); assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
assert.equal(await resetPassword(second, "new correct horse battery staple"), false);
});
test("email verification token is single use", async () => {
const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple");
const client = await pool.connect();
const token = await createVerificationToken(client, userId);
client.release();
assert.equal(await verifyEmailToken(token), true);
assert.equal(await verifyEmailToken(token), false);
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
const expiredSession = "expired-session-token-hash";
const activeSession = "active-session-token-hash";
const expiredVerify = "expired-verify-token-hash";
const activeVerify = "active-verify-token-hash";
const expiredReset = "expired-reset-token-hash";
const activeReset = "active-reset-token-hash";
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, expiredSession, "expired-csrf", past],
);
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, activeSession, "active-csrf", future],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredVerify, past],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeVerify, future],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredReset, past],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeReset, future],
);
await cleanupExpiredAuthRows();
assert.equal(await countByTokenHash("sessions", expiredSession), 0);
assert.equal(await countByTokenHash("sessions", activeSession), 1);
assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0);
assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1);
assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0);
assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1);
}); });
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => { test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {