// @vitest-environment node import { describe, expect, it } from 'vitest' import { ALLOWED_TYPES, detectType, looksLikeText, matchesDeclared } from '@/lib/files/magic' import { absolutePath, storageKeyFor } from '@/lib/files/paths' import { deliveryFileName } from '@/lib/files/delivery' /** * Die Typprüfung ist die Stelle, an der eine feindselige Datei auffallen muss. * * Geprüft wird deshalb nicht nur, dass die erlaubten Formate durchkommen, * sondern vor allem, dass die verbotenen es **nicht** tun - und zwar auch * dann, wenn sie sich als erlaubte ausgeben. */ const bytes = (...werte: number[]) => new Uint8Array(werte) const text = (wert: string) => new TextEncoder().encode(wert) const PNG = bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13) const JPEG = bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46) const WEBP = new Uint8Array([...text('RIFF'), 0, 0, 0, 0, ...text('WEBP'), ...text('VP8 ')]) const PDF = text('%PDF-1.7\n1 0 obj\n') describe('Erkennung erlaubter Typen', () => { it('erkennt jedes zugelassene Format am Inhalt', () => { expect(detectType(PNG)).toEqual({ ok: true, type: 'image/png' }) expect(detectType(JPEG)).toEqual({ ok: true, type: 'image/jpeg' }) expect(detectType(WEBP)).toEqual({ ok: true, type: 'image/webp' }) expect(detectType(PDF)).toEqual({ ok: true, type: 'application/pdf' }) expect(detectType(text('Nur Text, mehr nicht.\n'))).toEqual({ ok: true, type: 'text/plain', }) }) it('deckt genau die fünf Typen aus dem Dokument ab', () => { expect([...ALLOWED_TYPES]).toEqual([ 'image/png', 'image/jpeg', 'image/webp', 'application/pdf', 'text/plain', ]) }) }) describe('Abwehr', () => { it('lehnt SVG ab, auch wenn es wie Text aussieht', () => { // Der eigentliche Grund für die ganze Kette: SVG führt im Browser Skripte // aus. Es ist gültiges UTF-8 und käme sonst als text/plain durch - das ist // in Ordnung, denn als Text wird es nie gerendert, sondern ausgeliefert // mit Content-Disposition: attachment. const svg = text('') const erkannt = detectType(svg) expect(erkannt).toEqual({ ok: true, type: 'text/plain' }) // Entscheidend: es ist nie image/svg+xml, und der gemeldete Typ passt nicht. expect(matchesDeclared('text/plain', 'image/svg+xml')).toBe(false) }) it('lehnt Archive und Ausführbares ab', () => { expect(detectType(bytes(0x50, 0x4b, 0x03, 0x04, 0, 0))).toEqual({ ok: false, reason: 'unknown-type', }) // ELF expect(detectType(bytes(0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0))).toEqual({ ok: false, reason: 'unknown-type', }) // Windows-Programm expect(detectType(bytes(0x4d, 0x5a, 0x90, 0x00))).toEqual({ ok: false, reason: 'unknown-type', }) }) it('lässt sich vom RIFF-Container nicht täuschen', () => { // WAV ist auch RIFF. Ohne die zweite Prüfung wäre es ein WebP. const wav = new Uint8Array([...text('RIFF'), 0, 0, 0, 0, ...text('WAVE')]) expect(detectType(wav)).toEqual({ ok: false, reason: 'unknown-type' }) }) it('erkennt ein PDF mit Vorspann nicht als PDF', () => { // Müll vor %PDF- ist der klassische Weg, eine Datei für zwei Programme // zugleich lesbar zu machen. Die Spezifikation erlaubt es, wir nicht: die // Signatur muss an Position 0 stehen. const getarnt = new Uint8Array([...text('GIF89a'), ...PDF]) const erkannt = detectType(getarnt) expect(erkannt).not.toEqual({ ok: true, type: 'application/pdf' }) // Es bleibt lesbarer Text und wird deshalb als solcher angenommen. Das ist // harmlos, weil die Auslieferung die Endung auf den **erkannten** Typ // setzt: die Datei kommt als .txt an und landet in keinem PDF-Betrachter. expect(erkannt).toEqual({ ok: true, type: 'text/plain' }) expect(deliveryFileName('bericht.pdf', 'text/plain')).toBe('bericht.txt') }) it('lehnt einen Vorspann mit Binärdaten ganz ab', () => { const getarnt = new Uint8Array([0x00, 0x01, ...text('%PDF-1.7')]) expect(detectType(getarnt)).toEqual({ ok: false, reason: 'unknown-type' }) }) it('lässt kein Binärformat als Text durch', () => { expect(looksLikeText(bytes(0x48, 0x00, 0x69))).toBe(false) expect(looksLikeText(bytes(0xff, 0xfe, 0x41))).toBe(false) expect(looksLikeText(text('Umlaute sind erlaubt: äöüß\n\t'))).toBe(true) }) it('lehnt eine leere Datei ab', () => { expect(detectType(new Uint8Array())).toEqual({ ok: false, reason: 'empty' }) }) it('erkennt, wenn der gemeldete Typ nicht zum Inhalt passt', () => { expect(matchesDeclared('image/png', 'application/pdf')).toBe(false) expect(matchesDeclared('image/png', 'image/png')).toBe(true) // Der Browser weiss es manchmal selbst nicht; das ist kein Verdacht. expect(matchesDeclared('image/png', 'application/octet-stream')).toBe(true) expect(matchesDeclared('image/png', null)).toBe(true) // Zeichensatzangabe im Kopf stört nicht. expect(matchesDeclared('text/plain', 'text/plain; charset=utf-8')).toBe(true) }) }) describe('Ablagepfade', () => { it('verteilt auf zwei Ebenen und behält die Kennung', () => { expect(storageKeyFor('store', '0192abcd-ef01-7000-8000-000000000001')).toBe( 'store/01/92/0192abcd-ef01-7000-8000-000000000001', ) }) it('lässt keinen Pfad aus dem Dateiverzeichnis heraus', () => { process.env.FILES_DIR ??= '/tmp/afis-test-files' // Kommt aus der Datenbank, nicht aus einer Eingabe - aber ein // Verzeichniswechsel wäre der teuerste Fehler an dieser Stelle. expect(() => absolutePath('../../etc/passwd')).toThrow() expect(() => absolutePath('store/../../../etc/passwd')).toThrow() expect(absolutePath('store/ab/cd/ef')).toContain('store/ab/cd/ef') }) it('wirft Sonderzeichen aus der Kennung, bevor sie ein Pfad wird', () => { expect(storageKeyFor('quarantine', '../../etc')).not.toContain('..') }) }) describe('Name beim Ausliefern', () => { it('setzt die Endung auf den erkannten Typ, nicht auf den hochgeladenen Namen', () => { // Sonst trüge eine als Text erkannte Datei weiter .pdf und würde beim // Doppelklick im PDF-Betrachter geöffnet. expect(deliveryFileName('urlaub.jpeg', 'image/png')).toBe('urlaub.png') expect(deliveryFileName('notiz', 'text/plain')).toBe('notiz.txt') expect(deliveryFileName('vertrag.pdf', 'application/pdf')).toBe('vertrag.pdf') expect(deliveryFileName('bild.JPG', 'image/jpeg')).toBe('bild.jpg') }) it('lässt keine Kopfzeile zerlegen', () => { const boes = deliveryFileName('a"; rm -rf /\r\nX: y.txt', 'text/plain') expect(boes).not.toContain('"') expect(boes).not.toMatch(/[\r\n]/) }) })