mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Suite de l'audit : les cinq points laissés en suspens sont traités. Mot de passe - `changeOwnPassword` exige désormais le mot de passe actuel. Un poste laissé ouvert ne suffit plus à s'approprier un compte. Les erreurs reviennent sur l'écran avec un message au lieu d'une page d'erreur brute. Sessions révocables - Colonne `users.session_version`, portée dans le jeton et comparée à la base. - `getSession()` (src/lib/session.ts) remplace `auth()` sur les 20 pages et actions : rôle, rattachement adhérent et existence du compte sont relus à chaque requête. Supprimer un compte ou réinitialiser un mot de passe coupe immédiatement les sessions ouvertes, sans attendre l'expiration du jeton. Dépendances — de 8 vulnérabilités (2 critiques) à zéro - next 15.5.19 → 15.5.21, next-auth beta.25 → beta.32, drizzle-orm 0.38 → 0.45. - postcss et sharp forcés par `overrides` sur leurs versions corrigées, Next ne les ayant pas encore reprises ; drizzle-kit et esbuild montés côté outillage. - `npm audit fix --force` a été écarté : il proposait de RÉTROGRADER Next en 9.3.3 et eslint-config-next en 12, ce qui aurait cassé l'application. - `eslint-config-next` traînait dans node_modules sans être déclaré : retiré. CSP complète - Politique à nonce posée par le middleware, nonce régénéré à chaque requête, `script-src` sans 'unsafe-inline'. `style-src` garde 'unsafe-inline' : tout le design repose sur des attributs style, et une injection de style n'a pas la portée d'une injection de script. - Conséquence assumée : rendu dynamique pour toutes les pages, un HTML pré-généré ne pouvant pas porter de nonce. Tests - `npm test` (runner natif node:test via tsx), 18 tests sur le filtre XSS, la limitation des tentatives de connexion et le chiffrement des jetons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
112 lines
4.3 KiB
TypeScript
112 lines
4.3 KiB
TypeScript
import { strict as assert } from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
|
|
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
|
import { clearLoginFailures, isLoginBlocked, recordLoginFailure } from "../src/lib/login-throttle";
|
|
import { decryptSecret, encryptSecret, tryDecryptSecret } from "../src/lib/crypto";
|
|
|
|
process.env.SOCIAL_TOKEN_KEY ??= "cle-de-test-suffisamment-longue-pour-scrypt";
|
|
|
|
/** Rend les nœuds React en texte brut, pour vérifier ce qui atteint le DOM. */
|
|
function renderToText(message: string): string {
|
|
const nodes = activityNodes(message);
|
|
const flat = Array.isArray(nodes) ? nodes : [nodes];
|
|
return JSON.stringify(flat);
|
|
}
|
|
|
|
describe("Journal d'activité — XSS stocké", () => {
|
|
// Ces messages agrègent des saisies de tiers, dont le formulaire de contact
|
|
// public : ils étaient rendus en HTML brut sur le tableau de bord admin.
|
|
const payloads = [
|
|
`<img src=x onerror="alert(1)">`,
|
|
`<script>alert(1)</script>`,
|
|
`<strong onclick="alert(1)">x</strong>`,
|
|
`<svg/onload=alert(1)>`,
|
|
`<iframe src="javascript:alert(1)">`,
|
|
`<ScRiPt>alert(1)</ScRiPt>`,
|
|
`<img src=x onerror=alert(1)`,
|
|
`<a href="javascript:alert(1)">clic</a>`,
|
|
];
|
|
|
|
for (const payload of payloads) {
|
|
it(`neutralise ${payload.slice(0, 32)}`, () => {
|
|
const stored = sanitizeActivityMessage(`<strong>Pirate</strong> « ${payload} »`);
|
|
assert.ok(!/<script|<iframe|<svg|<img|<a\b|onerror|onload|onclick/i.test(stored),
|
|
`balise survivante dans : ${stored}`);
|
|
|
|
// Même si un message malveillant existait déjà en base, le rendu React
|
|
// l'échappe : rien d'exécutable ne peut atteindre le DOM.
|
|
const rendered = renderToText(payload);
|
|
assert.ok(!rendered.includes('"dangerouslySetInnerHTML"'));
|
|
});
|
|
}
|
|
|
|
it("conserve la mise en gras légitime", () => {
|
|
const stored = sanitizeActivityMessage("<strong>Au Bon Pain</strong> a soumis une promotion");
|
|
assert.equal(stored, "<strong>Au Bon Pain</strong> a soumis une promotion");
|
|
assert.ok(renderToText(stored).includes("Au Bon Pain"));
|
|
});
|
|
|
|
it("garde le texte lisible quand une balise est retirée", () => {
|
|
const stored = sanitizeActivityMessage("Promotion « <b>Soldes</b> » validée");
|
|
assert.ok(stored.includes("Soldes"));
|
|
assert.ok(stored.includes("validée"));
|
|
});
|
|
});
|
|
|
|
describe("Limitation des tentatives de connexion", () => {
|
|
it("laisse passer les premières tentatives puis bloque", () => {
|
|
const key = `essai-${Date.now()}@test.fr`;
|
|
assert.equal(isLoginBlocked(key), false);
|
|
for (let i = 0; i < 7; i++) recordLoginFailure(key);
|
|
assert.equal(isLoginBlocked(key), false, "blocage prématuré");
|
|
recordLoginFailure(key);
|
|
assert.equal(isLoginBlocked(key), true, "8e échec non bloqué");
|
|
});
|
|
|
|
it("remet le compteur à zéro après une connexion réussie", () => {
|
|
const key = `succes-${Date.now()}@test.fr`;
|
|
for (let i = 0; i < 10; i++) recordLoginFailure(key);
|
|
assert.equal(isLoginBlocked(key), true);
|
|
clearLoginFailures(key);
|
|
assert.equal(isLoginBlocked(key), false);
|
|
});
|
|
|
|
it("isole les comptes entre eux", () => {
|
|
const cible = `cible-${Date.now()}@test.fr`;
|
|
const voisin = `voisin-${Date.now()}@test.fr`;
|
|
for (let i = 0; i < 10; i++) recordLoginFailure(cible);
|
|
assert.equal(isLoginBlocked(cible), true);
|
|
assert.equal(isLoginBlocked(voisin), false);
|
|
});
|
|
});
|
|
|
|
describe("Chiffrement des secrets réseaux", () => {
|
|
const secret = "EAAB-jeton-de-page-très-long-avec-accents-éàç";
|
|
|
|
it("fait un aller-retour fidèle", () => {
|
|
assert.equal(decryptSecret(encryptSecret(secret)), secret);
|
|
});
|
|
|
|
it("ne laisse pas le clair dans le chiffré", () => {
|
|
assert.ok(!encryptSecret(secret).includes(secret));
|
|
});
|
|
|
|
it("produit un résultat différent à chaque appel (IV aléatoire)", () => {
|
|
assert.notEqual(encryptSecret(secret), encryptSecret(secret));
|
|
});
|
|
|
|
it("rejette un contenu altéré (authentification GCM)", () => {
|
|
const parts = encryptSecret(secret).split(":");
|
|
const payload = Buffer.from(parts[3], "base64");
|
|
payload[0] ^= 0xff;
|
|
parts[3] = payload.toString("base64");
|
|
assert.throws(() => decryptSecret(parts.join(":")));
|
|
});
|
|
|
|
it("rejette un format inattendu", () => {
|
|
assert.throws(() => decryptSecret("pas-un-secret-chiffré"));
|
|
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
|
});
|
|
});
|