metalfrom.eu/apps/api/test/publicRoutes.test.js
Nicolas FRYDER a21bd55a90 fix(sécurité): verrouillage de compte détournable, et Vary manquant sur le CORS
isLockedOut comptait `username = $2 OR ip = $3` dans un seul total. Cinq échecs
avec le nom d'un admin, depuis n'importe quelle adresse, verrouillaient donc ce
compte un quart d'heure — à répéter indéfiniment, sans coût et sans connaître
le mot de passe. Il suffisait de connaître le nom d'utilisateur pour mettre
l'admin dehors en permanence.

Les deux compteurs deviennent distincts et volontairement asymétriques. L'IP
reste stricte à 5 : c'est elle qui freine la force brute, et s'auto-verrouiller
n'a aucun intérêt pour un attaquant. Le compteur par username subsiste contre
une attaque répartie, mais à 20 — il faut brûler 4 IP avant de commencer à
gêner le titulaire du compte. Vérifié contre PostgreSQL : l'admin légitime a
bien par_ip=0 quand un tiers martèle depuis ailleurs.

Ajoute par ailleurs `Vary: Origin` aux réponses CORS. L'en-tête Allow-Origin
dépend de l'origine demandée : sans Vary, un cache intermédiaire peut servir à
une origine la réponse mise en cache pour une autre — ce qui revient à
autoriser une origine qui ne l'est pas, ou à faire refuser une origine
légitime. Posé aussi quand l'origine est refusée, pour la même raison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:24:33 +02:00

