From 11a5ca5f491eeea2ce4a90357bdc5413afe6f680 Mon Sep 17 00:00:00 2001 From: bot-claude Date: Wed, 26 Aug 2026 07:46:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20proxy=20=E2=80=94=20SEICHE=5FTR?= =?UTF-8?q?UST=5FPROXY=20+=20SEICHE=5FCOOKIE=5FSECURE=20(+4=20tests,=2030/?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +++ server/src/app.ts | 1 + server/src/config.ts | 10 ++++ server/src/routes/auth.ts | 6 +-- server/tests/helpers.ts | 2 + server/tests/proxy.test.ts | 94 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 server/tests/proxy.test.ts diff --git a/.env.example b/.env.example index 3d16354..3981281 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ SEICHE_SESSION_ABSOLUTE_TTL_MS=604800000 # Nombre max de tentatives de connexion par minute (par IP) 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 --- # "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) diff --git a/server/src/app.ts b/server/src/app.ts index db663a8..8433399 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -22,6 +22,7 @@ export async function buildApp(deps: AppDeps): Promise { const { config, db } = deps; const app = Fastify({ + trustProxy: config.trustProxy, logger: { level: process.env.SEICHE_LOG_LEVEL ?? "info", transport: diff --git a/server/src/config.ts b/server/src/config.ts index 6608f75..e1d0cec 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,6 +12,8 @@ export interface Config { sessionTtlMs: number; sessionAbsoluteTtlMs: number; maxLoginAttemptsPerMinute: number; + cookieSecure: boolean; + trustProxy: boolean; publicDir: string | null; external: { adapter: "example" | "http"; @@ -40,6 +42,12 @@ function envInt(name: string, fallback: number): number { 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 { const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data")); return { @@ -49,6 +57,8 @@ export function loadConfig(): Config { sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000), sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000), 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, external: { adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http", diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 010ae68..9bafe7a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -52,13 +52,13 @@ export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void { httpOnly: true, sameSite: "lax" as const, 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, { httpOnly: false, sameSite: "lax", path: "/", - secure: false, + secure: config.cookieSecure, }); return reply.send({ user: { username: user.username }, @@ -74,7 +74,7 @@ export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void { async (request, reply) => { const token = request.cookies[SESSION_COOKIE]; 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 .setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 }) .setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 }); diff --git a/server/tests/helpers.ts b/server/tests/helpers.ts index e586733..13d57dc 100644 --- a/server/tests/helpers.ts +++ b/server/tests/helpers.ts @@ -12,6 +12,8 @@ export function makeConfig(overrides: Partial = {}): Config { sessionTtlMs: 8 * 60 * 60 * 1000, sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000, maxLoginAttemptsPerMinute: 5, + cookieSecure: false, + trustProxy: false, publicDir: null, external: { adapter: "example", diff --git a/server/tests/proxy.test.ts b/server/tests/proxy.test.ts new file mode 100644 index 0000000..5ba25f4 --- /dev/null +++ b/server/tests/proxy.test.ts @@ -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 { + await db.insert(users).values({ username, passwordHash: await hashPassword(password) }); +} + +function setCookies(res: { headers: Record }, 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[0], +): Promise { + db = await openTestDb(); + const instance = await buildApp({ config: makeConfig(overrides), db }); + await seedUser("alice", "secret"); + return instance; +} + +function login(password: string, headers: Record = {}) { + 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"); + } + }); + }); +});