Durchgesehen, was mit Text passiert, der nicht als Text ankommt. Die Grundlage
war schon in Ordnung: genau ein dangerouslySetInnerHTML mit einer Konstanten,
kein eval, kein $queryRawUnsafe, keine Weiterleitung aus einem Adressparameter,
Dateien mit nosniff und sandbox-CSP als Anhang statt inline.
Drei Ränder waren offen.
**Vorlagen.** Das zod-Schema hatte einen festen Satz Feldarten und speicherte
das Geprüfte, verwarf unbekannte Schlüssel aber stillschweigend. Mit
strictObject fällt ein `typ` statt `type` auf, statt eine Vorlage zu
hinterlassen, die nicht tut, was dasteht.
**Platzhalter lasen über die Prototypkette.** Beim Durchprobieren gefunden:
`constructor` ist ein gültiger Feldschlüssel, die Schlüsselregel erlaubt
Kleinbuchstaben. Ein Baustein mit {{constructor}} bekam den Quelltext der
geerbten Funktion in den Text geschrieben - kein ausgeführter Code, aber
Innenleben in einem Dokument, das weitergereicht wird. Jetzt Object.hasOwn.
**Farbmarken aus der Datenbank** landen als Name einer CSS-Variablen in einem
style-Attribut. Heute setzt das nur der Seed, und eine zweite Deklaration liesse
sich darüber ohnehin nicht einschleusen - aber der Kommentar sagt „im
Adminbereich pflegbar", und was aus der Datenbank in ein Stilattribut geht,
wird geprüft.
Dreizehn Prüfungen, die es mit echten Versuchen probieren. Bösartiger Text
bleibt dabei ausdrücklich erlaubt: eine Feldbeschriftung darf
<script>alert(1)</script> heissen und sieht dann genau so aus. Verboten ist,
dass daraus ein Element wird.
Nebenbei: eine Vorlagenänderung stand im Zugriffsprotokoll als roles.update.
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
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 = [
|
|
'<script>alert(1)</script>',
|
|
'"><img src=x onerror=alert(1)>',
|
|
'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: '<script>alert(1)</script>',
|
|
})
|
|
// Unverändert übernommen — und React macht daraus beim Rendern Text.
|
|
expect(text).toBe('Hallo <script>alert(1)</script>')
|
|
})
|
|
|
|
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('<script>alert(1)</script> @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('<script>alert(1)</script>')
|
|
expect(stuecke.some((s) => s.kind === 'mention')).toBe(true)
|
|
})
|
|
})
|