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>
147 lines
6.2 KiB
TypeScript
147 lines
6.2 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
})();
|