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.
132 lines
3.5 KiB
TypeScript
132 lines
3.5 KiB
TypeScript
import type { Prisma } from '@afis/db'
|
|
import { db } from '@/lib/db'
|
|
|
|
/**
|
|
* Zugriffsprotokoll. Zweite, unabhängige Spur neben der Versionstabelle.
|
|
*
|
|
* Zwei Wege, aus einem Grund: der Eintrag darf die Antwort nicht blockieren
|
|
* (docs/09-performance-und-ux.md), aber bei schreibenden Aktionen darf er auch
|
|
* nicht verloren gehen.
|
|
*
|
|
* - `writeAudit` läuft **in derselben Transaktion** wie die Änderung. Schlägt
|
|
* das Protokoll fehl, schlägt die Änderung fehl.
|
|
* - `recordRead` sammelt Lesezugriffe im Prozess und schreibt sie alle zwei
|
|
* Sekunden gebündelt weg. Ein verlorener Lesevermerk nach einem Absturz ist
|
|
* hinnehmbar, eine um 40 ms langsamere Aktenansicht nicht.
|
|
*/
|
|
export type AuditAction =
|
|
| 'case.read'
|
|
| 'case.create'
|
|
| 'case.update'
|
|
| 'case.reclassify'
|
|
| 'case.status'
|
|
| 'entry.create'
|
|
| 'entry.edit'
|
|
| 'entry.retract'
|
|
| 'entry.restore'
|
|
| 'case.participant.add'
|
|
| 'case.participant.remove'
|
|
| 'case.link'
|
|
| 'case.unlink'
|
|
| 'personnel.read'
|
|
| 'personnel.create'
|
|
| 'personnel.update'
|
|
| 'personnel.reclassify'
|
|
| 'personnel.assign'
|
|
| 'personnel.unassign'
|
|
| 'document.read'
|
|
| 'document.create'
|
|
| 'document.update'
|
|
| 'document.finalize'
|
|
| 'document.retract'
|
|
| 'document.reclassify'
|
|
| 'attachment.upload'
|
|
| 'attachment.read'
|
|
| 'attachment.release'
|
|
| 'chat.adopt'
|
|
| 'department.create'
|
|
| 'department.update'
|
|
| 'department.assign'
|
|
| 'department.entry.create'
|
|
| 'department.entry.edit'
|
|
| 'department.entry.retract'
|
|
| 'roles.update'
|
|
| 'template.update'
|
|
| 'export.pdf'
|
|
| 'export.approve'
|
|
| 'export.reject'
|
|
| 'export.download'
|
|
| 'export.database'
|
|
|
|
export type AuditInput = {
|
|
actorId: string
|
|
action: AuditAction
|
|
targetType: string
|
|
targetId: string
|
|
clearanceAtTime: number
|
|
meta?: Prisma.InputJsonValue
|
|
}
|
|
|
|
/** Ab dieser Stufe wird auch das blosse Lesen protokolliert (harte Regel 3). */
|
|
export const READ_AUDIT_FROM_LEVEL = 3
|
|
|
|
type Client = Pick<typeof db, 'auditLog'>
|
|
|
|
/** Schreibende Aktionen: synchron, in der Transaktion des Aufrufers. */
|
|
export async function writeAudit(client: Client, input: AuditInput): Promise<void> {
|
|
await client.auditLog.create({
|
|
data: {
|
|
actorId: input.actorId,
|
|
action: input.action,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
clearanceAtTime: input.clearanceAtTime,
|
|
meta: input.meta,
|
|
},
|
|
})
|
|
}
|
|
|
|
const buffer: AuditInput[] = []
|
|
let flushTimer: NodeJS.Timeout | null = null
|
|
|
|
async function flush(): Promise<void> {
|
|
flushTimer = null
|
|
if (buffer.length === 0) return
|
|
|
|
const batch = buffer.splice(0, buffer.length)
|
|
try {
|
|
await db.auditLog.createMany({
|
|
data: batch.map((entry) => ({
|
|
actorId: entry.actorId,
|
|
action: entry.action,
|
|
targetType: entry.targetType,
|
|
targetId: entry.targetId,
|
|
clearanceAtTime: entry.clearanceAtTime,
|
|
meta: entry.meta,
|
|
})),
|
|
})
|
|
} catch (error) {
|
|
console.error('[audit] Bündel nicht geschrieben', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lesezugriff vormerken. Nur ab Stufe 3 aufrufen, sonst füllt sich das
|
|
* Protokoll mit Belanglosigkeiten und die interessanten Zeilen gehen unter.
|
|
*/
|
|
export function recordRead(input: AuditInput): void {
|
|
buffer.push(input)
|
|
flushTimer ??= setTimeout(() => void flush(), 2000)
|
|
// Der Prozess soll nicht wegen des Protokolls offen bleiben.
|
|
flushTimer.unref?.()
|
|
}
|
|
|
|
/** Für Tests und für das geordnete Herunterfahren. */
|
|
export async function flushAuditBuffer(): Promise<void> {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer)
|
|
flushTimer = null
|
|
}
|
|
await flush()
|
|
}
|