Le limiteur était keyé sur le Bearer fourni par l'appelant : keyGenerator: (req) => authBearer(req) || req.ip La clé était donc une valeur non authentifiée, entièrement choisie par l'attaquant. Envoyer un jeton différent à chaque requête ouvrait un seau neuf à chaque fois : sur 30 tentatives avec un jeton variable, 0 étaient bloquées, là où 20 l'étaient avec un jeton fixe. Le plafond ne freinait donc rien — alors qu'il est précisément là pour ralentir la recherche du jeton par force brute. Chaque jeton inédit consommait en prime une entrée du cache LRU du limiteur, évinçant les compteurs légitimes. La clé redevient l'IP. Isoler par identité réelle est impossible à cette couche : le limiteur s'exécute avant l'authentification. Le test « limite par jeton, pas globalement » verrouillait explicitement le comportement vulnérable. Il est remplacé, avec la raison de l'abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.9 KiB
JavaScript
151 lines
5.9 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const { createFullAppWith } = await import("./helpers/testApp.js");
|
|
const { makeFakePool, rows } = await import("./helpers/fakePool.js");
|
|
|
|
/**
|
|
* Les autres fichiers de test partagent une app avec des plafonds de débit
|
|
* neutralisés (sinon l'état du limiteur fuiterait d'un cas à l'autre). C'EST
|
|
* ICI, et seulement ici, que les vraies valeurs de production sont vérifiées —
|
|
* sinon la paramétrisation introduite pour la performance créerait un angle
|
|
* mort sur une protection de sécurité.
|
|
*/
|
|
|
|
const TOKEN = "jeton-de-test";
|
|
|
|
async function appWithRealLimits(overrides = {}) {
|
|
return createFullAppWith({
|
|
pool: makeFakePool([{ match: /./, result: rows({ total: 0 }) }]),
|
|
importToken: TOKEN,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("plafond global", () => {
|
|
it("répond 429 au-delà du plafond", async () => {
|
|
const app = await appWithRealLimits({ globalRateLimitMax: 3 });
|
|
const codes = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
codes.push((await app.inject({ method: "GET", url: "/api/health" })).statusCode);
|
|
}
|
|
expect(codes).toEqual([200, 200, 200, 429, 429]);
|
|
await app.close();
|
|
});
|
|
|
|
it("le 429 traverse le gestionnaire d'erreurs sans devenir un 500", async () => {
|
|
// Régression : setErrorHandler écrasait tous les statuts en 500, ce qui
|
|
// transformait une limitation de débit en fausse panne serveur.
|
|
const app = await appWithRealLimits({ globalRateLimitMax: 1 });
|
|
await app.inject({ method: "GET", url: "/api/health" });
|
|
const res = await app.inject({ method: "GET", url: "/api/health" });
|
|
expect(res.statusCode).toBe(429);
|
|
expect(res.json().ok).toBe(false);
|
|
await app.close();
|
|
});
|
|
|
|
it("la valeur de production par défaut est 1000/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({ method: "GET", url: "/api/health" });
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("1000");
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
describe("plafond de /admin/import", () => {
|
|
it("la valeur de production par défaut est 10/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands: [] },
|
|
});
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("10");
|
|
await app.close();
|
|
});
|
|
|
|
it("limite par IP, y compris pour un appelant authentifié", async () => {
|
|
// Ce test verrouillait auparavant l'inverse — un compteur par jeton, pour
|
|
// qu'un client bruyant n'affame pas les autres. L'intention se défend, mais
|
|
// le limiteur s'exécute AVANT l'authentification : la cle etait donc une
|
|
// valeur non verifiee, fournie par l'appelant. Isoler par identite reelle
|
|
// est impossible a cette couche ; on retombe sur l'IP, seule cle que
|
|
// l'appelant ne choisit pas.
|
|
const app = await appWithRealLimits({ adminRateLimitMax: 2 });
|
|
const call = (token) =>
|
|
app.inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { "x-forwarded-for": "4.4.4.4", authorization: `Bearer ${token}` },
|
|
payload: { bands: [] },
|
|
});
|
|
|
|
await call(TOKEN);
|
|
await call(TOKEN);
|
|
expect((await call(TOKEN)).statusCode).toBe(429);
|
|
await app.close();
|
|
});
|
|
});
|
|
|
|
describe("plafond de /admin/auth/login", () => {
|
|
it("la valeur de production par défaut est 10/minute", async () => {
|
|
const app = await appWithRealLimits();
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/auth/login", payload: { username: "", password: "" },
|
|
});
|
|
expect(res.headers["x-ratelimit-limit"]).toBe("10");
|
|
await app.close();
|
|
});
|
|
|
|
// Le verrouillage de compte (5 échecs / 15 min, en base) et le rate-limit HTTP
|
|
// sont deux protections distinctes : celle-ci s'applique même à des requêtes
|
|
// malformées qui n'atteignent jamais la base.
|
|
it("limite le bruteforce même sur des corps invalides", async () => {
|
|
const app = await appWithRealLimits({ authRateLimitMax: 2 });
|
|
const call = () =>
|
|
app.inject({ method: "POST", url: "/admin/auth/login", payload: {} });
|
|
|
|
expect((await call()).statusCode).toBe(400);
|
|
expect((await call()).statusCode).toBe(400);
|
|
expect((await call()).statusCode).toBe(429);
|
|
await app.close();
|
|
});
|
|
|
|
it("le plafond d'import ne se contourne pas en changeant de jeton", async () => {
|
|
// Le limiteur etait keye sur le Bearer fourni, c'est-a-dire sur une valeur
|
|
// entierement controlee par l'appelant : envoyer un jeton different a
|
|
// chaque requete donnait un seau neuf a chaque fois, et le plafond ne
|
|
// freinait plus rien -- alors qu'il est justement la pour ralentir la
|
|
// recherche du jeton par force brute.
|
|
const app = await appWithRealLimits({ adminRateLimitMax: 2 });
|
|
const call = (token) =>
|
|
app.inject({
|
|
method: "POST",
|
|
url: "/admin/import",
|
|
headers: { "x-forwarded-for": "9.9.9.9", authorization: `Bearer ${token}` },
|
|
payload: { bands: [] },
|
|
});
|
|
|
|
expect((await call("faux-a")).statusCode).toBe(401);
|
|
expect((await call("faux-b")).statusCode).toBe(401);
|
|
// 3e requete depuis la meme IP : bloquee, quel que soit le jeton presente.
|
|
expect((await call("faux-c")).statusCode).toBe(429);
|
|
await app.close();
|
|
});
|
|
|
|
it("limite par IP", async () => {
|
|
const app = await appWithRealLimits({ authRateLimitMax: 1 });
|
|
const call = (ip) =>
|
|
app.inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
headers: { "x-forwarded-for": ip },
|
|
payload: {},
|
|
});
|
|
|
|
expect((await call("1.2.3.4")).statusCode).toBe(400);
|
|
expect((await call("1.2.3.4")).statusCode).toBe(429);
|
|
// Une autre IP garde son propre quota (trustProxy est actif : l'app est
|
|
// derrière Traefik, l'IP réelle vient de X-Forwarded-For).
|
|
expect((await call("5.6.7.8")).statusCode).toBe(400);
|
|
await app.close();
|
|
});
|
|
});
|