Compare commits
2 Commits
518139438e
...
25681075b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 25681075b4 | |||
| ad81a7c93e |
@@ -9,7 +9,7 @@
|
||||
* mehrfach aufrufbar.
|
||||
*/
|
||||
import prisma from '../src/lib/prisma.js';
|
||||
import { stripHtml } from '../src/utils/sanitize.js';
|
||||
import { stripHtml, isValidDocumentPath as isValidDocumentPathShared } from '../src/utils/sanitize.js';
|
||||
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||||
|
||||
const CUSTOMER_STRING_FIELDS = [
|
||||
@@ -93,18 +93,10 @@ const ALLOWED_CONSENT_SOURCES = new Set([
|
||||
]);
|
||||
|
||||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
||||
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
||||
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
||||
// raus (Pentest 2026-05-20 LOW 27.1).
|
||||
function isValidDocumentPath(v: string | null | undefined): boolean {
|
||||
if (!v) return true; // null/leer ist OK
|
||||
if (v.includes('..')) return false;
|
||||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||||
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
|
||||
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
|
||||
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
|
||||
}
|
||||
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize – wir
|
||||
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
|
||||
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
|
||||
const isValidDocumentPath = isValidDocumentPathShared;
|
||||
|
||||
async function cleanupConsents() {
|
||||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imap
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||
@@ -1914,8 +1915,11 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveEmailAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||
// Lock) statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2226,8 +2230,10 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { recordPredecessorFinalReading } from '../services/customer.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
@@ -783,7 +784,13 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
|
||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||
// Schreibfehler.
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||
} as ApiResponse);
|
||||
|
||||
@@ -309,6 +309,18 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||
}
|
||||
|
||||
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
|
||||
// mitschicken und damit den Consent-Status auf den initialen System-
|
||||
// Status zurücksetzen. PENDING ist nur intern (Default beim
|
||||
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
|
||||
// Verfälschte sonst die DSGVO-Auswertung.
|
||||
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
|
||||
});
|
||||
}
|
||||
|
||||
const consentLabels: Record<string, string> = {
|
||||
DATA_PROCESSING: 'Datenverarbeitung',
|
||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||
|
||||
/**
|
||||
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
||||
@@ -53,6 +54,8 @@ export async function grantAuthorization(
|
||||
representativeId: number,
|
||||
data: { source?: string; documentPath?: string; notes?: string }
|
||||
) {
|
||||
// Pentest 26.7 (Defense-in-Depth): documentPath nur als /uploads/<safe>.
|
||||
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||
return prisma.representativeAuthorization.upsert({
|
||||
where: {
|
||||
customerId_representativeId: { customerId, representativeId },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ConsentType, ConsentStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||
|
||||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||||
@@ -80,6 +81,12 @@ export async function updateConsent(
|
||||
throw new Error('Kunde nicht gefunden');
|
||||
}
|
||||
|
||||
// Pentest 26.7: documentPath darf nur ein gültiger /uploads/<safe>-Pfad
|
||||
// sein. Aktuell hat KEIN Endpoint diesen Wert aus User-Input gemappt
|
||||
// (Portal: nicht aus Body, Admin-Auth-Upload: server-generated). Diese
|
||||
// Service-Side-Validation ist Defense-in-Depth gegen zukünftige Caller.
|
||||
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||
|
||||
const now = new Date();
|
||||
const updateData = {
|
||||
status: data.status,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import cron from 'node-cron';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { createAuditLog, logChange } from './audit.service.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
|
||||
async function runExpireCheck(): Promise<void> {
|
||||
const today = new Date();
|
||||
@@ -110,7 +111,9 @@ export async function assertNoRecentDuplicateDocument(
|
||||
select: { id: true },
|
||||
});
|
||||
if (recent) {
|
||||
throw new Error('Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt – bitte kurz warten und Seite neu laden.');
|
||||
// Pentest 64.1: ApiError(400) statt generischem Error – Caller
|
||||
// mappt das auf 400 Bad Request statt pauschal 500.
|
||||
throw new ApiError(400, 'Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt – bitte kurz warten und Seite neu laden.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Erlaubt Service-/Helper-Funktionen, einen Fehler mit explizitem HTTP-
|
||||
* Status nach oben zu reichen. Controller können in ihrem `catch` per
|
||||
* `instanceof ApiError` den Status auslesen statt pauschal 500 zu liefern.
|
||||
*
|
||||
* Pentest 64.1 (LOW, 2026-06-02): Race-Lock (assertNoRecentDuplicate-
|
||||
* Document) warf einen generischen Error → catch hat 500 zurückgegeben,
|
||||
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
||||
* angelegt" eindeutig eine 400-Class-Situation ist.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly statusCode: number;
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,27 @@ export function validateContractDocumentType(raw: unknown): string {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
|
||||
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
|
||||
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
|
||||
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
|
||||
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
|
||||
// 1:1 – Single Source of Truth für Lese- UND Schreibpfad.
|
||||
export function isValidDocumentPath(v: string | null | undefined): boolean {
|
||||
if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt
|
||||
if (typeof v !== 'string') return false;
|
||||
if (v.includes('..')) return false;
|
||||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||||
if (/<[a-z!/]/i.test(v)) return false;
|
||||
return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
|
||||
}
|
||||
|
||||
export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
|
||||
if (!isValidDocumentPath(v)) {
|
||||
throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
|
||||
// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie
|
||||
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
|
||||
|
||||
Reference in New Issue
Block a user