feat(server): proxy — SEICHE_TRUST_PROXY + SEICHE_COOKIE_SECURE (+4 tests, 30/30)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user