mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui rédigeait devait connaître une syntaxe pour obtenir du gras. On édite désormais comme dans un traitement de texte — on sélectionne, on clique sur G, le texte devient gras à l'écran. Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras, l'italique, les listes, les sous-titres et les liens ; le balisage part dans un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre dans le produit — le risque n'a jamais été la surface d'édition, mais la base. - rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode). Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout document mettrait le texte entier en gras. - Un collage passe par DOMParser, dont le document est inerte : un <img onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté. - L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x*** serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien. - saveInformation normalise les CRLF que l'encodage des formulaires introduit. Vérifié au navigateur contre une base réelle : aller-retour identique après enregistrement, et un collage hostile (script, img onerror, iframe, style, lien javascript:) ressort en texte structuré sans qu'une seule alerte parte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
367 lines
16 KiB
TypeScript
367 lines
16 KiB
TypeScript
import { strict as assert } from "node:assert";
|
|
import { readFileSync } from "node:fs";
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("Coordonnées du référent — jamais dans les lectures publiques", () => {
|
|
const source = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
|
|
|
/** Corps d'une fonction exportée de `queries.ts`, jusqu'à la suivante. */
|
|
function bodyOf(name: string): string {
|
|
const start = source.indexOf(`export async function ${name}(`);
|
|
assert.notEqual(start, -1, `${name} introuvable dans src/lib/queries.ts`);
|
|
const next = source.indexOf("\nexport ", start + 1);
|
|
return source.slice(start, next === -1 ? source.length : next);
|
|
}
|
|
|
|
// Nom, prénom et ligne directe de l'adhérent ne sont montrés qu'à un visiteur
|
|
// connecté. La garantie tient à une seule chose : aucune requête servant une
|
|
// page publique ne les rapatrie — sinon ils partiraient dans le HTML, le
|
|
// JSON-LD ou les props du composant client de l'annuaire.
|
|
const publicReads = [
|
|
"getActiveMembersWithCategory",
|
|
"getActiveMembersByCategory",
|
|
"getRotatingActiveMembers",
|
|
"getPublicMember",
|
|
"getLivePromotions",
|
|
];
|
|
|
|
for (const name of publicReads) {
|
|
it(`${name} ne lit pas le référent`, () => {
|
|
const body = bodyOf(name);
|
|
for (const column of ["contactFirstName", "contactLastName", "contactPhone"]) {
|
|
assert.ok(
|
|
!body.includes(column),
|
|
`${name} sélectionne ${column} : ces coordonnées deviendraient publiques.`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
it("les lectures réservées existent et sont séparées", () => {
|
|
for (const name of ["getMemberContacts", "getMemberContact"]) {
|
|
const body = bodyOf(name);
|
|
assert.ok(body.includes("contactPhone"), `${name} devrait lire le référent`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Mots de passe temporaires — jamais mis en file d'attente", () => {
|
|
const source = readFileSync(new URL("../src/app/backend/actions.ts", import.meta.url), "utf8");
|
|
|
|
/** Corps d'une fonction exportée, jusqu'à la suivante. */
|
|
function actionBody(name: string): string {
|
|
const start = source.indexOf(`export async function ${name}(`);
|
|
assert.notEqual(start, -1, `${name} introuvable dans src/app/backend/actions.ts`);
|
|
const next = source.indexOf("\nexport ", start + 1);
|
|
return source.slice(start, next === -1 ? source.length : next);
|
|
}
|
|
|
|
// `mail_messages.html` est stocké en base. Mettre un message d'identifiants
|
|
// dans la file y écrirait le mot de passe en clair, alors que tout le reste
|
|
// du produit s'emploie à ne jamais le conserver : il part donc en ligne
|
|
// directe, et seule une trace sans contenu est journalisée.
|
|
const issuers = [
|
|
"addMember",
|
|
"createMissingMemberAccounts",
|
|
"resetMemberPassword",
|
|
"inviteAdmin",
|
|
"approveMembershipRequest",
|
|
];
|
|
|
|
for (const name of issuers) {
|
|
it(`${name} envoie directement, sans passer par la file`, () => {
|
|
const body = actionBody(name);
|
|
assert.ok(body.includes("generateTempPassword()"), `${name} devrait émettre un mot de passe`);
|
|
assert.ok(body.includes("deliverCredentials("), `${name} devrait transmettre les identifiants`);
|
|
assert.ok(
|
|
!body.includes("queueMail"),
|
|
`${name} met un mot de passe temporaire dans la file : il finirait stocké en base.`
|
|
);
|
|
});
|
|
}
|
|
|
|
it("la trace journalisée ne porte aucun contenu", () => {
|
|
const outbox = readFileSync(new URL("../src/lib/mail-outbox.ts", import.meta.url), "utf8");
|
|
const start = outbox.indexOf("export async function logSentMail(");
|
|
assert.notEqual(start, -1, "logSentMail introuvable");
|
|
const body = outbox.slice(start, outbox.indexOf("\n/**", start + 1));
|
|
assert.ok(body.includes('html: ""'), "logSentMail devrait écrire un corps vide");
|
|
});
|
|
});
|
|
|
|
describe("Messagerie — aucun secret vers le navigateur", () => {
|
|
const page = readFileSync(new URL("../src/app/backend/boite-mail/page.tsx", import.meta.url), "utf8");
|
|
|
|
// Même règle que l'écran Réseaux sociaux : la page ne connaît que la
|
|
// *présence* d'un secret, jamais sa valeur.
|
|
for (const column of ["appSecret", "smtpPassword", "accessToken", "refreshToken"]) {
|
|
it(`l'écran ne lit jamais ${column} en clair`, () => {
|
|
// La présence du champ sert au libellé du champ de saisie ; ce qui est
|
|
// interdit, c'est de le déchiffrer.
|
|
assert.ok(
|
|
!page.includes(`decryptSecret(`) && !page.includes(`tryDecryptSecret(`),
|
|
`boite-mail/page.tsx déchiffre un secret : il partirait dans le HTML.`
|
|
);
|
|
});
|
|
}
|
|
|
|
it("le champ de saisie est masqué et un champ vide conserve la valeur", () => {
|
|
assert.ok(page.includes('type="password"'), "les secrets doivent être saisis en champ masqué");
|
|
assert.ok(
|
|
page.includes("laissez vide pour le conserver"),
|
|
"l'écran doit annoncer qu'un champ vide conserve le secret enregistré"
|
|
);
|
|
});
|
|
|
|
it("les secrets sont chiffrés à l'écriture", () => {
|
|
const accounts = readFileSync(new URL("../src/lib/mail-accounts.ts", import.meta.url), "utf8");
|
|
|
|
/** Corps d'une fonction exportée, jusqu'à la suivante. */
|
|
function writerBody(name: string): string {
|
|
const start = accounts.indexOf(`export async function ${name}(`);
|
|
assert.notEqual(start, -1, `${name} introuvable dans src/lib/mail-accounts.ts`);
|
|
const next = accounts.indexOf("\nexport ", start + 1);
|
|
return accounts.slice(start, next === -1 ? accounts.length : next);
|
|
}
|
|
|
|
// Seuls ces trois écrivent en base ; `exchangeMailCode` rend les jetons en
|
|
// mémoire à son appelant, qui les chiffre.
|
|
for (const name of ["saveOAuthApp", "saveSmtpAccount", "saveMailConnection"]) {
|
|
const body = writerBody(name);
|
|
const assignments = body.match(/\b(appSecret|smtpPassword|accessToken|refreshToken):\s*[^,\n]+/g) ?? [];
|
|
// On écarte les annotations de type de la signature et les valeurs
|
|
// littérales : seules les affectations réelles nous intéressent.
|
|
const writes = assignments.filter(
|
|
(assignment) => !/:\s*(null|""|string|number|boolean|Date)\b/.test(assignment)
|
|
);
|
|
assert.ok(writes.length > 0, `${name} n'écrit aucun secret : le test ne vérifie plus rien`);
|
|
for (const assignment of writes) {
|
|
assert.ok(
|
|
assignment.includes("encryptSecret("),
|
|
`${name} écrit un secret sans le chiffrer : ${assignment}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Diffusion groupée — aucune adresse partagée", () => {
|
|
it("ni copie ni copie cachée nulle part dans la chaîne d'envoi", () => {
|
|
for (const file of ["../src/lib/mailer.ts", "../src/lib/mail-outbox.ts"]) {
|
|
const source = readFileSync(new URL(file, import.meta.url), "utf8");
|
|
assert.ok(!/\bcc\s*:/i.test(source), `${file} pose une copie`);
|
|
assert.ok(!/\bbcc\s*:/i.test(source), `${file} pose une copie cachée`);
|
|
}
|
|
});
|
|
|
|
it("la file ne porte qu'un destinataire par ligne", () => {
|
|
const schema = readFileSync(new URL("../src/db/schema.ts", import.meta.url), "utf8");
|
|
assert.ok(
|
|
/toAddress: varchar\("to_address", \{ length: \d+ \}\)\.notNull\(\)/.test(schema),
|
|
"to_address devrait être une adresse unique et obligatoire"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Informations — rien n'est rendu en HTML brut", () => {
|
|
const files = [
|
|
"../src/lib/rich-text.ts",
|
|
"../src/components/InformationCard.tsx",
|
|
"../src/app/backend/informations/InformationForm.tsx",
|
|
"../src/components/RichTextEditor.tsx",
|
|
"../src/app/backend/espace/informations/page.tsx",
|
|
];
|
|
|
|
for (const file of files) {
|
|
it(`${file.split("/").pop()} n'emprunte pas dangerouslySetInnerHTML`, () => {
|
|
const source = readFileSync(new URL(file, import.meta.url), "utf8");
|
|
// L'usage réel, pas la mention : `rich-text.ts` explique en commentaire
|
|
// pourquoi il ne s'en sert pas.
|
|
assert.ok(
|
|
!/dangerouslySetInnerHTML\s*[={]/.test(source),
|
|
"le texte des informations vient de tiers : il doit rester des nœuds React"
|
|
);
|
|
});
|
|
}
|
|
|
|
it("les lectures publiques ignorent les informations", () => {
|
|
// Le fil est réservé aux adhérents connectés : aucune requête servant une
|
|
// page publique ne doit y toucher.
|
|
const queries = readFileSync(new URL("../src/lib/queries.ts", import.meta.url), "utf8");
|
|
assert.ok(!queries.includes("informations"), "src/lib/queries.ts lit les informations");
|
|
});
|
|
});
|
|
|
|
describe("Boucles de fond — jamais dans le bundle edge", () => {
|
|
it("le module Node reste chargé à la demande et écarté du runtime edge", () => {
|
|
const entry = readFileSync(new URL("../src/instrumentation.ts", import.meta.url), "utf8");
|
|
assert.ok(entry.includes('NEXT_RUNTIME !== "nodejs"'), "la garde de runtime a disparu");
|
|
assert.ok(!entry.includes("mail-outbox"), "instrumentation.ts importe la file directement");
|
|
assert.ok(!entry.includes("nodemailer"), "instrumentation.ts importe nodemailer");
|
|
|
|
const config = readFileSync(new URL("../next.config.mjs", import.meta.url), "utf8");
|
|
assert.ok(config.includes("IgnorePlugin"), "l'exclusion du bundle edge a disparu");
|
|
assert.ok(config.includes('serverExternalPackages: ["nodemailer"]'), "nodemailer doit rester externe");
|
|
});
|
|
|
|
it("chaque boucle a son propre interrupteur", () => {
|
|
// Couper le libérateur de promotions ne doit pas couper l'envoi des
|
|
// e-mails : les deux n'ont rien à voir.
|
|
const worker = readFileSync(new URL("../src/instrumentation-node.ts", import.meta.url), "utf8");
|
|
assert.ok(worker.includes('process.env.PROMO_SCHEDULER !== "off"'));
|
|
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
|
|
});
|
|
});
|
|
|
|
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
|
|
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
|
|
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
|
|
|
|
// L'édition est visuelle, mais ce qui part au serveur reste le format
|
|
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
|
|
// confortable sans stocker de HTML tiers.
|
|
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
|
|
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
|
|
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
|
|
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
|
|
});
|
|
|
|
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
|
|
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
|
|
// une chaîne venue du presse-papier ou du serveur.
|
|
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
|
|
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
|
|
}
|
|
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
|
|
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
|
|
}
|
|
});
|
|
|
|
it("le collage est analysé dans un document inerte", () => {
|
|
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
|
|
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
|
|
// `<img onerror>` peut s'y déclencher.
|
|
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
|
|
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
|
|
const pasteIndex = editor.indexOf('getData("text/html")');
|
|
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
|
|
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
|
|
});
|
|
|
|
it("le sérialiseur écarte les balises exécutables", () => {
|
|
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
|
|
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
|
|
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
|
|
}
|
|
});
|
|
});
|