Files
AfiS-Aktensystem/web/lib/files/magic.ts
T
Harlekin 38be773636 README erklärt das Projekt, Zusicherungen unter Nummern
Die README beschrieb das Vorgehen statt der Anwendung. Sie erklärt jetzt, was
das System kann, wie es aufgebaut ist und wie man es lokal und im Betrieb
fährt.

Dazu tragen die acht Zusicherungen dort ihre Nummern - Berechtigungsprüfung in
der Abfrage, kein DELETE im Verlauf, Protokollpflicht ab Stufe 3, Magic Bytes
statt Dateiendungen, Skelette statt Spinner, Existenz als Information, keine
Geheimnisse im Repository, keine Einmal-Container. Achtzehn Dateien verwiesen
für ebendiese Regeln auf eine Datei, die nicht mehr mitverfolgt wird; sie
zeigen jetzt auf die README.

Arbeitsunterlagen bleiben auf der Platte, werden aber nicht mehr verfolgt. Die
Ausschlussregel steht in .git/info/exclude statt in .gitignore, damit sie nicht
selbst im Repository steht.
2026-09-04 08:42:07 +02:00

119 lines
4.2 KiB
TypeScript

/**
* Typprüfung über Magic Bytes.
*
* **Die Endung wird nie angesehen**, und der vom Browser gemeldete Typ nur, um
* ihn gegen den erkannten zu prüfen. Siehe Zusicherung 4 der README.
*
* Bewusst eine eigene Signaturtabelle statt `file-type`: die Liste der
* erlaubten Typen ist kurz und geschlossen, und was nicht darin steht, wird
* abgelehnt. Eine Bibliothek, die hundert Formate erkennt, hilft bei einer
* Positivliste nicht — sie erweitert nur die Angriffsfläche und die Menge
* dessen, was aktuell gehalten werden muss. Der eigentliche Schutz ist ohnehin
* die Neukodierung danach, siehe `process.ts`.
*
* Ausdrücklich verboten, auch wenn es bequem wäre: SVG (führt Skripte aus),
* HTML, ZIP und andere Archive, Office-Dokumente, alles Ausführbare. Sie
* fallen automatisch durch, weil sie nicht in der Tabelle stehen.
*/
export const ALLOWED_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
'application/pdf',
'text/plain',
] as const
export type AllowedType = (typeof ALLOWED_TYPES)[number]
export function isAllowedType(value: string): value is AllowedType {
return (ALLOWED_TYPES as readonly string[]).includes(value)
}
/** Wie viele Bytes vom Anfang für die Erkennung reichen. */
export const MAGIC_WINDOW = 32
type Signature = {
type: AllowedType
/** Bytes ab Position 0, `null` an einer Stelle heisst „beliebig". */
bytes: (number | null)[]
/** Zusätzliche Bedingung, etwa der Containertyp bei WebP. */
extra?: (buffer: Uint8Array) => boolean
}
const ascii = (text: string): number[] => [...text].map((c) => c.charCodeAt(0))
const SIGNATURES: Signature[] = [
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
// JFIF, Exif und die übrigen JPEG-Varianten beginnen alle so.
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{
// RIFF-Container. Ohne die zweite Prüfung wäre auch eine WAV-Datei ein Treffer.
type: 'image/webp',
bytes: ascii('RIFF'),
extra: (buffer) => ascii('WEBP').every((byte, index) => buffer[8 + index] === byte),
},
// Die Spezifikation erlaubt Müll vor %PDF-. Hier nicht: ein Vorspann ist der
// klassische Weg, eine Datei für zwei Programme gleichzeitig lesbar zu machen.
{ type: 'application/pdf', bytes: ascii('%PDF-') },
]
export type Detection =
| { ok: true; type: AllowedType }
| { ok: false; reason: 'unknown-type' | 'not-utf8' | 'empty' }
/**
* Erkennt den Typ am Inhalt.
*
* Reine Textdateien haben keine Signatur. Sie werden deshalb zuletzt geprüft
* und nur dann angenommen, wenn der ganze Inhalt gültiges UTF-8 ohne
* Steuerzeichen ist — sonst wäre `text/plain` das Schlupfloch, durch das jedes
* Binärformat passt.
*/
export function detectType(content: Uint8Array): Detection {
if (content.length === 0) return { ok: false, reason: 'empty' }
for (const signature of SIGNATURES) {
const passt = signature.bytes.every(
(byte, index) => byte === null || content[index] === byte,
)
if (passt && (signature.extra?.(content) ?? true)) {
return { ok: true, type: signature.type }
}
}
return looksLikeText(content) ? { ok: true, type: 'text/plain' } : { ok: false, reason: 'unknown-type' }
}
/**
* Gültiges UTF-8, keine Nullbytes, keine Steuerzeichen ausser Tabulator,
* Zeilenumbruch und Wagenrücklauf.
*/
export function looksLikeText(content: Uint8Array): boolean {
for (const byte of content) {
if (byte === 0x09 || byte === 0x0a || byte === 0x0d) continue
if (byte < 0x20 || byte === 0x7f) return false
}
try {
new TextDecoder('utf-8', { fatal: true }).decode(content)
return true
} catch {
return false
}
}
/**
* Der gemeldete Typ muss zum erkannten passen.
*
* Nicht, weil der gemeldete etwas wert wäre — er kommt vom Browser und ist frei
* wählbar —, sondern weil eine Abweichung ein Hinweis ist: wer eine PNG-Datei
* hochlädt und sie als PDF ankündigt, hat etwas vor.
*/
export function matchesDeclared(detected: AllowedType, declared: string | null): boolean {
if (!declared) return true
const normalisiert = declared.split(';')[0]!.trim().toLowerCase()
if (normalisiert === '' || normalisiert === 'application/octet-stream') return true
return normalisiert === detected
}