API-Datei-Uploads mit Magic Numbers in Node.js prüfen
Ein Upload namens avatar.png ist nicht unbedingt eine PNG-Datei. Diese Anleitung
zeigt einen lokalen Node.js-Endpunkt, der PNG- und JPEG-Uploads anhand ihrer Bytes erkennt,
andere erkannte Typen ablehnt und Byte-Limits für Dateien und Anfragen durchsetzt. Eine erfolgreiche
Antwort bedeutet, dass die Signatur zur Positivliste passt, nicht, dass das Bild gültig oder sicher ist.
Warum die Validierung der Dateiendung nicht ausreicht
Der Dateiname und der Wert für Content-Type des Dateiteils stammen vom Client.
Eine Datei umzubenennen oder image/png anzugeben, ändert ihren Inhalt nicht.
Der folgende Endpunkt ignoriert beides bewusst, wenn er entscheidet, ob der erkannte Typ zulässig
ist. Er verwendet niemals einen vom Client gelieferten Dateinamen als Speicherpfad.
Die Upload-Empfehlungen von OWASP
erläutern, warum Client-MIME-Typen keine Sicherheitsgrenze bilden können.
Magic Numbers und Dateisignaturen verstehen
Magic Numbers sind erkennbare Byte-Folgen, die auf das Format einer Datei hindeuten. Eine PNG-Datei
beginnt mit 89 50 4E 47 0D 0A 1A 0A, eine JPEG-Datei mit FF D8 FF.
Eine Erkennungsbibliothek kennt mehr Formatdetails als eine kurze, selbst geschriebene
Präfixprüfung, decodiert aber dennoch nicht das vollständige Bild.
Die Dokumentation zu file-type
beschreibt die Erkennung als Hinweis nach bestem Bemühen, nicht als Nachweis der Dateigültigkeit.
Bei manchen beschädigten Dateien wird kein Treffer gefunden oder eine Ausnahme ausgelöst;
andere haben weiterhin eine erkennbare Signatur. Nennen Sie das Ergebnis nicht
valid und verwenden Sie es nicht als Malware-Befund.
Magic-Number-Validierung in Node.js implementieren
Verwenden Sie Bash unter Linux, cURL, Node.js 24.15.0 oder eine neuere Version der 24-LTS-Reihe sowie Corepack mit verfügbarem Yarn 4.12.0. Das Beispiel wurde mit Node.js 24.15.0 und 26.8.1 getestet. Verwenden Sie für die Bereitstellung eine aktuelle LTS-Version mit Sicherheitsupdates, statt die getestete Mindestversion als Empfehlung für den Stand der Sicherheitsupdates zu betrachten.
Beginnen Sie in einem beschreibbaren Verzeichnis. Dadurch wird ein neues Projekt
magic-upload angelegt, ohne das Arbeitsverzeichnis Ihrer Shell zu ändern.
Ein bereits vorhandenes Projektverzeichnis wird abgelehnt. Falls das Anlegen oder der
Verzeichniswechsel fehlschlägt, wird vor der Installation abgebrochen. Die lokale Datei
yarn.lock legt ein separates Yarn-Projekt fest. Der node-modules-Linker
ermöglicht es dem einfachen Befehl node, seine Abhängigkeiten aufzulösen,
auch innerhalb eines übergeordneten Yarn-Plug’n’Play-Projekts.
(
mkdir magic-upload &&
cd magic-upload &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
YARN_NODE_LINKER=node-modules corepack yarn add --exact \
express@4.22.2 file-type@22.0.0 multer@2.4.0
)
Multer 2.4.0 stellt die unten verwendete Option streamHandler bereit und enthält
eine Sicherheitskorrektur für die Upload-Bereinigung.
Halten Sie Upload-Abhängigkeiten beim Anpassen des Beispiels mit Sicherheitsupdates aktuell.
Größenbegrenzte Uploads mit Express.js validieren
Speichern Sie das vollständige Programm als magic-upload/server.mts. Nodes
natives Entfernen von Typannotationen führt diese Datei mit der
Endung .mts als ES-Modul aus, ohne Compiler oder TypeScript-Loader.
Dabei werden weder die Typen des Programms geprüft noch eine übergeordnete
tsconfig.json gelesen.
Die erste Middleware verwendet express.raw,
um den vollständigen Anfragekörper vor dem Multipart-Parsing mit einem Limit von 5 MiB + 16 KiB
zu lesen. Das erfasst neben Dateiinhalten auch Begrenzungsmarkierungen, Teil-Header und nachfolgende
Bytes des Anfragekörpers, einschließlich Anfragen ohne Content-Length.
Multer setzt anschließend ein separates Dateilimit von 5 MiB durch und akzeptiert nur einen
Dateiteil namens file, ohne Textfelder. Die Option
streamHandler übergibt den bereits gelesenen
Anfragekörper an den Multipart-Parser, statt den ausgeschöpften Anfragestream erneut zu lesen.
import express, { type ErrorRequestHandler, type Request, type Response } from 'express'
import { fileTypeFromBuffer } from 'file-type'
import multer from 'multer'
const app = express()
const maxFileBytes = 5 * 1024 * 1024
const maxRequestBytes = maxFileBytes + 16 * 1024
const allowedTypes = new Set(['image/png', 'image/jpeg'])
async function identifyUpload(request: Request, response: Response): Promise<void> {
if (!request.file || request.file.size === 0) {
response.status(400).json({ error: 'Send one nonempty file in the file field' })
return
}
const type = await fileTypeFromBuffer(request.file.buffer)
if (type === undefined || !allowedTypes.has(type.mime)) {
response.status(415).json({ error: 'Only detected PNG or JPEG files are allowed' })
return
}
response.json({ detectedMime: type.mime, size: request.file.size })
}
app.post(
'/upload',
express.raw({ type: 'multipart/form-data', limit: maxRequestBytes, inflate: false }),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
response.status(415).json({ error: 'Send multipart/form-data' })
return
}
const body = request.body
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: maxFileBytes, files: 1, fields: 0, parts: 1 },
streamHandler: (_request, parser) => parser.end(body),
})
upload.single('file')(request, response, (error: unknown) => {
if (error) return next(error)
void identifyUpload(request, response).catch(() => {
response.status(400).json({ error: 'Unable to identify the file' })
})
})
},
)
const rejectUpload: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
const tooLarge =
(error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') ||
(error instanceof Error && 'type' in error && error.type === 'entity.too.large')
response.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'Upload exceeds byte limit' : 'Malformed upload',
})
}
app.use(rejectUpload)
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = app.listen(port, '127.0.0.1')
server.on('listening', () => {
const address = server.address()
if (address !== null && typeof address !== 'string') {
console.log(`Listening on http://127.0.0.1:${address.port}`)
}
})
server.on('error', () => {
console.error('Could not start the upload server; check PORT and whether it is in use')
process.exitCode = 1
})
Starten Sie den Server im Vordergrund aus demselben Verzeichnis, in dem Sie die Einrichtung ausgeführt haben:
node magic-upload/server.mts
Warten Sie, bis die URL des lauschenden Servers erscheint, bevor Sie Anfragen senden. Wenn Port
3000 belegt ist, setzen Sie PORT beim Ausführen desselben Befehls auf
einen anderen verfügbaren Port und passen Sie die Anfrage-URL entsprechend an. Beenden Sie den
Server mit Ctrl+C; dadurch kehren Sie zur Shell zurück. Der Endpunkt
bindet sich an die Loopback-Adresse, hält Uploads im Arbeitsspeicher und schreibt keine
hochgeladenen Dateien auf die Festplatte.
Implementierung testen
Kehren Sie in einem zweiten Terminal zum Verzeichnis zurück, das magic-upload
enthält. Erstellen Sie eine PNG-Datei mit einem Pixel und dem irreführenden Dateinamen
.bin. Der Schreibvorgang erstellt nur neue Dateien und verweigert das
Überschreiben einer vorhandenen Datei sample.bin. Entfernen Sie diese
Demodatei gezielt, wenn Sie sie neu erstellen möchten.
node --input-type=module -e '
import { writeFileSync } from "node:fs"
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQI12P4z8DwHwAFAAH/cpxSZwAAAABJRU5ErkJggg=="
writeFileSync("magic-upload/sample.bin", Buffer.from(png, "base64"), { flag: "wx" })
'
Laden Sie sie mit einem bewusst falschen MIME-Typ für den Dateiteil hoch:
curl -fsS -F 'file=@magic-upload/sample.bin;type=text/plain' \
http://127.0.0.1:3000/upload
Die Antwort basiert auf den empfangenen Dateibytes, nicht auf .bin oder
text/plain:
{"detectedMime":"image/png","size":70}
Geben Sie nun gewöhnlichen Text als PNG aus. Dieser Befehl gibt den HTTP-Status aus, ohne die erwartete Ablehnung als cURL-Fehler zu behandeln:
printf '%s' 'not an image' | curl -sS -o /dev/null -w '%{http_code}\n' \
-F 'file=@-;filename=avatar.png;type=image/png' http://127.0.0.1:3000/upload
Erwarten Sie 415. Testen Sie beim Anpassen des Endpunkts auch dieses
Verhalten:
- Eine gültige JPEG-Datei mit einem nicht dazu passenden Dateinamen liefert
image/jpegund ihre tatsächliche Dateigröße in Bytes zurück. - Eine fehlende oder leere Datei liefert
400zurück. Ein fehlerhafter Multipart-Anfragekörper oder ein unerwartetes Feld liefert ebenfalls400zurück. - Unbekannte Signaturen und erkannte, aber nicht zugelassene Formate wie GIF oder PDF liefern
415zurück. - Dateiinhalte bis einschließlich 5 MiB bestehen die Größenprüfung; ein Byte mehr führt zu
413. - Ein Anfragekörper bis einschließlich 5 MiB + 16 KiB besteht die Prüfung der Anfragegröße;
ein Byte mehr führt zu
413, selbst wenn die zusätzlichen Bytes auf die letzte Multipart-Begrenzungsmarkierung folgen. Auch unterhalb der jeweiligen Limits gelten weiterhin die Typ- und Formularprüfungen.
Sonderfälle und polyglotte Dateien behandeln
Ein Präfix aus drei Bytes, FF D8 FF, kann als JPEG erkannt werden, ohne ein
vollständiges Bild zu enthalten. Auch ein Bild mit Schäden im späteren Inhalt kann die Prüfung
bestehen. Der Endpunkt meldet bewusst detectedMime, keine erfolgreiche
Decodierung und kein Sicherheitsurteil. Eine polyglotte Datei kann als mehr als ein Format
interpretiert werden. Die Suche nach verdächtigen Byte-Folgen darin ist keine zuverlässige
Erkennungsmethode.
Ergänzen Sie die Signaturerkennung durch einen gepflegten formatspezifischen Decoder, Limits für Bildabmessungen und Pixelanzahl sowie einen Sicherheitsscan oder eine Rekonstruktion des Inhalts, je nach Ihrem Bedrohungsmodell. Führen Sie nicht vertrauenswürdige Parser isoliert und mit verbindlichen Ausführungslimits aus. Das lokale Beispiel tut nichts davon und lehnt nicht jedes fehlerhafte Bild ab.
Speicher- und Sicherheitsgrenzen explizit festhalten
Dies ist eine Demonstration für kleine Uploads, keine Streaming-Pipeline für große Dateien.
Der rohe Anfragekörper und der Dateipuffer von Multer liegen gleichzeitig im Arbeitsspeicher,
zusätzlich zum Aufwand für Parser und Speicherzuweisungen. Ein Dateigrößenlimit begrenzt weder
den gesamten Prozessspeicher noch gleichzeitige Uploads oder die Erkennungszeit. Große Dateien
benötigen einen anderen Speicherpfad und andere Richtlinien. Weder ein Präfix fester Länge noch
eine erfolgreiche Signaturerkennung belegt, dass der Rest eines Uploads geprüft wurde. Diese
Anleitung verwendet durchgehend Node.js; sie erfordert weder Python noch
libmagic.
Magic-Number-Validierung mit weiteren Schutzmaßnahmen kombinieren
Ergänzen Sie vor der Freigabe eines Upload-Endpunkts Authentifizierung, Autorisierung, Limits für
Anfragerate und Parallelität sowie Zeitlimits für Anfragen an der passenden Anwendungs- oder
Proxy-Grenze. Halten Sie Abhängigkeiten mit Sicherheitsupdates aktuell. Wenn Sie Uploads dauerhaft
speichern, generieren Sie Speichernamen, stellen Sie Dateien vor der Verarbeitung unter Quarantäne
und vermeiden Sie es, aktive Inhalte vom Ursprung Ihrer Anwendung auszuliefern. Dies sind separate
Schutzmaßnahmen, keine Eigenschaften von file-type. Siehe die
vollständige Upload-Checkliste von OWASP.
Häufige Probleme beheben
- Keine URL des lauschenden Servers: Prüfen Sie die Startdiagnose,
PORTund ob die Adresse belegt ist. Senden Sie keinen Test-Upload an einen fremden Dienst, der diesen Port bereits verwendet. - Paket nicht gefunden: Schließen Sie die Installation in
magic-uploadab und verwenden Sie den dokumentierten Dateinamen.mts. Ersetzen Sie ESM-Importe nicht durch das CommonJS-Konstruktrequire(). - Unerwartetes
400: Senden Sie genau eine nicht leere Datei namensfileund keine zusätzlichen Formularfelder. Auch eine Ausnahme bei der Erkennung wird gemeldet, ohne den internen Fehler offenzulegen. - Unerwartetes
413: Prüfen Sie neben der Dateigröße auch den gesamten Multipart-Anfragekörper. Teil-Header und Begrenzungsmarkierungen verbrauchen das zusätzliche Kontingent von 16 KiB für die Anfrage.
Behalten Sie die Unterscheidung zwischen „erkannt“ und „validiert“ bei, wenn Sie diesen Endpunkt an eine Speicherung oder Verarbeitung anbinden. Für einen verwalteten Upload-Workflow entdecken Sie unseren Service für Datei-Uploads. Die Richtlinien dafür, was Ihre Anwendung akzeptiert, bleiben in Ihrer Verantwortung.
