import { describe, expect, it } from 'vitest' import { templateFieldsSchema } from '@/lib/templates/schema' import { fillPlaceholders } from '@/lib/templates/fields' import { colorVariable, COLOR_TOKENS } from '@/components/StampMark' import { deliveryFileName } from '@/lib/files/delivery' import { splitBody } from '@/lib/chat/mentions' /** * Was passiert, wenn jemand versucht, über ein Textfeld Code einzuschleusen. * * Die Grundlage ist, dass React alles maskiert, was es als Text rendert, und * dass es in dieser Anwendung genau **ein** `dangerouslySetInnerHTML` gibt — * mit einer Konstanten aus demselben Modul. Diese Datei prüft die Ränder darum * herum: die Stellen, an denen Text nicht als Text ankommt, sondern als * Vorlage, als Stilangabe oder als Kopfzeile. */ const BOESARTIG = [ '', '">', 'javascript:alert(1)', '{{constructor.constructor("alert(1)")()}}', ] describe('Vorlagen nehmen nur an, was sie kennen', () => { it('weist unbekannte Schlüssel ab, statt sie still zu verwerfen', () => { // Still verwerfen wäre die schlechtere Antwort: die Vorlage wäre // gespeichert und täte nicht, was dasteht. const ergebnis = templateFieldsSchema.safeParse([ { key: 'titel', label: 'Titel', type: 'shorttext', onClick: 'alert(1)' }, ]) expect(ergebnis.success).toBe(false) }) it('nimmt keine erfundene Feldart', () => { for (const art of ['script', 'html', 'iframe', '__proto__']) { const ergebnis = templateFieldsSchema.safeParse([ { key: 'feld', label: 'Feld', type: art }, ]) expect(ergebnis.success, art).toBe(false) } }) it('nimmt keinen Schlüssel, der kein Bezeichner ist', () => { for (const schluessel of ['__proto__', 'a-b', 'A', '../x', '']) { const ergebnis = templateFieldsSchema.safeParse([ { key: schluessel, label: 'Feld', type: 'shorttext' }, ]) expect(ergebnis.success, schluessel).toBe(false) } }) it('lässt bösartigen Text als Beschriftung durch — als Text', () => { // Das ist Absicht: eine Beschriftung darf jedes Zeichen enthalten, weil sie // als Text gerendert wird und nie als HTML. Verboten ist die Struktur, // nicht das Zeichen. for (const text of BOESARTIG) { const ergebnis = templateFieldsSchema.safeParse([ { key: 'feld', label: text, type: 'shorttext' }, ]) expect(ergebnis.success, text).toBe(true) } }) }) describe('Platzhalter bleiben Text', () => { it('setzt Werte ein, ohne sie auszuwerten', () => { const text = fillPlaceholders('Hallo {{name}}', { name: '', }) // Unverändert übernommen — und React macht daraus beim Rendern Text. expect(text).toBe('Hallo ') }) it('holt nichts aus der Prototypkette', () => { // `constructor` ist ein gültiger Feldschlüssel: die Schlüsselregel erlaubt // Kleinbuchstaben, und mehr braucht das Wort nicht. Ohne die Prüfung auf // eigene Eigenschaften stand danach der Quelltext der geerbten Funktion im // Dokument. expect(fillPlaceholders('{{constructor}}', { anderes: 'x' })).toBe('[constructor]') // Als echtes Feld funktioniert derselbe Schlüssel weiterhin. expect(fillPlaceholders('{{constructor}}', { constructor: 'Bauleitung' })).toBe( 'Bauleitung', ) }) it('erkennt nur Kleinbuchstaben als Platzhalter', () => { // Zweite Schranke vor derselben Gefahr: `toString` und `valueOf` tragen // Grossbuchstaben und sind damit schon syntaktisch kein Platzhalter. Sie // bleiben stehen, wie sie dastehen. for (const name of ['toString', 'valueOf', 'hasOwnProperty', '__proto__']) { expect(fillPlaceholders(`{{${name}}}`, { anderes: 'x' })).toBe(`{{${name}}}`) } }) it('führt keinen Ausdruck aus, der wie einer aussieht', () => { const text = fillPlaceholders('{{a}}', { a: '{{b}}', b: 'nie' }) // Kein zweiter Durchlauf: eingesetzte Werte werden nicht erneut ersetzt. expect(text).toBe('{{b}}') }) }) describe('Farbmarken aus der Datenbank', () => { it('lässt nur bekannte Marken in ein Stilattribut', () => { for (const token of COLOR_TOKENS) { expect(colorVariable(token)).toBe(`var(--color-${token})`) } }) it('fällt bei allem anderen auf die neutrale Marke zurück', () => { for (const versuch of [ 'stamp-0); background: url(https://example.invalid/x', 'x); position:fixed; inset:0', '', null, undefined, ]) { expect(colorVariable(versuch as string)).toBe('var(--color-stamp-0)') } }) }) describe('Dateinamen in der Kopfzeile', () => { it('lässt keinen Zeilenumbruch und kein Anführungszeichen durch', () => { const name = deliveryFileName('a"\r\nX-Injected: 1\\.png', 'image/png') expect(name).not.toMatch(/["\r\n\\]/) expect(name.endsWith('.png')).toBe(true) }) it('erzwingt die Endung aus dem geprüften Typ, nicht aus dem Namen', () => { // Der Name kommt vom Hochladenden, der Typ aus den Magic Bytes. expect(deliveryFileName('harmlos.png.html', 'application/pdf')).toBe('harmlos.png.pdf') }) }) describe('Nachrichtentext wird zerlegt, nicht gebaut', () => { it('macht aus Auszeichnung keine Struktur', () => { const stuecke = splitBody(' @jemand AfiS-2026-0001') // Der gefährliche Teil bleibt ein Textstück; nur Erwähnung und Aktenzeichen // werden als eigene Art erkannt. const text = stuecke .filter((s) => s.kind === 'text') .map((s) => s.value) .join('') expect(text).toContain('') expect(stuecke.some((s) => s.kind === 'mention')).toBe(true) }) })