Pentest 2026-05-28 LOW 34.5: Backend-Validierung für AppSettings
Schema-Whitelist und Trailing-Slash-Strip für portalLoginUrl standen
NUR im Frontend. Der API-Endpoint nahm sonst /relative/path,
javascript:/ftp:/data:-Schemata und private IPs ungeprüft entgegen –
das landet als toter / bösartiger Link in den an Kunden verschickten
Portal-Mails (Open-Redirect / SSRF-Vektor).
Neuer validateSettingValue(key, value) in appSetting.service mit
per-Key-Logik:
- portalLoginUrl: absolute http(s)-URL, isBlockedSsrfHost-Check
(Cloud-Metadata immer, private Ranges via SSRF_BLOCK_PRIVATE_IPS),
Trailing-Slash-Strip.
- Schwellenwerte (deadline*/documentExpiry*): positive Integer.
- Bool-Settings: strict 'true'/'false'.
- monitoringAlertEmail: RFC-5322-light gegen Header-Injection.
- Andere Keys: kein Format-Check (Default).
Controller (updateSetting + updateSettings) rufen Validator nach
stripHtml; bei Fehler HTTP 400 mit klarer Message. Bulk-PUT
validiert ALLE Werte VOR dem ersten DB-Write – kein halb-committed
State bei einem ungültigen Eintrag.
Live-verifiziert auf dev: alle Test-Payloads aus dem Pentest
sauber abgelehnt, legitime Werte (https-URL, Trailing-Slash, Pfade)
korrekt akzeptiert + normalisiert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,17 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
||||||
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
||||||
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
||||||
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
|
// Schema-spezifische Validierung (URL/Email/Int/Bool). Pentest
|
||||||
|
// 2026-05-28, LOW 34.5: portalLoginUrl nahm `/relative/path` und
|
||||||
|
// `http://192.168.1.1` ungefiltert entgegen → Open-Redirect /
|
||||||
|
// SSRF in der versendeten Mail.
|
||||||
|
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||||
|
if (!validation.ok) {
|
||||||
|
res.status(400).json({ success: false, error: validation.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newValue = validation.value;
|
||||||
|
|
||||||
await appSettingService.setSetting(key, newValue);
|
await appSettingService.setSetting(key, newValue);
|
||||||
|
|
||||||
@@ -102,12 +112,23 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorherige Werte laden für Audit
|
// Vorherige Werte laden für Audit. Validierung erfolgt vor dem
|
||||||
|
// ersten Schreibzugriff, damit ein Bulk-PUT mit einem ungültigen
|
||||||
|
// Wert nicht die anderen Werte halb-committed liegen lässt.
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
|
const sanitizedEntries: Array<{ key: string; oldValue: string; newValue: string }> = [];
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
const oldValue = before?.value ?? '-';
|
||||||
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
|
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||||
|
if (!validation.ok) {
|
||||||
|
res.status(400).json({ success: false, error: `${key}: ${validation.error}` } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitizedEntries.push({ key, oldValue, newValue: validation.value });
|
||||||
|
}
|
||||||
|
for (const { key, oldValue, newValue } of sanitizedEntries) {
|
||||||
if (oldValue !== newValue) {
|
if (oldValue !== newValue) {
|
||||||
changes[key] = { von: oldValue, nach: newValue };
|
changes[key] = { von: oldValue, nach: newValue };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { stripHtml } from '../utils/sanitize.js';
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
import { isBlockedSsrfHost } from '../utils/ssrfGuard.js';
|
||||||
|
|
||||||
// Default settings
|
// Default settings
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
@@ -64,6 +65,85 @@ export function sanitizeSettingValue(key: string, value: string): string {
|
|||||||
return typeof stripped === 'string' ? stripped : String(stripped);
|
return typeof stripped === 'string' ? stripped : String(stripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-spezifische Wert-Validierung VOR dem Speichern. Wird vom
|
||||||
|
* Controller aufgerufen; liefert entweder { ok: true, value: <sanitized> }
|
||||||
|
* oder { ok: false, error: <message> } für 400.
|
||||||
|
*
|
||||||
|
* Hintergrund Pentest 2026-05-28 LOW 34.5: Schema-Whitelist und
|
||||||
|
* Slash-Trimming standen NUR im Frontend, der API-Endpoint nahm
|
||||||
|
* relative URLs (`/evil/path`), `javascript:`-Schemata und Adressen
|
||||||
|
* auf private Hosts (`http://192.168.1.1`) ungeprüft entgegen. Bei
|
||||||
|
* Cloud-Deployment war das ein SSRF-/Open-Redirect-Vektor in der
|
||||||
|
* an Kunden verschickten Mail.
|
||||||
|
*/
|
||||||
|
export function validateSettingValue(key: string, rawValue: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||||
|
// Schwellenwerte: müssen positive ganze Zahlen sein, sonst läuft das
|
||||||
|
// Cockpit in NaN-Vergleichen. Bestehende Validierung war nicht
|
||||||
|
// konsequent.
|
||||||
|
const intKeys = new Set(['deadlineCriticalDays', 'deadlineWarningDays', 'deadlineOkDays', 'documentExpiryCriticalDays', 'documentExpiryWarningDays']);
|
||||||
|
if (intKeys.has(key)) {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
|
return { ok: false, error: `${key} muss eine positive ganze Zahl sein.` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bool-Settings
|
||||||
|
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
|
||||||
|
const trimmed = rawValue.trim().toLowerCase();
|
||||||
|
if (trimmed !== 'true' && trimmed !== 'false') {
|
||||||
|
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email-Settings (Format-Check analog zu Customer/User – verhindert
|
||||||
|
// Header-Injection in System-Mails)
|
||||||
|
if (key === 'monitoringAlertEmail') {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed === '') return { ok: true, value: '' };
|
||||||
|
// RFC-5322-light, gleiches Pattern wie isValidEmail in utils/sanitize
|
||||||
|
if (/[\r\n\t\0\v\f]/.test(trimmed) || trimmed.length > 254) {
|
||||||
|
return { ok: false, error: 'Ungültige E-Mail-Adresse.' };
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(trimmed)) {
|
||||||
|
return { ok: false, error: 'Ungültiges E-Mail-Format.' };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal-Login-URL: nur http/https, keine relativen URLs, keine
|
||||||
|
// Cloud-Metadata/Link-Local/Mcast (ssrfGuard); private Ranges
|
||||||
|
// nur wenn SSRF_BLOCK_PRIVATE_IPS=true. Trailing-Slash strippen.
|
||||||
|
if (key === 'portalLoginUrl') {
|
||||||
|
const trimmed = rawValue.trim().replace(/\/+$/, '');
|
||||||
|
if (trimmed === '') return { ok: true, value: '' };
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Portal-Login-URL muss eine absolute http(s)-URL sein.' };
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return { ok: false, error: `Portal-Login-URL: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` };
|
||||||
|
}
|
||||||
|
if (!parsed.hostname) {
|
||||||
|
return { ok: false, error: 'Portal-Login-URL: Host fehlt.' };
|
||||||
|
}
|
||||||
|
if (isBlockedSsrfHost(parsed.hostname)) {
|
||||||
|
return { ok: false, error: `Portal-Login-URL: Host '${parsed.hostname}' ist gesperrt (interne/Metadata-Adresse).` };
|
||||||
|
}
|
||||||
|
// Werte mit Pfad/Query sind erlaubt – Mail-Versand hängt ohnehin
|
||||||
|
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: kein zusätzlicher Format-Check
|
||||||
|
return { ok: true, value: rawValue };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSetting(key: string): Promise<string | null> {
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
const setting = await prisma.appSetting.findUnique({
|
const setting = await prisma.appSetting.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ 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-28 LOW 34.5: Backend-URL-Validierung für AppSettings**
|
||||||
|
- Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
|
||||||
|
API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`,
|
||||||
|
`ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor
|
||||||
|
in den an Kunden verschickten Portal-Mails.
|
||||||
|
- Neuer `validateSettingValue(key, value)` in appSetting.service.ts
|
||||||
|
mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host,
|
||||||
|
`isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte
|
||||||
|
(deadline*/documentExpiry*) → positive Integer. Bool-Settings
|
||||||
|
→ strict `true`/`false`. monitoringAlertEmail → RFC-5322-light.
|
||||||
|
- Controller (updateSetting + updateSettings) ruft Validator nach
|
||||||
|
der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger
|
||||||
|
Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert
|
||||||
|
wird (kein halb-committed-State bei einem ungültigen Eintrag).
|
||||||
|
- **Live-verifiziert** auf dev:
|
||||||
|
- `/evil/path` → 400 "muss absolute http(s)-URL sein"
|
||||||
|
- `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema)
|
||||||
|
- `ftp://evil.com` / `data:text/html` → 400
|
||||||
|
- `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt)
|
||||||
|
- `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
|
||||||
|
- `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt)
|
||||||
|
- `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc`
|
||||||
|
(nur trailing slash; doppelte slashes mittendrin bleiben)
|
||||||
|
|
||||||
- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
|
- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
|
||||||
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
|
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
|
||||||
`sanitizeContractBody`-Funktion lief auch über `portalPassword`.
|
`sanitizeContractBody`-Funktion lief auch über `portalPassword`.
|
||||||
|
|||||||
Reference in New Issue
Block a user