Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)
28.1 URI-Schema unvollstaendig: DANGEROUS_URI_SCHEMES erweitert um file:/ftp: – "ftp://evil.com/x.js" und "file:///etc/passwd" wurden vorher in companyName akzeptiert. 28.2 HTML-Entity-Decoding-Bypass: stripHtml() lief direkt ueber den Roh-String, "javascript:", "<script>" und "<script>" umgingen die Regex. decodeHtmlEntities() dekodiert jetzt numerische (decimal+hex) + gaengige named entities VOR dem Tag-/URI-Strip. 28.3 Vollmacht-Upload Magic-Byte-Check: multer pruefte nur client-MIME, HTML/PHP/Shell-Scripts kamen als application/pdf durch. uploadAuthorizationDocument liest jetzt die ersten 5 Bytes und verlangt "%PDF-", sonst Loeschen + 400. 28.4 Rate-Limit auf /api/public/consent: 30 Requests pro IP pro 15min. Brute-Force-sicher war der 128-bit- UUID-Hash schon, aber ohne Limit konnte ein Angreifer das System mit Audit-Log- und Mail-Spam belasten. Live-verifiziert auf dev: alle vier Bypaesse blockiert, legitime Eingaben unangetastet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -891,6 +891,33 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
return res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magic-Byte-Check: multer prüft nur den client-gemeldeten MIME-Type,
|
||||||
|
// ein Angreifer kann beliebige Daten als "application/pdf" hochladen.
|
||||||
|
// PDF beginnt mit "%PDF-" (0x25 0x50 0x44 0x46 0x2D). Wenn nicht,
|
||||||
|
// gleich wegwerfen. (Pentest 2026-05-20 LOW 28.3)
|
||||||
|
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(req.file.path, 'r');
|
||||||
|
const head = Buffer.alloc(5);
|
||||||
|
fs.readSync(fd, head, 0, 5, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
if (!head.equals(PDF_MAGIC)) {
|
||||||
|
// Hochgeladene Nicht-PDF-Datei sofort wieder löschen, sonst
|
||||||
|
// bleibt der Müll im uploads/-Volume.
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Datei ist keine gültige PDF (Magic-Bytes fehlen).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Hochgeladene Datei konnte nicht gelesen werden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const documentPath = `/uploads/authorizations/${req.file.filename}`;
|
const documentPath = `/uploads/authorizations/${req.file.filename}`;
|
||||||
const auth = await authorizationService.updateAuthorizationDocument(
|
const auth = await authorizationService.updateAuthorizationDocument(
|
||||||
customerId,
|
customerId,
|
||||||
|
|||||||
@@ -81,3 +81,26 @@ export const passwordResetRateLimiter = rateLimit({
|
|||||||
res.status(options.statusCode).json(options.message);
|
res.status(options.statusCode).json(options.message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
||||||
|
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
||||||
|
* aber DoS-Vektor: ohne Limit könnte ein Angreifer endlos POSTen und
|
||||||
|
* den Service durch Audit-Log-Spam + Mail-Versand belasten.
|
||||||
|
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||||||
|
* für legitime Kunden weit aus.
|
||||||
|
*/
|
||||||
|
export const publicConsentRateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 30,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Zu viele Anfragen. Bitte in 15 Minuten erneut versuchen.',
|
||||||
|
},
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('public-consent', 'MEDIUM')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as controller from '../controllers/consent-public.controller.js';
|
import * as controller from '../controllers/consent-public.controller.js';
|
||||||
|
import { publicConsentRateLimiter } from '../middleware/rateLimit.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Öffentliche Routes - KEINE Authentifizierung erforderlich
|
// Öffentliche Routes - KEINE Authentifizierung erforderlich.
|
||||||
|
// Rate-Limit gegen DoS – siehe publicConsentRateLimiter
|
||||||
|
// (Pentest 2026-05-20 INFO 28.4).
|
||||||
|
router.use(publicConsentRateLimiter);
|
||||||
router.get('/:hash', controller.getConsentPage);
|
router.get('/:hash', controller.getConsentPage);
|
||||||
router.post('/:hash/grant', controller.grantAllConsents);
|
router.post('/:hash/grant', controller.grantAllConsents);
|
||||||
router.get('/:hash/pdf', controller.getConsentPdf);
|
router.get('/:hash/pdf', controller.getConsentPdf);
|
||||||
|
|||||||
@@ -241,16 +241,42 @@ const USER_CREATE_FIELDS = [
|
|||||||
* companyName landete vorher ungefiltert in der DB.
|
* companyName landete vorher ungefiltert in der DB.
|
||||||
*
|
*
|
||||||
* Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata
|
* Pentest 2026-05-20 (LOW): zusätzlich werden Skript-URI-Schemata
|
||||||
* unschädlich gemacht (`javascript:`, `data:`, `vbscript:`). Plain-Text-
|
* unschädlich gemacht (`javascript:`, `data:`, `vbscript:`, `file:`,
|
||||||
* Felder enthalten legitime URLs ohnehin selten; ein gespeicherter
|
* `ftp:`). Plain-Text-Felder enthalten legitime URLs ohnehin selten;
|
||||||
* `javascript:alert(1)` würde ansonsten in einem `<a href={value}>`
|
* ein gespeicherter `javascript:alert(1)` würde ansonsten in einem
|
||||||
* sofort feuern.
|
* `<a href={value}>` sofort feuern. `file:` + `ftp:` ergänzt nach
|
||||||
|
* Pentest 28.1 – kein direkter XSS, aber Data-Exfil/Lokalpfad-Vektor.
|
||||||
|
*
|
||||||
|
* Pentest 28.2: HTML-Entity-Decoding VOR dem Strip, sonst umgehen
|
||||||
|
* `javascript:` und `<script>` die Regex.
|
||||||
*/
|
*/
|
||||||
const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript)\s*:/gi;
|
const DANGEROUS_URI_SCHEMES = /(?:javascript|data|vbscript|file|ftp)\s*:/gi;
|
||||||
|
|
||||||
|
function decodeHtmlEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
// Numeric decimal: j → 'j'
|
||||||
|
.replace(/&#(\d+);?/g, (_m, code) => {
|
||||||
|
const n = parseInt(code, 10);
|
||||||
|
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||||||
|
})
|
||||||
|
// Numeric hex: j → 'j'
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, code) => {
|
||||||
|
const n = parseInt(code, 16);
|
||||||
|
return n >= 0 && n <= 0x10FFFF ? String.fromCodePoint(n) : '';
|
||||||
|
})
|
||||||
|
// Häufige Named-Entities (bewusst klein gehalten – wir wollen nur
|
||||||
|
// XSS-relevante Bypässe verhindern, nicht jeden Entity-Sonderfall).
|
||||||
|
// & ZULETZT, sonst doppel-dekodiert (`&lt;` → `<`).
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/&#?apos;/gi, "'")
|
||||||
|
.replace(/&/gi, '&');
|
||||||
|
}
|
||||||
|
|
||||||
export function stripHtml(value: unknown): unknown {
|
export function stripHtml(value: unknown): unknown {
|
||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
return value
|
return decodeHtmlEntities(value)
|
||||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<\/?[a-z][^>]*>/gi, '')
|
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||||
|
|||||||
@@ -120,6 +120,40 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||||||
|
|
||||||
|
- [x] **🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)**
|
||||||
|
- **28.1 URI-Schema unvollständig**: `DANGEROUS_URI_SCHEMES`-Regex
|
||||||
|
erweitert um `file:` und `ftp:`. `ftp://evil.com/x.js` und
|
||||||
|
`file:///etc/passwd` in companyName werden jetzt zu
|
||||||
|
`blocked://...` bzw. `blocked:///...` neutralisiert.
|
||||||
|
- **28.2 HTML-Entity-Decoding-Bypass**: `stripHtml` lief vorher
|
||||||
|
direkt über den Roh-String, sodass `javascript:`,
|
||||||
|
`<script>` und `<script>` an der Regex
|
||||||
|
vorbeischlüpften. Neuer `decodeHtmlEntities()` läuft VOR dem
|
||||||
|
Strip und dekodiert numerische (`&#NN;`/`&#xHH;`) + gängige
|
||||||
|
Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale
|
||||||
|
Tag- und URI-Säuberung wieder.
|
||||||
|
- **28.3 Vollmacht-Upload Magic-Byte-Check**: multer prüfte nur
|
||||||
|
den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als
|
||||||
|
`application/pdf` kam durch. Neuer Check liest die ersten 5
|
||||||
|
Bytes nach dem Upload und verlangt `%PDF-` – sonst wird die
|
||||||
|
Datei gelöscht und 400 geliefert. Greift bevor irgendwas in
|
||||||
|
der DB landet.
|
||||||
|
- **28.4 Rate-Limit auf /api/public/consent**: 30 Requests pro
|
||||||
|
IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash
|
||||||
|
schon, aber ohne Limit konnte ein Angreifer das System per
|
||||||
|
POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer
|
||||||
|
`publicConsentRateLimiter` greift jetzt auf alle drei
|
||||||
|
Sub-Routes (`/:hash`, `/:hash/grant`, `/:hash/pdf`).
|
||||||
|
- **Live-verifiziert** auf dev:
|
||||||
|
- `ftp://evil.com/x.js` → `blocked://evil.com/x.js`
|
||||||
|
- `file:///etc/passwd` → `blocked:///etc/passwd`
|
||||||
|
- `javascript:alert(1)` → `blocked:alert(1)`
|
||||||
|
- `<script>alert(1)</script>OK` → `OK`
|
||||||
|
- `<script>bad()</script>Legit` → `Legit`
|
||||||
|
- HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
|
||||||
|
- Echtes PDF (Magic-Bytes ok) → 200
|
||||||
|
- 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429
|
||||||
|
|
||||||
- [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix**
|
- [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix**
|
||||||
- **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte
|
- **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte
|
||||||
`documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`
|
`documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`
|
||||||
|
|||||||
Reference in New Issue
Block a user