Pentest 2026-05-30 LOW 39.3 + INFO 39.4: Magic-Byte-Check + Endung-Normalisierung
Upload-Endpoints (/api/upload/...) hatten denselben Mismatch-Vektor
wie schon die Vollmacht-Route (Pentest 28.3): multer prüft nur den
client-gemeldeten MIME-Type, eine `.php`-Datei mit
Content-Type: image/gif rutschte durch und landete als
`<unique>.gif.php` (Doppel-Endung) auf Disk – kein RCE in unserem
Setup, aber dreckige Datei + Inkonsistenz zwischen geliefertem MIME
und tatsächlichem Inhalt.
Fix: neue validateUploadedFile-Middleware nach upload.single(...) –
- liest die ersten 12 Bytes der gerade geschriebenen Datei
- erkennt PDF/PNG/JPEG/GIF/WebP per Magic-Bytes
- bei Mismatch: Datei löschen + 415 "Datei-Inhalt entspricht keinem
zulässigen Typ"
- benennt die Datei auf eine KANONISCHE Endung (.pdf/.jpg/.png/.gif/
.webp) um, abgeleitet aus dem erkannten Typ (NICHT aus
file.originalname). Damit verschwindet `evil.gif.php` zu
`<unique>.gif` (39.4).
- setzt req.file.mimetype auf den erkannten Type, sodass Controller
konsistente Werte sehen.
Eingehängt in allen 10 upload.single('document')-Routes
(bank-cards, documents, business-registrations, commercial-register,
contract-docs etc.).
Live-verifiziert:
- PHP-Datei als image/gif → 415 + Datei gelöscht
- HTML-Datei als application/pdf → 415 + Datei gelöscht
- WebP-Inhalt mit MIME image/png → 200, gespeichert als .webp
- echtes WebP/JPG → 200 mit kanonischer Endung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,85 @@ function setUploadDir(subDir: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-Upload-Validierung: prüft die Magic-Bytes der gerade geschriebenen
|
||||||
|
* Datei und vergleicht mit dem fileFilter-Whitelist. Bei Mismatch
|
||||||
|
* (Pentest 2026-05-30 LOW 39.3: WebP/GIF/JPG/PDF-Spoofing) wird die
|
||||||
|
* Datei sofort gelöscht + 415 zurück.
|
||||||
|
*
|
||||||
|
* Zusätzlich (39.4): die Datei wird auf eine kanonische Endung umbenannt,
|
||||||
|
* die aus dem ERKANNTEN Typ abgeleitet ist – nicht aus dem
|
||||||
|
* client-gemeldeten file.originalname. Damit verschwindet die
|
||||||
|
* `evil.gif.php`-Doppel-Endung; gespeicherter Name ist
|
||||||
|
* `<timestamp-random>.<canonical-ext>` (z.B. `.pdf` / `.png`).
|
||||||
|
*/
|
||||||
|
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
|
||||||
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const GIF87 = Buffer.from('GIF87a', 'latin1');
|
||||||
|
const GIF89 = Buffer.from('GIF89a', 'latin1');
|
||||||
|
|
||||||
|
function detectType(buf: Buffer): { mime: string; ext: string } | null {
|
||||||
|
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
|
||||||
|
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
|
||||||
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
|
||||||
|
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
|
||||||
|
if (buf.length >= 12
|
||||||
|
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUploadedFile(req: AuthRequest, res: Response, next: Function) {
|
||||||
|
if (!req.file) return next();
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(req.file.path, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const detected = detectType(head);
|
||||||
|
if (!detected) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(415).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename auf kanonische Extension normalisieren. Multer hat
|
||||||
|
// `<unique>.gif.php` o.ä. geschrieben – wir wollen `<unique>.gif`.
|
||||||
|
const dir = path.dirname(req.file.path);
|
||||||
|
const base = path.basename(req.file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
|
||||||
|
const newName = base + detected.ext;
|
||||||
|
const newPath = path.join(dir, newName);
|
||||||
|
if (newPath !== req.file.path) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(req.file.path, newPath);
|
||||||
|
req.file.path = newPath;
|
||||||
|
req.file.filename = newName;
|
||||||
|
} catch (e) {
|
||||||
|
// Rename hat seltene Edge-Cases (Cross-Device). Sicherheit
|
||||||
|
// geht vor – sollte das fehlschlagen, werfen wir lieber 500
|
||||||
|
// und putzen die alte Datei.
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
console.error('Upload-Rename fehlgeschlagen:', e);
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mimetype mit dem ERKANNTEN überschreiben, damit die Controller
|
||||||
|
// den korrekten Typ persistieren (falls sie ihn weiterreichen).
|
||||||
|
req.file.mimetype = detected.mime;
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Magic-Byte-Check fehlgeschlagen:', e);
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upload für Bankkarten-Dokumente
|
// Upload für Bankkarten-Dokumente
|
||||||
router.post(
|
router.post(
|
||||||
'/bank-cards/:id',
|
'/bank-cards/:id',
|
||||||
@@ -76,6 +155,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('bank-cards'),
|
setUploadDir('bank-cards'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -115,6 +195,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('documents'),
|
setUploadDir('documents'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -244,6 +325,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('business-registrations'),
|
setUploadDir('business-registrations'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -292,6 +374,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('commercial-registers'),
|
setUploadDir('commercial-registers'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -422,6 +505,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('privacy-policies'),
|
setUploadDir('privacy-policies'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -679,6 +763,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-letters'),
|
setUploadDir('cancellation-letters'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -696,6 +781,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-confirmations'),
|
setUploadDir('cancellation-confirmations'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -713,6 +799,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-letters-options'),
|
setUploadDir('cancellation-letters-options'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -730,6 +817,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-confirmations-options'),
|
setUploadDir('cancellation-confirmations-options'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -749,6 +837,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('invoices'),
|
setUploadDir('invoices'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
|
|||||||
Reference in New Issue
Block a user