/** * 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 }