fix: harden authentication security
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { createResetToken, resetPassword } from "../src/authService";
|
||||
import { buildHelmetOptions, createApp } from "../src/app";
|
||||
import {
|
||||
cleanupExpiredAuthRows,
|
||||
createResetToken,
|
||||
createVerificationToken,
|
||||
resetPassword,
|
||||
verifyEmailToken,
|
||||
} from "../src/authService";
|
||||
import { resolveTurnstileBypassToken } from "../src/config";
|
||||
import { pool } from "../src/db";
|
||||
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
|
||||
import { hashPassword, normalizeEmail } from "../src/security";
|
||||
@@ -30,6 +37,14 @@ async function authAgent() {
|
||||
}
|
||||
|
||||
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 inserted = await pool.query<{ id: string }>(
|
||||
`INSERT INTO users (email, password_hash)
|
||||
@@ -37,12 +52,22 @@ async function createVerifiedUser(email: string, password: string) {
|
||||
RETURNING id`,
|
||||
[normalizeEmail(email), passwordHash],
|
||||
);
|
||||
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
|
||||
inserted.rows[0].id,
|
||||
]);
|
||||
if (verified) {
|
||||
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
|
||||
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 () => {
|
||||
const res = await request(app).get("/api/health").expect(200);
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { agent } = await authAgent();
|
||||
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(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", () => {
|
||||
|
||||
Reference in New Issue
Block a user