Files
AfiS-Aktensystem/web/lib/templates/schema.ts
T
Harlekin e21e478fd1 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.
2026-09-03 23:52:53 +02:00

95 lines
3.1 KiB
TypeScript

import { z } from 'zod'
import { INPUT_TYPES, STATIC_TYPES, type TemplateField } from '@/lib/templates/fields'
/**
* Verbindliche Prüfung einer Vorlage. Läuft ausschliesslich serverseitig:
* eine Server Action ist ein Endpunkt, und was hier durchgeht, geht auch durch,
* wenn jemand den Editor umgeht.
*/
const key = z
.string()
.min(1)
.max(64)
// Der Schlüssel ist stabil und wird nie geändert: ein Abschnitt findet über
// ihn seinen Baustein wieder, auch über Vorlagenversionen hinweg.
.regex(/^[a-z][a-z0-9_]*$/, 'Kleinbuchstaben, Ziffern und Unterstrich')
const gemeinsam = {
key,
label: z.string().min(1).max(200),
help: z.string().max(500).optional(),
/** Feld erst ab dieser Stufe sichtbar, siehe docs/05-akten-und-vorlagen.md. */
minClearance: z.number().int().min(0).max(5).optional(),
}
/**
* `.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,
type: z.enum(INPUT_TYPES),
required: z.boolean().optional(),
requiredToClose: z.boolean().optional(),
options: z.array(z.string().min(1)).optional(),
columns: z.array(z.string().min(1)).optional(),
span: z.number().int().min(1).max(5).optional(),
rows: z.number().int().min(1).max(12).optional(),
slot: z.enum(['classbar']).optional(),
emphasis: z.boolean().optional(),
dateKey: key.optional(),
})
const staticBlock = z.strictObject({
...gemeinsam,
type: z.enum(STATIC_TYPES),
text: z.string().max(20_000).optional(),
span: z.number().int().min(1).max(5).optional(),
})
export const templateFieldSchema = z.union([inputField, staticBlock])
export const templateFieldsSchema = z
.array(templateFieldSchema)
.max(200)
.superRefine((fields, ctx) => {
const seen = new Set<string>()
for (const [index, field] of fields.entries()) {
if (seen.has(field.key)) {
ctx.addIssue({
code: 'custom',
path: [index, 'key'],
message: `Schlüssel doppelt vergeben: ${field.key}`,
})
}
seen.add(field.key)
if (
(field.type === 'select' || field.type === 'multiselect') &&
(!('options' in field) || !field.options?.length)
) {
ctx.addIssue({
code: 'custom',
path: [index, 'options'],
message: 'Auswahlfelder brauchen mindestens eine Option',
})
}
if ('dateKey' in field && field.dateKey && field.type !== 'signature') {
ctx.addIssue({
code: 'custom',
path: [index, 'dateKey'],
message: 'Ein Datumsverweis gehört nur an eine Unterschrift',
})
}
}
})
export function parseTemplateFields(raw: unknown): TemplateField[] {
const parsed = templateFieldsSchema.safeParse(raw)
return parsed.success ? (parsed.data as TemplateField[]) : []
}