import prisma from '../lib/prisma.js'; import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js'; import { validateHttpUrl } from '../utils/url.js'; // Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl. // PUT akzeptierte `javascript:alert(...)` als URL, das Portal rendert // sie als → ein Klick im Kunden-Browser löst die // XSS aus. Fix: vor Schreiben durch validateHttpUrl, das auch andere // gefährliche Schemata (data:, vbscript:, file:) sperrt und private // IPs verbietet (die URL wird Kunden gezeigt, denen interne Hosts // nichts bringen). function assertValidPortalUrl(portalUrl: string | undefined | null): string | undefined { if (portalUrl == null || portalUrl === '') return undefined; const check = validateHttpUrl(portalUrl, { fieldLabel: 'Portal-URL' }); if (!check.ok) throw new Error(check.error); return check.value === '' ? undefined : check.value; } export async function getAllProviders(includeInactive = false) { const where = includeInactive ? {} : { isActive: true }; return prisma.provider.findMany({ where, orderBy: { name: 'asc' }, include: { tariffs: { where: includeInactive ? {} : { isActive: true }, orderBy: { name: 'asc' }, }, _count: { select: { contracts: true, tariffs: true }, }, }, }); } export async function getProviderById(id: number) { return prisma.provider.findUnique({ where: { id }, include: { tariffs: { orderBy: { name: 'asc' }, }, _count: { select: { contracts: true }, }, }, }); } // Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB. // Aktuell escapt React das Textnode, also kein direkter XSS – aber neue // Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären // sofort betroffen. Defense-in-depth: schon beim Schreiben strippen. // // 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch // isValidEmail (Header-Injection-Schutz für künftige Mail-Templates), // contactPhone/contactFax/cancellationFax durch sanitizePhoneField // (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit // 500-Cap (mehrzeilig, normalisierte Newlines). function stripProviderStrings(data: T): T { const out: any = { ...data }; for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) { if (typeof out[k] === 'string') out[k] = stripHtml(out[k]); } for (const k of ['contactEmail', 'cancellationEmail'] as const) { if (out[k] === '' || out[k] === null) { out[k] = null; continue; } if (out[k] === undefined) continue; const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k]; const value = typeof stripped === 'string' ? stripped.trim() : stripped; if (value === '' ) { out[k] = null; continue; } if (!isValidEmail(value)) { throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`); } out[k] = value; } const phoneLabels: Record = { contactPhone: 'Kontakt-Telefonnummer', contactFax: 'Kontakt-Faxnummer', cancellationFax: 'Kündigungs-Faxnummer', }; for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) { if (out[k] === undefined) continue; if (out[k] === '' || out[k] === null) { out[k] = null; continue; } const v = sanitizePhoneField(out[k], phoneLabels[k]); out[k] = v === undefined ? null : v; } const addressLabels: Record = { contactAddress: 'Kontakt-Postadresse', cancellationAddress: 'Kündigungs-Postadresse', }; for (const k of ['contactAddress', 'cancellationAddress'] as const) { if (out[k] === undefined) continue; // R89.1/R89.2: validateProviderAddress wirft 400 bei Längen- // Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate, // kein silent null-overwrite mehr. out[k] = validateProviderAddress(out[k], addressLabels[k]); } return out; } interface ProviderWritable { name?: string; portalUrl?: string; usernameFieldName?: string; passwordFieldName?: string; contactEmail?: string | null; contactPhone?: string | null; contactFax?: string | null; contactAddress?: string | null; cancellationEmail?: string | null; cancellationFax?: string | null; cancellationAddress?: string | null; isActive?: boolean; } export async function createProvider(data: ProviderWritable & { name: string }) { const clean = stripProviderStrings(data); const portalUrl = assertValidPortalUrl(clean.portalUrl); return prisma.provider.create({ data: { ...clean, name: clean.name, portalUrl, isActive: true, }, }); } export async function updateProvider(id: number, data: ProviderWritable) { // portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert). // Leerstring = "auf null setzen" - hier setzen wir explizit auf null, // damit Prisma nicht den alten Wert hält. const updateData: any = stripProviderStrings(data); if (data.portalUrl !== undefined) { const validated = assertValidPortalUrl(data.portalUrl); updateData.portalUrl = validated ?? null; } return prisma.provider.update({ where: { id }, data: updateData, }); } export async function deleteProvider(id: number) { // Check if provider is used by any contracts const count = await prisma.contract.count({ where: { providerId: id }, }); if (count > 0) { throw new Error( `Anbieter kann nicht gelöscht werden, da er von ${count} Verträgen verwendet wird` ); } return prisma.provider.delete({ where: { id } }); }