Pentest R138 Hygiene + neue Vorgaben: 1) endDate wird bei DRAFT-Vertraegen NICHT mehr gesetzt (Entwurf = nur Vorlage). Nur Status wurde vorher geschont, endDate zog trotzdem mit. 2) Ueberweisungsbelege (credit-note-receipts) sind jetzt reine Mitarbeiter/Admin-Downloads: neuer FileOwner-kind 'contract-staff' blockt Portal-Kunden im fileDownload-Controller. Das generierte Gutschrift-PDF (credit-notes) bleibt vertragsbasiert -> der besitzende Kunde darf seine eigene Gutschrift laden. Bereits vorher abgesichert (bestaetigt): Kunden koennen keine Gutschriften anlegen (blockPortal) und keine Belege hochladen (Portal-403 im Upload). Verifiziert: DRAFT haelt endDate; Beleg-Owner=contract-staff, PDF-Owner=contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.9 KiB
TypeScript
166 lines
6.9 KiB
TypeScript
import { Response } from 'express';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import { AuthRequest } from '../types/index.js';
|
||
import { findUploadOwner } from '../services/fileDownload.service.js';
|
||
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
|
||
|
||
/**
|
||
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
|
||
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
|
||
*
|
||
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
|
||
*
|
||
* Schritte:
|
||
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
|
||
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
|
||
* 3. canAccessCustomer / canAccessContract / Permission-Check
|
||
* 4. Datei senden (mit korrektem Content-Type)
|
||
*
|
||
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
|
||
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
|
||
* vertretenen Kunden mit Vollmacht) herunterladen – nicht mehr beliebige
|
||
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
|
||
* mitgeschnitten hätte.
|
||
*/
|
||
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
|
||
const requested = typeof req.query.path === 'string' ? req.query.path : '';
|
||
if (!requested) {
|
||
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
|
||
return;
|
||
}
|
||
|
||
// Format-Validierung (Traversal-Schutz)
|
||
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
|
||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||
return;
|
||
}
|
||
|
||
// Owner ermitteln
|
||
const owner = await findUploadOwner(requested);
|
||
if (!owner) {
|
||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||
return;
|
||
}
|
||
|
||
// Access-Check je nach Owner-Typ
|
||
if (owner.kind === 'customer') {
|
||
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||
} else if (owner.kind === 'contract') {
|
||
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||
} else if (owner.kind === 'contract-staff') {
|
||
// Nur Mitarbeiter/Admin – Portal-Kunden nie (z.B. Gutschrift-
|
||
// Überweisungsbelege). Staff dürfen wie bei allen Vertrags-Files.
|
||
if (req.user?.isCustomerPortal) {
|
||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||
return;
|
||
}
|
||
} else if (owner.kind === 'admin') {
|
||
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||
const perms = req.user?.permissions || [];
|
||
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
|
||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||
return;
|
||
}
|
||
} else if (owner.kind === 'gdpr-admin') {
|
||
const perms = req.user?.permissions || [];
|
||
if (!perms.includes('gdpr:admin')) {
|
||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Datei vom Disk lesen
|
||
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
|
||
const relative = requested.substring('/uploads/'.length);
|
||
const absolute = path.join(process.cwd(), 'uploads', relative);
|
||
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
|
||
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
|
||
if (!absolute.startsWith(uploadsRoot)) {
|
||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||
return;
|
||
}
|
||
if (!fs.existsSync(absolute)) {
|
||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||
return;
|
||
}
|
||
|
||
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
|
||
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
|
||
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
|
||
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
||
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
||
// Extension – also `text/html` – und der Browser hätte das als
|
||
// Stored-XSS gerendert.
|
||
//
|
||
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||
//
|
||
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||
const wantsInline = req.query.disposition === 'inline';
|
||
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||
|
||
if (wantsInline && !safeContentType) {
|
||
console.warn(
|
||
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||
);
|
||
}
|
||
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
if (safeContentType) {
|
||
res.setHeader('Content-Type', safeContentType);
|
||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||
} else {
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||
}
|
||
res.sendFile(absolute);
|
||
}
|
||
|
||
/**
|
||
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||
*
|
||
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||
*/
|
||
function detectSafeContentType(absolute: string): string | null {
|
||
let fd: number | null = null;
|
||
try {
|
||
fd = fs.openSync(absolute, 'r');
|
||
const head = Buffer.alloc(12);
|
||
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||
if (bytesRead >= 8
|
||
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||
) return 'image/png';
|
||
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||
if (bytesRead >= 6
|
||
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||
) return 'image/gif';
|
||
if (bytesRead >= 12
|
||
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||
) return 'image/webp';
|
||
return null;
|
||
} catch (err) {
|
||
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||
return null;
|
||
} finally {
|
||
if (fd !== null) {
|
||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
}
|