From 759be6925098f82a06ff10516b53a32ba676552f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:12:49 +0000 Subject: [PATCH] Terminplaner: Einstellungen, URL-Host einschraenken und unsichtbare Formatzeichen entfernen Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einstellungen.js | 42 +++++++++++---- test/termineEinstellungen.test.js | 66 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index a919307..f3b9ad5 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -45,21 +45,33 @@ export function neuerFeedToken() { // ---------------------------------------------------------------- Bausteine zum Pruefen -// ASCII- und C1-Steuerzeichen, Zeilen-/Absatztrenner und Richtungsumschalter (Anzeige-Spoofing in Mails und Widget) -const STEUERZEICHEN = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g +// Steuerzeichen (\p{Cc}: ASCII und C1), unsichtbare Formatzeichen (\p{Cf}: Zero-Width Space, Richtungsmarken und +// -umschalter, Word Joiner, BOM, Arabic Letter Mark, Soft Hyphen u. a.) sowie Zeilen-/Absatztrenner. Sie erzeugen +// leere oder taeuschende Namen in Absendernamen, Betreffzeilen und auf oeffentlichen Seiten. +const STEUERZEICHEN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu +// ZWNJ und ZWJ gehoeren zu Emoji-Sequenzen und persischer Schrift und bleiben in Freitexten erhalten. +const VERBINDER = new Set(['\u200C', '\u200D']) + +/** Entfernt Steuerzeichen und unsichtbare Formatzeichen; behalteVerbinder: ZWNJ/ZWJ bleiben, umbruch: '\n' bleibt. */ +function ohneSteuerzeichen(s, { behalteVerbinder = false, umbruch = false } = {}) { + return s.replace(STEUERZEICHEN, (z) => ((behalteVerbinder && VERBINDER.has(z)) || (umbruch && z === '\n') ? z : '')) +} + +// Leerraum und ZWNJ/ZWJ am Rand entfernen: ein Text nur aus Verbindern bleibt sonst unsichtbar und besteht die Pflichtpruefung. +const randTrim = (s) => s.replace(/^[\s\u200C\u200D]+|[\s\u200C\u200D]+$/g, '') /** Bereinigter Text: ohne Steuerzeichen, getrimmt, auf max Zeichen (Codepoints) gekuerzt. Einzeilig: Umbrueche werden Leerzeichen. */ function text(wert, max, { mehrzeilig = false } = {}) { if (typeof wert !== 'string') return '' let s = wert if (mehrzeilig) { - s = s.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(STEUERZEICHEN, (z) => (z === '\n' ? z : '')).replace(/\n{3,}/g, '\n\n') + s = ohneSteuerzeichen(s.replace(/\r\n?/g, '\n').replace(/\t/g, ' '), { behalteVerbinder: true, umbruch: true }).replace(/\n{3,}/g, '\n\n') } else { - s = s.replace(/[\r\n\t]+/g, ' ').replace(STEUERZEICHEN, '').replace(/ {2,}/g, ' ') + s = ohneSteuerzeichen(s.replace(/[\r\n\t]+/g, ' '), { behalteVerbinder: true }).replace(/ {2,}/g, ' ') } - s = s.trim() + s = randTrim(s) const zeichen = [...s] - return zeichen.length > max ? zeichen.slice(0, max).join('').trim() : s + return zeichen.length > max ? randTrim(zeichen.slice(0, max).join('')) : s } /** Wert in {min..max} (ganzzahlig, Vielfaches von schritt) oder Vorgabe; fehlender Wert ohne Vorgabe ist ein Fehler. */ @@ -96,7 +108,7 @@ function wahrheit(fehl, pfad, wert, vorgabe) { /** Ein Postfach: kein Leerraum, genau ein @, keine ,;<>" und >= 1 Punkt in der Domain, hoechstens 254 Zeichen. */ function emailAdresse(wert) { if (typeof wert !== 'string') return null - const s = wert.replace(STEUERZEICHEN, '').trim().toLowerCase() + const s = ohneSteuerzeichen(wert).trim().toLowerCase() if (s.length === 0 || s.length > 254) return null if (!/^[^\s@,;<>"]+@[^\s@,;<>".]+(\.[^\s@,;<>".]+)+$/.test(s)) return null return s @@ -104,24 +116,32 @@ function emailAdresse(wert) { const URL_MELDUNG = 'Bitte gib eine vollständige Adresse mit https:// an (höchstens 300 Zeichen, ohne Zugangsdaten).' -/** https-URL (<= 300, ohne Zugangsdaten) in kanonischer Schreibweise, leer = null. */ +// Hostname wie ihn URL liefert (klein, Umlaute als Punycode): Labels aus a-z, 0-9 und Bindestrich, oder IPv6 in Klammern. +const HOST_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])$/ + +/** + * https-URL (<= 300, ohne Zugangsdaten, nur ASCII-Hostname) in kanonischer Schreibweise, leer = null. Diese Adressen + * landen als href auf oeffentlichen Seiten und in Mails: Anfuehrungszeichen, Apostroph und Backtick im Host werden + * abgelehnt, ein Apostroph im Pfad wird als %27 gespeichert (URL kodiert ihn dort nicht selbst). + */ function urlFeld(fehl, pfad, wert) { if (wert === undefined || wert === null) return null if (typeof wert !== 'string') { fehl(pfad, URL_MELDUNG) return null } - const s = wert.replace(STEUERZEICHEN, '').trim() + const s = ohneSteuerzeichen(wert).trim() if (s === '') return null let url = null if (s.length <= 300 && !/\s/.test(s)) { try { url = new URL(s) } catch { url = null } } - if (!url || url.protocol !== 'https:' || url.username || url.password || !url.hostname || url.href.length > 300) { + const href = url ? url.href.replace(/'/g, '%27') : '' + if (!url || url.protocol !== 'https:' || url.username || url.password || !HOST_RE.test(url.hostname) || href.length > 300) { fehl(pfad, URL_MELDUNG) return null } - return url.href + return href } /** ID eines Eintrags: fehlt sie, entsteht eine neue; sonst Format + Eindeutigkeit je Liste. */ diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index 89f42e0..2afc2a8 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -231,6 +231,72 @@ test('URLs: nur https, kein javascript:, keine Zugangsdaten, hoechstens 300 Zeic } }) +test('URLs: nur ASCII-Hostnamen (Punycode), Anfuehrungszeichen/Apostroph/Backtick im Host abgelehnt', () => { + const mit = (feld, wert) => ({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', [feld]: wert } }) + for (const feld of ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']) { + for (const schlecht of [ + 'https://exa"mple.com/', "https://a'b.de/", 'https://a`b.de/', "https://a'b.de/x'y", 'https://ab.de/', + 'https://a|b.de/', 'https://a^b.de/', 'https://-a.de/', 'https://a..de/', 'https://a.de./', 'https://[zz]/x', + ]) { + assert.ok(fehlerVon(mit(feld, schlecht))[`betrieb.${feld}`], `${feld}: ${schlecht}`) + } + } +}) + +test('URLs: Apostroph im Pfad wird als %27 gespeichert, Umlaut-Hosts als Punycode, IPv6 in Klammern', () => { + const url = (wert) => gueltig({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: wert } }).betrieb.impressumUrl + assert.equal(url("https://ab.de/x'y"), 'https://ab.de/x%27y') + assert.equal(url("https://ab.de/?q='a'#f'g"), 'https://ab.de/?q=%27a%27#f%27g') + assert.equal(url('https://Salon.DE/Impressum'), 'https://salon.de/Impressum') + assert.equal(url('https://Bücher.de/Über'), 'https://xn--bcher-kva.de/%C3%9Cber') + assert.equal(url('https://sub.salon-müller.de/impressum'), 'https://sub.xn--salon-mller-zhb.de/impressum') + assert.equal(url('https://[::1]/x'), 'https://[::1]/x') + for (const gespeichert of [url("https://ab.de/x'y\"z`w"), url('https://a.de/?q="x"')]) { + assert.ok(!/['"`<>]/.test(gespeichert), gespeichert) + } + // die 300 Zeichen gelten fuer die gespeicherte Schreibweise (%27 ist laenger als ') + const apostrophe = "'".repeat(110) + assert.ok(fehlerVon({ ...basis(), betrieb: { name: 'X', email: 'a@b.de', impressumUrl: `https://ab.de/${apostrophe}` } })['betrieb.impressumUrl']) + assert.equal(url(`https://ab.de/${"'".repeat(80)}`).length, 'https://ab.de/'.length + 80 * 3) +}) + +test('Unsichtbare Formatzeichen: Name nur daraus ist leer (Pflichtfeld), sonst werden sie entfernt', () => { + const name = (wert) => ({ ...basis(), betrieb: { name: wert, email: 'a@b.de' } }) + for (const leer of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u200B\u200E \uFEFF', ' \u200B\u200D\u200C ', '\u200D', '\u200C\u200D']) { + assert.ok(fehlerVon(name(leer))['betrieb.name'], JSON.stringify(leer)) + } + assert.equal(gueltig(name('A\u200EB\u061CC\uFEFFD')).betrieb.name, 'ABCD') + for (const zeichen of ['\u200B', '\u200E', '\u200F', '\u2060', '\uFEFF', '\u061C', '\u00AD', '\u2061', '\u206A']) { + assert.equal(gueltig(name(`Sa${zeichen}lon`)).betrieb.name, 'Salon', JSON.stringify(zeichen)) + } + // ZWJ (Emoji-Sequenzen) und ZWNJ (persische Schrift) innerhalb des Textes bleiben + assert.equal(gueltig(name('\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon')).betrieb.name, '\u{1F469}\u200D\u{1F469}\u200D\u{1F467} Salon') + assert.equal(gueltig(name('می\u200Cخواهم')).betrieb.name, 'می\u200Cخواهم') + // am Rand sind sie wertlos und fallen weg + assert.equal(gueltig(name('\u200DSalon\u200C ')).betrieb.name, 'Salon') +}) + +test('Unsichtbare Formatzeichen: auch Leistungs-/Ressourcennamen, Storno, E-Mail und URL', () => { + const e = basis() + e.ressourcen[0].name = '\u200B' + e.leistungen[0].name = '\uFEFF\u200F' + const f = fehlerVon(e) + assert.ok(f['ressourcen.0.name']) + assert.ok(f['leistungen.0.name']) + + const ok = basis() + ok.ressourcen[0].name = 'An\u200Bna' + ok.leistungen[0].beschreibung = 'Mit\u200E\u2060 Waschen' + ok.storno = { text: 'Zeile\u200B 1\n\u200EZeile 2' } + ok.betrieb = { name: 'X', email: 'in\u200Bfo@sa\uFEFFlon.de', impressumUrl: 'https://sa\u200Blon.de/imp\u2060ressum' } + const e2 = gueltig(ok) + assert.equal(e2.ressourcen[0].name, 'Anna') + assert.equal(e2.leistungen[0].beschreibung, 'Mit Waschen') + assert.equal(e2.storno.text, 'Zeile 1\nZeile 2') + assert.equal(e2.betrieb.email, 'info@salon.de') + assert.equal(e2.betrieb.impressumUrl, 'https://salon.de/impressum') +}) + test('Farbe, Anrede, Bundesland werden geprueft', () => { assert.equal(gueltig({ ...basis(), farbe: '#AABBCC' }).farbe, '#aabbcc') for (const schlecht of ['red', '#abc', '#12345g', 'ffffff', 5]) assert.ok(fehlerVon({ ...basis(), farbe: schlecht }).farbe, String(schlecht))