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:
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user