Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions src/lib/__tests__/beschreibung.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
};

/**
* 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+</g, "><")
.replace(/<br\s*\/?>/g, "\n")
.replace(/<\/li>/g, "\n")
.replace(/<\/(p|h[1-6]|ul|ol|blockquote)>/g, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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();
}
Expand Down Expand Up @@ -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("<script");

it("setzt beim Abräumen kein neues Tag zusammen", () => {
ohneSkript("<<b>script>alert(1)<</b>/script>");
ohneSkript("<scr<script>x</script>ipt>alert(1)</script>");
ohneSkript("<<script>script>alert(1)<</script>/script>");
});

it("lässt aus Entitäten keine Tags entstehen", () => {
ohneSkript("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(markdownToPlainText("&lt;b&gt;fett&lt;/b&gt;")).toBe("fett");
});

it("entschlüsselt Entitäten nur einmal", () => {
expect(markdownToPlainText("&amp;lt;")).toBe("&lt;");
expect(markdownToPlainText("Bläser &amp; 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");
});
});
48 changes: 39 additions & 9 deletions src/lib/markdown-to-plain-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ const ENTITIES: Record<string, string> = {
*/
const MASKIERT_AB = 0xe000;

/**
* Wendet eine Ersetzung an, bis sich nichts mehr ändert. Ein einzelner
* Durchgang reicht beim Entfernen von Tags nicht: Aus `<<b>script>` macht er
* `<script>` — das Entfernen selbst setzt ein neues Tag zusammen (CodeQL:
* „Incomplete multi-character sanitization").
*/
function bisStabil(text: string, muster: RegExp, ersatz = ""): string {
let vorher: string;
do {
vorher = text;
text = text.replace(muster, ersatz);
} while (text !== vorher);
return text;
}

/** Skript- und Stilblöcke samt Inhalt. */
const SKRIPT_ODER_STIL = /<(script|style)\b[\s\S]*?<\/\1\s*>/gi;

/**
* Nur echte Tags, nicht jedes spitze Klammerpaar: „Kinder <10 Jahre" und
* „a < b" sind Text und werden in der Anzeige auch als Text dargestellt.
*/
const TAG = /<\/?[a-zA-Z][^<>]*>/g;

/** Alle bekannten Entitäten in einem Muster — für eine einzige Auflösung. */
const ENTITY = /&(?:amp|lt|gt|quot|#39|apos|nbsp);/g;

/**
* Wandelt Markdown in Klartext um.
*
Expand Down Expand Up @@ -63,13 +90,11 @@ export function markdownToPlainText(markdown: string): string {
// diese Zeile bliebe von `<script>alert(1)</script>` 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
// `<u>Wort</u>` 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 `<u>Wort</u>` „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
Expand Down Expand Up @@ -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 „&amp;lt;"
// erst „&lt;" und dann „<" — doppelt entschlüsselt.
text = text.replace(ENTITY, (entity) => ENTITIES[entity] ?? entity);

// Aufgelöste Entitäten können Tags ergeben („&lt;script&gt;" → „<script>").
// Klartext wird zwar nirgends ausgeführt, aber er soll von sich aus sicher
// sein, egal wo ihn jemand später einsetzt — also noch einmal abräumen.
text = bisStabil(bisStabil(text, SKRIPT_ODER_STIL), TAG);

text = text.replace(/[ \t]+$/gm, "");
text = text.replace(/[ \t]{2,}/g, " ");
Expand Down