Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +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
}