/** * Dateityp-Erkennung an den Magic Bytes. * * Warum nicht am gemeldeten MIME-Typ oder an der Endung? Weil beides vom * Browser kommt und damit vom Angreifer bestimmt wird. Eine Datei, die * "rechnung.pdf" heisst und `application/pdf` meldet, kann eine HTML-Seite * mit Skript sein. Liefert der Server sie spaeter aus, laeuft das Skript im * Kontext dieser Anwendung - und diese Anwendung sieht Gesundheits- und * Finanzdaten einer Person, die sich nicht wehren kann. * * Deshalb: Der Inhalt entscheidet. Gemeldeter Typ und Endung sind Indizien, * mehr nicht. Bewusst ohne zusaetzliches Paket - die Signaturen der sechs * erlaubten Formate sind kurz und aendern sich nicht. * * Rein und ohne Seiteneffekte, damit es sich testen laesst. */ export type ErlaubterMimeTyp = | 'application/pdf' | 'image/jpeg' | 'image/png' | 'image/webp' | 'image/tiff' | 'image/heic' export type Dateityp = { mimeTyp: ErlaubterMimeTyp /** Endung ohne Punkt, kleingeschrieben. Wird an den Schluessel gehaengt. */ endung: string bezeichnung: string /** Kann Tesseract das direkt lesen, oder muss vorher gewandelt werden? */ istBild: boolean } export const ERLAUBTE_TYPEN: readonly Dateityp[] = [ { mimeTyp: 'application/pdf', endung: 'pdf', bezeichnung: 'PDF', istBild: false }, { mimeTyp: 'image/jpeg', endung: 'jpg', bezeichnung: 'JPEG-Bild', istBild: true }, { mimeTyp: 'image/png', endung: 'png', bezeichnung: 'PNG-Bild', istBild: true }, { mimeTyp: 'image/webp', endung: 'webp', bezeichnung: 'WEBP-Bild', istBild: true }, { mimeTyp: 'image/tiff', endung: 'tif', bezeichnung: 'TIFF-Bild', istBild: true }, { mimeTyp: 'image/heic', endung: 'heic', bezeichnung: 'HEIC-Foto', istBild: true }, ] /** Fuer Oberflaechentexte und das accept-Attribut im Datei-Auswahlfeld. */ export const ERLAUBTE_ENDUNGEN = ERLAUBTE_TYPEN.map((t) => `.${t.endung}`).concat(['.jpeg', '.tiff']) export const ACCEPT_ATTRIBUT = [ 'application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/heic', ...ERLAUBTE_ENDUNGEN, ].join(',') function typ(mimeTyp: ErlaubterMimeTyp): Dateityp { const treffer = ERLAUBTE_TYPEN.find((t) => t.mimeTyp === mimeTyp) if (!treffer) throw new Error(`Unbekannter Typ ${mimeTyp}`) return treffer } function beginntMit(inhalt: Buffer, bytes: readonly number[], versatz = 0): boolean { if (inhalt.length < versatz + bytes.length) return false for (let i = 0; i < bytes.length; i++) { if (inhalt[versatz + i] !== bytes[i]) return false } return true } function textAn(inhalt: Buffer, von: number, bis: number): string { if (inhalt.length < bis) return '' return inhalt.subarray(von, bis).toString('latin1') } /** * HEIC/HEIF steckt in einem ISO-BMFF-Container: vier Byte Boxlaenge, dann * "ftyp", dann die Marke. Nur die Bildmarken sind erlaubt - "mp42" waere * ein Video und hat hier nichts verloren. */ const HEIC_MARKEN = new Set([ 'heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs', 'mif1', 'msf1', ]) /** * Erkennt den Typ am Inhalt. Gibt null zurueck, wenn es keiner der sechs * erlaubten Typen ist - im Zweifel abgelehnt, nie "wird schon passen". */ export function typErkennen(inhalt: Buffer): Dateityp | null { if (inhalt.length < 12) return null // PDF: "%PDF-" muss ganz am Anfang stehen. Toleranz waere hier gefaehrlich: // Dateien, die erst nach ein paar Bytes mit %PDF beginnen, sind der // klassische Polyglot-Trick (HTML davor, PDF dahinter). if (beginntMit(inhalt, [0x25, 0x50, 0x44, 0x46, 0x2d])) return typ('application/pdf') // JPEG: FF D8 FF if (beginntMit(inhalt, [0xff, 0xd8, 0xff])) return typ('image/jpeg') // PNG: 89 "PNG" CR LF 1A LF if (beginntMit(inhalt, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return typ('image/png') // WEBP: "RIFF" ....(Groesse).... "WEBP" if (textAn(inhalt, 0, 4) === 'RIFF' && textAn(inhalt, 8, 12) === 'WEBP') return typ('image/webp') // TIFF: "II*\0" (little endian) oder "MM\0*" (big endian) if (beginntMit(inhalt, [0x49, 0x49, 0x2a, 0x00])) return typ('image/tiff') if (beginntMit(inhalt, [0x4d, 0x4d, 0x00, 0x2a])) return typ('image/tiff') // HEIC: ftyp-Box mit Bildmarke if (textAn(inhalt, 4, 8) === 'ftyp' && HEIC_MARKEN.has(textAn(inhalt, 8, 12))) { return typ('image/heic') } return null } /** * Beschreibt, was stattdessen erkannt wurde - damit die Fehlermeldung nicht * bei "Dateityp nicht erlaubt" stehen bleibt, sondern sagt, was los ist. * Nur fuer Meldungen, nie als Grundlage einer Entscheidung. */ export function inhaltBeschreiben(inhalt: Buffer): string { const anfang = inhalt.subarray(0, 512).toString('latin1').trimStart().toLowerCase() if (anfang.startsWith('