diff --git a/src/lib/__tests__/beschreibung.test.ts b/src/lib/__tests__/beschreibung.test.ts index 0c4af3aa..5679286b 100644 --- a/src/lib/__tests__/beschreibung.test.ts +++ b/src/lib/__tests__/beschreibung.test.ts @@ -12,19 +12,32 @@ import { * Text meinen — deshalb prüft der letzte Block sie gegeneinander. */ -/** Grobes Abräumen der Tags, nur für den Vergleich der beiden Enden. */ +const TEST_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + +/** + * Grobes Abräumen der Tags, nur für den Vergleich der beiden Enden. Tags bis + * zur Ruhe, Entitäten in einem Durchgang — dieselben Regeln wie im Helfer, + * sonst meldet CodeQL auch hier unvollständiges Abräumen. + */ function htmlZuText(html: string): string { - return html + let text = html .replace(/>\n+<") .replace(//g, "\n") .replace(/<\/li>/g, "\n") - .replace(/<\/(p|h[1-6]|ul|ol|blockquote)>/g, "\n\n") - .replace(/<[^>]+>/g, "") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") + .replace(/<\/(p|h[1-6]|ul|ol|blockquote)>/g, "\n\n"); + let vorher: string; + do { + vorher = text; + text = text.replace(/<[^<>]+>/g, ""); + } while (text !== vorher); + return text + .replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => TEST_ENTITIES[e] ?? e) .replace(/\n{3,}/g, "\n\n") .trim(); } @@ -173,3 +186,34 @@ describe("Klartext und Darstellung sagen dasselbe", () => { ); }); }); + +/** + * CodeQL meldete, dass ein einzelner Durchgang Tags neu zusammensetzen kann + * und Entitäten nach dem Abräumen wieder Tags ergeben. Klartext wird nirgends + * ausgeführt, soll aber von sich aus frei von Tags sein. + */ +describe("markdownToPlainText — keine Tags im Ergebnis", () => { + const ohneSkript = (eingabe: string) => + expect(markdownToPlainText(eingabe).toLowerCase()).not.toContain(" { + ohneSkript("<script>alert(1)</script>"); + ohneSkript("xipt>alert(1)"); + ohneSkript("</script>"); + }); + + it("lässt aus Entitäten keine Tags entstehen", () => { + ohneSkript("<script>alert(1)</script>"); + expect(markdownToPlainText("<b>fett</b>")).toBe("fett"); + }); + + it("entschlüsselt Entitäten nur einmal", () => { + expect(markdownToPlainText("&lt;")).toBe("<"); + expect(markdownToPlainText("Bläser & Chor")).toBe("Bläser & Chor"); + }); + + it("lässt spitze Klammern im Text stehen", () => { + expect(markdownToPlainText("Kinder <10 Jahre")).toBe("Kinder <10 Jahre"); + expect(markdownToPlainText("a < b und c > d")).toBe("a < b und c > d"); + }); +}); diff --git a/src/lib/markdown-to-plain-text.ts b/src/lib/markdown-to-plain-text.ts index a34b69a0..1ffe20a3 100644 --- a/src/lib/markdown-to-plain-text.ts +++ b/src/lib/markdown-to-plain-text.ts @@ -22,6 +22,33 @@ const ENTITIES: Record = { */ const MASKIERT_AB = 0xe000; +/** + * Wendet eine Ersetzung an, bis sich nichts mehr ändert. Ein einzelner + * Durchgang reicht beim Entfernen von Tags nicht: Aus `<script>` macht er + * `` das „alert(1)" im // Kalendereintrag stehen — gemessen. Gefährlich ist das nicht (Klartext // wird nirgends ausgeführt), nur falsch. - text = text.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ""); + text = bisStabil(text, SKRIPT_ODER_STIL); - // Sonst nur echte Tags entfernen, nicht jedes spitze Klammerpaar: „Kinder - // <10 Jahre" ist Text und wird in der Anzeige auch als Text dargestellt. - // Die frühere Fassung löschte pauschal `<` und `>` und machte aus - // `Wort` das Wort „uWort/u". - text = text.replace(/<\/?[a-zA-Z][^<>]*>/g, ""); + // Sonst nur echte Tags entfernen (siehe `TAG`). Die frühere Fassung + // löschte pauschal `<` und `>` und machte aus `Wort` „uWort/u". + text = bisStabil(text, TAG); // Das Ziel darf ein Klammerpaar enthalten (`javascript:alert(1)`, aber auch // Wikipedia-Adressen mit Klammern) — eine Ebene reicht dafür. Vorher blieb @@ -103,9 +128,14 @@ export function markdownToPlainText(markdown: string): string { String.fromCharCode(zeichen.charCodeAt(0) - MASKIERT_AB), ); - for (const [entity, char] of Object.entries(ENTITIES)) { - text = text.split(entity).join(char); - } + // In einem einzigen Durchgang: Nacheinander aufgelöst würde aus „&lt;" + // erst „<" und dann „<" — doppelt entschlüsselt. + text = text.replace(ENTITY, (entity) => ENTITIES[entity] ?? entity); + + // Aufgelöste Entitäten können Tags ergeben („<script>" → „