feat(server): proxy — SEICHE_TRUST_PROXY + SEICHE_COOKIE_SECURE (+4 tests, 30/30)

This commit is contained in:
bot-claude
2026-08-26 07:46:28 +02:00
parent e9ace97ee5
commit 11a5ca5f49
6 changed files with 116 additions and 3 deletions
+6
View File
@@ -20,6 +20,12 @@ SEICHE_SESSION_ABSOLUTE_TTL_MS=604800000
# Nombre max de tentatives de connexion par minute (par IP) # Nombre max de tentatives de connexion par minute (par IP)
SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE=5 SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE=5
# --- Réseau / proxy (Caddy, nginx, …) ---
# À mettre à 1 derrière un proxy : cookies marqués Secure
SEICHE_COOKIE_SECURE=0
# À mettre à 1 derrière un proxy : Fastify lit X-Forwarded-For (rate-limit par IP client)
SEICHE_TRUST_PROXY=0
# --- Service REST externe --- # --- Service REST externe ---
# "example" = adaptateur de démonstration (données statiques, pour tester le pipeline) # "example" = adaptateur de démonstration (données statiques, pour tester le pipeline)
# "http" = adaptateur générique pointant vers une vraie API REST (à paramétrer) # "http" = adaptateur générique pointant vers une vraie API REST (à paramétrer)
+1
View File
@@ -22,6 +22,7 @@ export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
const { config, db } = deps; const { config, db } = deps;
const app = Fastify({ const app = Fastify({
trustProxy: config.trustProxy,
logger: { logger: {
level: process.env.SEICHE_LOG_LEVEL ?? "info", level: process.env.SEICHE_LOG_LEVEL ?? "info",
transport: transport:
+10
View File
@@ -12,6 +12,8 @@ export interface Config {
sessionTtlMs: number; sessionTtlMs: number;
sessionAbsoluteTtlMs: number; sessionAbsoluteTtlMs: number;
maxLoginAttemptsPerMinute: number; maxLoginAttemptsPerMinute: number;
cookieSecure: boolean;
trustProxy: boolean;
publicDir: string | null; publicDir: string | null;
external: { external: {
adapter: "example" | "http"; adapter: "example" | "http";
@@ -40,6 +42,12 @@ function envInt(name: string, fallback: number): number {
return value; return value;
} }
function envBool(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
return raw === "1" || raw.toLowerCase() === "true";
}
export function loadConfig(): Config { export function loadConfig(): Config {
const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data")); const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data"));
return { return {
@@ -49,6 +57,8 @@ export function loadConfig(): Config {
sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000), sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000),
sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000), sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000),
maxLoginAttemptsPerMinute: envInt("SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE", 5), maxLoginAttemptsPerMinute: envInt("SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE", 5),
cookieSecure: envBool("SEICHE_COOKIE_SECURE", false),
trustProxy: envBool("SEICHE_TRUST_PROXY", false),
publicDir: process.env.SEICHE_PUBLIC_DIR ?? null, publicDir: process.env.SEICHE_PUBLIC_DIR ?? null,
external: { external: {
adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http", adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http",
+3 -3
View File
@@ -52,13 +52,13 @@ export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void {
httpOnly: true, httpOnly: true,
sameSite: "lax" as const, sameSite: "lax" as const,
path: "/", path: "/",
secure: false, // TLS géré par Caddy/proxy devant (voir README) secure: config.cookieSecure, // activer SEICHE_COOKIE_SECURE=1 derrière un proxy TLS
}; };
reply.setCookie(SESSION_COOKIE, token, cookieOptions).setCookie(CSRF_COOKIE, csrfToken, { reply.setCookie(SESSION_COOKIE, token, cookieOptions).setCookie(CSRF_COOKIE, csrfToken, {
httpOnly: false, httpOnly: false,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
secure: false, secure: config.cookieSecure,
}); });
return reply.send({ return reply.send({
user: { username: user.username }, user: { username: user.username },
@@ -74,7 +74,7 @@ export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void {
async (request, reply) => { async (request, reply) => {
const token = request.cookies[SESSION_COOKIE]; const token = request.cookies[SESSION_COOKIE];
await app.sessionManager.destroy(token); await app.sessionManager.destroy(token);
const clearOptions = { path: "/", sameSite: "lax" as const, secure: false }; const clearOptions = { path: "/", sameSite: "lax" as const, secure: config.cookieSecure };
reply reply
.setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 }) .setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 })
.setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 }); .setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 });
+2
View File
@@ -12,6 +12,8 @@ export function makeConfig(overrides: Partial<Config> = {}): Config {
sessionTtlMs: 8 * 60 * 60 * 1000, sessionTtlMs: 8 * 60 * 60 * 1000,
sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000, sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000,
maxLoginAttemptsPerMinute: 5, maxLoginAttemptsPerMinute: 5,
cookieSecure: false,
trustProxy: false,
publicDir: null, publicDir: null,
external: { external: {
adapter: "example", adapter: "example",
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance } from "fastify";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { hashPassword } from "../src/auth/password";
import { CSRF_COOKIE, SESSION_COOKIE } from "../src/auth/session";
import type { Database } from "../src/db/client";
import { users } from "../src/db/schema";
import { makeConfig, openTestDb } from "./helpers";
// Tests : pas de worker pino-pretty, error-handler en mode production.
process.env.NODE_ENV = "production";
process.env.SEICHE_LOG_LEVEL = "silent";
let db: Database;
let app!: FastifyInstance;
afterEach(async () => {
await app.close();
});
async function seedUser(username: string, password: string): Promise<void> {
await db.insert(users).values({ username, passwordHash: await hashPassword(password) });
}
function setCookies(res: { headers: Record<string, unknown> }, name: string): string[] {
const raw = res.headers["set-cookie"];
const list = Array.isArray(raw) ? raw : raw === undefined ? [] : [String(raw)];
return list.filter((c): c is string => typeof c === "string" && c.startsWith(`${name}=`));
}
async function buildLoginApp(
overrides: Parameters<typeof makeConfig>[0],
): Promise<FastifyInstance> {
db = await openTestDb();
const instance = await buildApp({ config: makeConfig(overrides), db });
await seedUser("alice", "secret");
return instance;
}
function login(password: string, headers: Record<string, string> = {}) {
return app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password },
headers,
});
}
describe("Proxy (SEICHE_TRUST_PROXY / SEICHE_COOKIE_SECURE)", () => {
describe("trustProxy", () => {
it("activé : X-Forwarded-For est parsé → chaque IP client est limitée séparément", async () => {
app = await buildLoginApp({ trustProxy: true });
const ips = [7, 8, 9, 10, 11, 12].map((n) => `203.0.113.${n}`);
for (const ip of ips) {
const res = await login("wrong", { "x-forwarded-for": ip });
expect(res.statusCode, `l'IP ${ip} a été bloquée à tort`).toBe(401);
}
});
it("désactivé (défaut) : X-Forwarded-For est ignoré → même IP socket, 6e tentative bloquée", async () => {
app = await buildLoginApp({ trustProxy: false });
const codes: number[] = [];
for (let i = 7; i <= 12; i++) {
const res = await login("wrong", { "x-forwarded-for": `203.0.113.${i}` });
codes.push(res.statusCode);
}
expect(codes).toEqual([401, 401, 401, 401, 401, 429]);
});
});
describe("cookieSecure", () => {
it("activé : les cookies de session et CSRF sont marqués Secure", async () => {
app = await buildLoginApp({ cookieSecure: true });
const res = await login("secret");
expect(res.statusCode).toBe(200);
for (const name of [SESSION_COOKIE, CSRF_COOKIE]) {
const cookies = setCookies(res, name);
expect(cookies, `cookie ${name} absent`).toHaveLength(1);
expect(cookies[0]?.toLowerCase()).toContain("secure");
}
});
it("désactivé (défaut) : pas de drapeau Secure", async () => {
app = await buildLoginApp({ cookieSecure: false });
const res = await login("secret");
expect(res.statusCode).toBe(200);
for (const name of [SESSION_COOKIE, CSRF_COOKIE]) {
const cookies = setCookies(res, name);
expect(cookies).toHaveLength(1);
expect(cookies[0]?.toLowerCase()).not.toContain("secure");
}
});
});
});