Vorlagen-JSON abweisen statt still verwerfen, Platzhalter ohne Prototypkette

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.
This commit is contained in:
Harlekin
2026-09-03 23:52:53 +02:00
parent 03a36badc8
commit e050ab7c41
8 changed files with 198 additions and 5 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ export async function saveTemplate(input: unknown): Promise<TemplateResult> {
await writeAudit(tx, { await writeAudit(tx, {
actorId: user.id, actorId: user.id,
action: 'roles.update', action: 'template.update',
targetType: 'DocumentTemplate', targetType: 'DocumentTemplate',
targetId: `${key}:${version}`, targetId: `${key}:${version}`,
clearanceAtTime: user.permissions.clearanceLevel, clearanceAtTime: user.permissions.clearanceLevel,
+26
View File
@@ -11,6 +11,32 @@ export function stampColor(level: number): string {
return STAMP_COLOR[level] ?? STAMP_COLOR[0]! return STAMP_COLOR[level] ?? STAMP_COLOR[0]!
} }
/**
* Die Farbmarken, die aus der Datenbank kommen duerfen.
*
* `ClearanceLevelConfig.colorToken` und `PersonnelStatusTag.colorToken` sind
* Daten und im Adminbereich pflegbar. Sie landen als Name einer CSS-Variablen
* in einem `style`-Attribut, und was aus der Datenbank in ein Stilattribut
* geht, wird geprueft — nicht weil es heute jemand setzen koennte, sondern
* weil es morgen jemand koennen soll. Ein unbekannter Wert faellt auf die
* neutrale Marke zurueck, statt eine Variable zu erfinden, die es nicht gibt.
*/
export const COLOR_TOKENS = [
'stamp-0',
'stamp-1',
'stamp-2',
'stamp-3',
'stamp-4',
'stamp-5',
] as const
export type ColorToken = (typeof COLOR_TOKENS)[number]
export function colorVariable(token: string | null | undefined): string {
const erlaubt = (COLOR_TOKENS as readonly string[]).includes(token ?? '')
return `var(--color-${erlaubt ? token : 'stamp-0'})`
}
/** /**
* Stempelmarke oben rechts am Objekt. Der Stempelcharakter kommt aus dem * Stempelmarke oben rechts am Objekt. Der Stempelcharakter kommt aus dem
* schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben. * schmalen Schnitt, der Rahmenlinie und der Farbe, nicht aus Grossbuchstaben.
+3 -1
View File
@@ -1,3 +1,5 @@
import { colorVariable } from '@/components/StampMark'
/** /**
* Status einer Person als Etiketten. * Status einer Person als Etiketten.
* *
@@ -20,7 +22,7 @@ export function StatusTags({
<ul className="flex flex-wrap gap-1.5"> <ul className="flex flex-wrap gap-1.5">
{tags.map((key) => { {tags.map((key) => {
const tag = byKey.get(key) const tag = byKey.get(key)
const color = `var(--color-${tag?.colorToken ?? 'stamp-0'})` const color = colorVariable(tag?.colorToken)
return ( return (
<li <li
key={key} key={key}
+1
View File
@@ -51,6 +51,7 @@ export type AuditAction =
| 'department.entry.edit' | 'department.entry.edit'
| 'department.entry.retract' | 'department.entry.retract'
| 'roles.update' | 'roles.update'
| 'template.update'
| 'export.pdf' | 'export.pdf'
| 'export.approve' | 'export.approve'
| 'export.reject' | 'export.reject'
+2
View File
@@ -57,6 +57,7 @@ export const auditText = {
'department.entry.edit': 'Abteilungsvermerk geändert', 'department.entry.edit': 'Abteilungsvermerk geändert',
'department.entry.retract': 'Abteilungsvermerk zurückgezogen', 'department.entry.retract': 'Abteilungsvermerk zurückgezogen',
'roles.update': 'Rollenzuordnung geändert', 'roles.update': 'Rollenzuordnung geändert',
'template.update': 'Vorlage geändert',
'export.pdf': 'PDF erzeugt', 'export.pdf': 'PDF erzeugt',
'export.approve': 'Export freigegeben', 'export.approve': 'Export freigegeben',
'export.reject': 'Export abgelehnt', 'export.reject': 'Export abgelehnt',
@@ -74,6 +75,7 @@ export const auditText = {
ChatMessage: 'Nachricht', ChatMessage: 'Nachricht',
ExportJob: 'Export', ExportJob: 'Export',
RoleMapping: 'Rollenzuordnung', RoleMapping: 'Rollenzuordnung',
DocumentTemplate: 'Vorlage',
ChangePolicy: 'Änderungsrichtlinie', ChangePolicy: 'Änderungsrichtlinie',
Database: 'Datenbank', Database: 'Datenbank',
Department: 'Abteilung', Department: 'Abteilung',
+7 -1
View File
@@ -91,7 +91,13 @@ export function fillPlaceholders(
fallback = (key: string) => `[${key}]`, fallback = (key: string) => `[${key}]`,
): string { ): string {
return text.replace(PLACEHOLDER, (_match, name: string) => { return text.replace(PLACEHOLDER, (_match, name: string) => {
const value = values[name] // **Nur eigene Eigenschaften.** `values[name]` lief über die
// Prototypkette: ein Baustein mit dem Platzhalter `{{constructor}}` oder
// `{{toString}}` bekam den Quelltext der geerbten Funktion in den Text
// geschrieben — die Feldschlüssel sind Kleinbuchstaben, und `constructor`
// ist einer. Kein Code, der ausgeführt wird, aber Innenleben, das in einem
// Dokument nichts zu suchen hat.
const value = Object.hasOwn(values, name) ? values[name] : undefined
if (value === undefined || value === null || value === '') return fallback(name) if (value === undefined || value === null || value === '') return fallback(name)
return Array.isArray(value) ? value.join(', ') : String(value) return Array.isArray(value) ? value.join(', ') : String(value)
}) })
+9 -2
View File
@@ -22,7 +22,14 @@ const gemeinsam = {
minClearance: z.number().int().min(0).max(5).optional(), minClearance: z.number().int().min(0).max(5).optional(),
} }
const inputField = z.object({ /**
* `.strict()` und nicht die Voreinstellung: zod wuerde unbekannte Schluessel
* **stillschweigend verwerfen**. Fuer eine Vorlage, die von Hand als JSON
* eingegeben wird, ist das die schlechtere Antwort — wer `typ` statt `type`
* schreibt, bekaeme eine gespeicherte Vorlage, die nicht tut, was dasteht.
* Und was nicht ankommt, kann auch nichts einschleusen.
*/
const inputField = z.strictObject({
...gemeinsam, ...gemeinsam,
type: z.enum(INPUT_TYPES), type: z.enum(INPUT_TYPES),
required: z.boolean().optional(), required: z.boolean().optional(),
@@ -36,7 +43,7 @@ const inputField = z.object({
dateKey: key.optional(), dateKey: key.optional(),
}) })
const staticBlock = z.object({ const staticBlock = z.strictObject({
...gemeinsam, ...gemeinsam,
type: z.enum(STATIC_TYPES), type: z.enum(STATIC_TYPES),
text: z.string().max(20_000).optional(), text: z.string().max(20_000).optional(),
+149
View File
@@ -0,0 +1,149 @@
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)
})
})