Pentester-Helfer: Seed-Script fuer Magic-Byte-Mismatch-Test (R152)

backend/scripts/seed-magic-byte-test.ts legt eine dedizierte TEST-Bankkarte
(Marker im accountHolder) mit einer getarnten Datei an: .pdf-Endung, aber
SVG-mit-<script>-Inhalt (Non-Whitelist-Magic-Byte, Stored-XSS-Payload).
Damit kann der Pentester den Magic-Byte-Mismatch->attachment-Zweig des
Download-Endpoints live ausloesen (auf Staging fehlte bisher ein
Non-Whitelist-Upload). Echte Kundendaten werden nicht angefasst; cleanup
entfernt Karte + Datei.

Modi: create [--customer <id>] | cleanup.

Verifiziert (create -> Controller-Integrationstest -> cleanup): inline
angefragt -> Content-Disposition attachment + nosniff (nicht inline) + Log;
ohne disposition -> attachment; fremder Portal-User -> 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 13:31:08 +02:00
co-authored by Claude Opus 4.8
parent 06d51cf680
commit 2ea211cca0
+146
View File
@@ -0,0 +1,146 @@
/**
* Pentester-Hilfsscript (Pentest R152): legt einen KONTROLLIERTEN „getarnten"
* Upload an, um den Magic-Byte-Mismatch→attachment-Zweig des Download-Endpoints
* live zu belegen.
*
* Was passiert:
* - Es wird eine dedizierte TEST-Bankkarte (accountHolder-Marker) angelegt
* ECHTE Kundendaten werden NICHT angefasst/überschrieben.
* - An diese Karte wird eine Datei mit `.pdf`-Endung gehängt, deren Inhalt
* aber ein SVG mit <script> ist (Non-Whitelist-Magic-Byte, klassischer
* Stored-XSS-Payload).
* - documentPath wird exakt gesetzt, damit findUploadOwner die Datei der
* Test-Bankkarte (→ Customer) zuordnet und der Ownership-Check greift.
*
* Erwartetes Verhalten am Endpoint
* GET /api/files/download?path=<pfad>&disposition=inline :
* → HTTP 200, aber `Content-Disposition: attachment` (NICHT inline),
* `X-Content-Type-Options: nosniff` → Browser lädt herunter, rendert
* NICHT im iframe → kein Stored-XSS.
* → Backend-Log: "[fileDownload] inline angefragt, aber Magic-Byte-Check
* fehlgeschlagen: <pfad>"
* Ohne `disposition=inline` ohnehin attachment (Default).
* Als fremder Portal-User: 403 (Ownership-Check, Karte gehört Test-Customer).
*
* Aufruf (im Backend-App-Verzeichnis / Container-WORKDIR /app):
* npx tsx scripts/seed-magic-byte-test.ts create [--customer <id>]
* npx tsx scripts/seed-magic-byte-test.ts cleanup
*
* Danach unbedingt `cleanup` laufen lassen (löscht Datei + Test-Bankkarte).
*/
import fs from 'fs';
import path from 'path';
import prisma from '../src/lib/prisma.js';
const MARKER = 'MAGICBYTE-TEST (Pentester R152)';
const FILENAME = 'magic-byte-test.pdf'; // .pdf-Endung, aber SVG-Inhalt (getarnt)
const UPLOAD_SUBDIR = 'bank-cards';
const RELATIVE_PATH = `/uploads/${UPLOAD_SUBDIR}/${FILENAME}`;
// SVG mit <script> würde bei inline-Auslieferung als image/svg+xml im Browser
// ausgeführt. Genau das muss der Magic-Byte-Gate verhindern.
const SVG_XSS = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
<text x="10" y="40">MAGICBYTE-TEST</text>
<script type="text/javascript">/* Pentest R152 */ alert('XSS-MAGICBYTE-TEST');</script>
</svg>
`;
function uploadsDir(): string {
return path.join(process.cwd(), 'uploads', UPLOAD_SUBDIR);
}
function absoluteFilePath(): string {
return path.join(uploadsDir(), FILENAME);
}
async function create(customerIdArg?: number): Promise<void> {
// Ziel-Customer bestimmen (Default: erster vorhandener Kunde).
const customer = customerIdArg
? await prisma.customer.findUnique({ where: { id: customerIdArg }, select: { id: true, customerNumber: true } })
: await prisma.customer.findFirst({ orderBy: { id: 'asc' }, select: { id: true, customerNumber: true } });
if (!customer) {
console.error('Kein Kunde gefunden bitte zuerst einen Kunden anlegen oder --customer <id> angeben.');
process.exit(1);
}
// Getarnte Datei schreiben.
fs.mkdirSync(uploadsDir(), { recursive: true });
fs.writeFileSync(absoluteFilePath(), SVG_XSS, 'utf8');
// Test-Bankkarte anlegen oder wiederverwenden (idempotent, kein Duplikat).
const existing = await prisma.bankCard.findFirst({ where: { accountHolder: MARKER } });
const card = existing
? await prisma.bankCard.update({ where: { id: existing.id }, data: { customerId: customer.id, documentPath: RELATIVE_PATH } })
: await prisma.bankCard.create({
data: {
customerId: customer.id,
accountHolder: MARKER,
iban: 'DE00000000000000000000',
documentPath: RELATIVE_PATH,
description: 'Pentest R152 getarnte Datei (SVG-als-PDF). Nach Test via cleanup entfernen.',
},
});
const head = Buffer.alloc(12);
const fd = fs.openSync(absoluteFilePath(), 'r');
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
console.log('=== Magic-Byte-Test angelegt ===');
console.log('Test-Customer :', customer.customerNumber, `(id ${customer.id})`);
console.log('Test-Bankkarte : id', card.id, ` accountHolder="${MARKER}"`);
console.log('Datei (Disk) :', absoluteFilePath());
console.log('Erste 12 Bytes :', JSON.stringify(head.toString('latin1')), '(kein PDF/PNG/JPEG/GIF/WebP-Magic)');
console.log('documentPath :', RELATIVE_PATH);
console.log('');
console.log('--- So testen (Token des berechtigten Users anhängen) ---');
console.log(` curl -sSI "https://<host>/api/files/download?path=${RELATIVE_PATH}&disposition=inline&token=<JWT>"`);
console.log(' Erwartet: 200, Content-Disposition: attachment, X-Content-Type-Options: nosniff');
console.log(' (NICHT inline) + Backend-Log "Magic-Byte-Check fehlgeschlagen".');
console.log(` Ohne &disposition=inline: ebenfalls attachment.`);
console.log(' Als fremder Portal-User: 403 (Ownership-Check).');
console.log('');
console.log('>>> Nach dem Test aufräumen: npx tsx scripts/seed-magic-byte-test.ts cleanup');
}
async function cleanup(): Promise<void> {
let removed = 0;
const cards = await prisma.bankCard.findMany({ where: { accountHolder: MARKER } });
for (const c of cards) {
await prisma.bankCard.delete({ where: { id: c.id } });
removed++;
}
// Datei löschen (falls vorhanden).
const abs = absoluteFilePath();
let fileDeleted = false;
if (fs.existsSync(abs)) {
fs.unlinkSync(abs);
fileDeleted = true;
}
console.log('=== Cleanup ===');
console.log('Gelöschte Test-Bankkarten:', removed);
console.log('Datei gelöscht :', fileDeleted ? abs : '(nicht vorhanden)');
}
(async () => {
const mode = process.argv[2] || 'create';
const custIdx = process.argv.indexOf('--customer');
const customerIdArg = custIdx >= 0 ? parseInt(process.argv[custIdx + 1], 10) : undefined;
try {
if (mode === 'create') {
await create(Number.isFinite(customerIdArg as number) ? customerIdArg : undefined);
} else if (mode === 'cleanup') {
await cleanup();
} else {
console.error(`Unbekannter Modus "${mode}". Nutze: create | cleanup`);
process.exit(1);
}
} catch (err) {
console.error('Fehler:', err instanceof Error ? err.message : err);
process.exit(1);
} finally {
await prisma.$disconnect();
}
})();