443 lines
18 KiB
JavaScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
const { rows } = await import("./helpers/fakePool.js");
const { createFullApp, createFullAppWith } = await import("./helpers/testApp.js");
const TOKEN = "c4b99106fcd09badce25810e974ec4d6";
// Deux apps pour tout le fichier (cf. helpers/testApp.js : construire une app
// coûte ~14 ms, multipliées par des milliers d'exécutions en tests de mutation).
// Chaque cas se contente de reprogrammer le pool associé.
let app, pool, tokenApp, tokenPool;
beforeAll(async () => {
({ app, pool } = await createFullApp());
({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN }));
});
afterAll(async () => {
await app.close();
await tokenApp.close();
});
/** Handlers par défaut : chaque requête renvoie quelque chose de plausible. */
function defaultHandlers() {
return [
{ match: "FROM band_locations bl", result: rows({ ma_id: 1, name: "Mayhem", lat: 59.9, lon: 10.7 }) },
{ match: "grid_cells", result: rows({ lat: 59.9, lon: 10.7, count: 3, sample_bands: [] }) },
{ match: "FROM bands", result: rows({ ma_id: 1, name: "Mayhem", total: 1 }) },
];
}
/**
* Reprogramme le pool partagé et renvoie l'app publique.
* @param {import("./helpers/fakePool.js").Handler[]} [handlers]
*/
function build(handlers = defaultHandlers()) {
pool.reset(handlers);
return app;
}
/** Idem pour l'app configurée avec un jeton d'import. */
function buildToken(handlers = []) {
tokenPool.reset(handlers);
return tokenApp;
}
describe("/api/health et /", () => {
it("health répond 200 sans toucher la base", async () => {
const res = await build([]).inject({ method: "GET", url: "/api/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
expect(pool.calls).toHaveLength(0);
});
it("la racine identifie le service", async () => {
expect((await build([]).inject({ method: "GET", url: "/" })).json().ok).toBe(true);
});
});
describe("CORS", () => {
it("renvoie l'en-tête pour une origine autorisée", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu" },
});
expect(res.headers["access-control-allow-origin"]).toBe("https://metalfrom.eu");
});
it("n'écho PAS une origine non autorisée", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://evil.example" },
});
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});
it("annonce Vary: Origin, y compris quand l'origine est refusée", async () => {
// L'en-tete Allow-Origin DEPEND de l'origine demandee. Sans Vary, un cache
// intermediaire peut servir a une origine la reponse mise en cache pour une
// autre — ce qui revient a autoriser une origine qui ne l'est pas, ou a
// faire refuser une origine legitime.
for (const origin of ["https://metalfrom.eu", "https://evil.example", undefined]) {
const res = await build().inject({
method: "GET", url: "/api/health", headers: origin ? { origin } : {},
});
expect(res.headers["vary"]).toContain("Origin");
}
});
it("ne fait pas de match par préfixe sur l'origine", async () => {
const res = await build().inject({
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu.evil.example" },
});
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});
// Régression : sans `return reply`, Fastify poursuivait le cycle de vie et le
// préflight repartait dans le handler de route (ou un 404).
it("le préflight OPTIONS répond 204 et court-circuite le routage", async () => {
const res = await build().inject({ method: "OPTIONS", url: "/api/bands" });
expect(res.statusCode).toBe(204);
expect(res.body).toBe("");
});
it("OPTIONS répond 204 même sur une route inexistante", async () => {
expect((await build().inject({ method: "OPTIONS", url: "/nimporte/quoi" })).statusCode).toBe(204);
});
});
describe("en-têtes de sécurité (helmet)", () => {
it.each([
"x-content-type-options",
"x-frame-options",
"strict-transport-security",
])("pose %s", async (header) => {
const res = await build().inject({ method: "GET", url: "/api/health" });
expect(res.headers[header]).toBeDefined();
});
it("ne divulgue pas la stack ni les identifiants en cas d'erreur serveur", async () => {
const res = await build([
{ match: "FROM bands", throws: new Error("connexion perdue: host=10.0.1.7 user=bm") },
]).inject({ method: "GET", url: "/api/stats" });
expect(res.statusCode).toBe(500);
expect(res.body).not.toMatch(/10\.0\.1\.7|user=bm|at Object/);
});
});
describe("/api/bands — validation", () => {
it("répond 200 sans paramètre", async () => {
const res = await build().inject({ method: "GET", url: "/api/bands" });
expect(res.statusCode).toBe(200);
expect(res.json().limit).toBe(1000);
});
// Régression : Number("abc") = NaN partait dans LIMIT $n → erreur pg → 500.
it.each([
["limit=abc", "/api/bands?limit=abc"],
["limit=0", "/api/bands?limit=0"],
["limit=-5", "/api/bands?limit=-5"],
["offset=abc", "/api/bands?offset=abc"],
["offset=-1", "/api/bands?offset=-1"],
["offset>1M", "/api/bands?offset=1000001"],
])("répond 400 (pas 500) pour %s", async (_label, url) => {
const res = await build().inject({ method: "GET", url });
expect(res.statusCode).toBe(400);
expect(res.json().ok).toBe(false);
});
it("plafonne le limit sans erreur", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?limit=999999" })).json().limit).toBe(150000);
});
it("rejette une recherche d'un seul caractère", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?q=a" })).statusCode).toBe(400);
});
it("rejette les patterns ILIKE pathologiques", async () => {
expect((await build().inject({ method: "GET", url: "/api/bands?q=%25%25%25" })).statusCode).toBe(400);
});
it("rejette plus de 100 pays", async () => {
const many = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
const res = await build().inject({ method: "GET", url: `/api/bands?countries=${many}` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/Max 100/);
});
it("rejette plus de 50 statuts", async () => {
const many = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
expect((await build().inject({ method: "GET", url: `/api/bands?status=${many}` })).statusCode).toBe(400);
});
it("passe la recherche en paramètre lié", async () => {
await build().inject({
method: "GET", url: "/api/bands?q=" + encodeURIComponent("'; DROP TABLE bands--"),
});
const call = pool.find("FROM bands");
expect(call.sql).not.toContain("DROP TABLE");
expect(call.values.some((v) => String(v).includes("DROP TABLE"))).toBe(true);
});
it("normalise les codes pays en majuscules", async () => {
await build().inject({ method: "GET", url: "/api/bands?countries=fr,de" });
expect(pool.find("FROM bands").values).toContainEqual(["FR", "DE"]);
});
});
describe("/api/clusters — validation géographique", () => {
it.each([
["bbox absente", "/api/clusters?zoom=5"],
["zoom absent", "/api/clusters?bbox=-5,40,10,55"],
["bbox à 3 valeurs", "/api/clusters?bbox=1,2,3&zoom=5"],
["bbox non numérique", "/api/clusters?bbox=a,b,c,d&zoom=5"],
["latitude > 90", "/api/clusters?bbox=-5,40,10,95&zoom=5"],
["longitude < -180", "/api/clusters?bbox=-181,40,10,55&zoom=5"],
["bbox inversée", "/api/clusters?bbox=10,40,-5,55&zoom=5"],
["zoom négatif", "/api/clusters?bbox=-5,40,10,55&zoom=-1"],
["zoom > 22", "/api/clusters?bbox=-5,40,10,55&zoom=23"],
])("répond 400 pour %s", async (_label, url) => {
expect((await build().inject({ method: "GET", url })).statusCode).toBe(400);
});
it("accepte une bbox valide", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
expect(res.statusCode).toBe(200);
});
// Le seuil de zoom 12 fait basculer entre agrégation en grille et points bruts.
it("agrège en clusters en dessous du zoom 12", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" });
expect(res.json().type).toBe("clusters");
expect(pool.find("grid_cells")).toBeDefined();
});
it("renvoie les groupes bruts à partir du zoom 12", async () => {
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=12" });
expect(res.json().type).toBe("bands");
expect(pool.find("grid_cells")).toBeUndefined();
});
it("ne lit que les localisations réellement géocodées", async () => {
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
const sql = pool.find("grid_cells").sql;
expect(sql).toContain("bl.geom IS NOT NULL");
expect(sql).toContain("geocode_status IN ('done','country_only')");
});
it("utilise l'index spatial via ST_MakeEnvelope avec des paramètres liés", async () => {
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
const call = pool.find("grid_cells");
expect(call.sql).toContain("bl.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)");
expect(call.values.slice(0, 4)).toEqual([-5, 40, 10, 55]);
});
});
describe("/api/band/:ma_id", () => {
it("répond 400 pour un identifiant non numérique", async () => {
expect((await build().inject({ method: "GET", url: "/api/band/abc" })).statusCode).toBe(400);
});
it("répond 404 quand le groupe n'existe pas", async () => {
const res = await build([{ match: "FROM bands", result: rows() }])
.inject({ method: "GET", url: "/api/band/42" });
expect(res.statusCode).toBe(404);
});
it("accepte un ma_id au-delà de 2^31 (colonne BIGINT)", async () => {
const res = await build([{ match: "FROM bands", result: rows({ ma_id: 3500000000 }) }])
.inject({ method: "GET", url: "/api/band/3500000000" });
expect(res.statusCode).toBe(200);
expect(pool.find("FROM bands").values).toEqual([3500000000]);
});
});
describe("/admin/import — jeton Bearer", () => {
it.each([
["sans en-tête", undefined],
["jeton vide", "Bearer "],
["jeton faux de même longueur", `Bearer ${"0".repeat(TOKEN.length)}`],
["jeton faux plus court", "Bearer abc"],
["schéma Basic", "Basic " + Buffer.from("a:b").toString("base64")],
])("répond 401 %s", async (_label, authorization) => {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: authorization ? { authorization } : {},
payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
});
it("ne touche pas la base quand le jeton est invalide", async () => {
await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: "Bearer faux" },
payload: { bands: [{ ma_id: 1 }] },
});
expect(tokenPool.calls).toHaveLength(0);
});
it("accepte le bon jeton", async () => {
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` },
payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
});
expect(res.statusCode).toBe(200);
expect(res.json().upserted).toBe(1);
});
// Un jeton multi-octets de même longueur en caractères mais pas en octets
// faisait lever timingSafeEqual → 500 au lieu d'un 401 propre.
it("répond 401 (pas 500) pour un jeton multi-octets", async () => {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${"é".repeat(TOKEN.length)}` },
payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
});
it("refuse un corps sans tableau bands", async () => {
for (const payload of [{}, { bands: "pas-un-tableau" }, { bands: null }]) {
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` }, payload,
});
expect(res.statusCode).toBe(400);
}
});
it("refuse plus de 1000 groupes par lot", async () => {
const bands = Array.from({ length: 1001 }, (_, i) => ({ ma_id: i }));
const res = await buildToken().inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands },
});
expect(res.statusCode).toBe(400);
});
it("ignore les entrées sans ma_id numérique sans planter", async () => {
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
method: "POST", url: "/admin/import",
headers: { authorization: `Bearer ${TOKEN}` },
payload: { bands: [{ ma_id: "abc" }, null, "chaine", { ma_id: 7 }] },
});
expect(res.statusCode).toBe(200);
expect(res.json().upserted).toBe(1);
});
// Cas nécessitant une fabrique différente : app sans jeton configuré.
it("quand aucun jeton n'est configuré, tout est refusé", async () => {
const noTokenApp = await createFullAppWith({ importToken: "", pool: null });
const res = await noTokenApp.inject({
method: "POST", url: "/admin/import",
headers: { authorization: "Bearer nimportequoi" }, payload: { bands: [] },
});
expect(res.statusCode).toBe(401);
await noTokenApp.close();
});
});
describe("/admin/auth/login", () => {
it("refuse un corps mal formé sans requête SQL", async () => {
const a = build([]);
for (const payload of [{}, { username: "a" }, { username: 1, password: 2 }]) {
expect((await a.inject({ method: "POST", url: "/admin/auth/login", payload })).statusCode).toBe(400);
}
expect(pool.calls).toHaveLength(0);
});
it("répond 429 quand le compte est verrouillé, sans vérifier le mot de passe", async () => {
const res = await build([
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 99, par_username: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: "x" }) },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "x" },
});
expect(res.statusCode).toBe(429);
expect(pool.find("FROM admin_users")).toBeUndefined();
});
it("enregistre la tentative échouée et ne pose pas de cookie", async () => {
const bcrypt = (await import("bcryptjs")).default;
// Hash à coût 4 : le DUMMY_HASH de production est à coût 12 (~330 ms).
const hash = bcrypt.hashSync("autre-chose", 4);
const res = await build([
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "mauvais" },
});
expect(res.statusCode).toBe(401);
expect(res.headers["set-cookie"]).toBeUndefined();
expect(pool.find("INSERT INTO admin_login_attempts").values[2]).toBe(false);
});
it("le cookie de session est httpOnly, secure, sameSite=strict", async () => {
const bcrypt = (await import("bcryptjs")).default;
const hash = bcrypt.hashSync("bon", 4);
const res = await build([
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
{ match: "UPDATE admin_users", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "nico", password: "bon" },
});
expect(res.statusCode).toBe(200);
const cookie = String(res.headers["set-cookie"]);
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
expect(cookie).toMatch(/SameSite=Strict/i);
// Le jeton ne doit jamais apparaître dans le corps de la réponse
expect(res.json()).toEqual({ ok: true, username: "nico" });
});
it("tronque un username exagérément long avant la requête", async () => {
const bcrypt = (await import("bcryptjs")).default;
const hash = bcrypt.hashSync("x", 4);
await build([
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
{ match: "INSERT INTO admin_login_attempts", result: rows() },
]).inject({
method: "POST", url: "/admin/auth/login",
payload: { username: "a".repeat(5000), password: "x" },
});
expect(pool.find("FROM admin_users").values[0]).toHaveLength(100);
});
});
describe("gestionnaire d'erreurs global", () => {
// Régression : setErrorHandler écrasait TOUS les statuts en 500, y compris
// les 4xx légitimes (payload trop gros, JSON invalide, rate-limit).
it("préserve un 400 sur JSON invalide", async () => {
const res = await build([]).inject({
method: "POST", url: "/admin/auth/login",
headers: { "content-type": "application/json" },
payload: "{ceci n'est pas du json",
});
expect(res.statusCode).toBe(400);
});
it("renvoie 500 générique pour une erreur inattendue", async () => {
const res = await build([{ match: "FROM bands", throws: new Error("boom") }])
.inject({ method: "GET", url: "/api/stats" });
expect(res.statusCode).toBe(500);
expect(res.json().error).not.toContain("boom");
});
});
describe("sans base de données configurée", () => {
it("les routes de données répondent 500 mais le service reste debout", async () => {
const noDbApp = await createFullAppWith({ pool: null });
expect((await noDbApp.inject({ method: "GET", url: "/api/stats" })).statusCode).toBe(500);
expect((await noDbApp.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
await noDbApp.close();
});
});