f02824fe7d
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent slice(0, 500) statt 400, und stripHtml lief vor dem Length- Check – `<script>…</script>` reduzierte auf "" → null in DB → vorheriger Wert silent überschrieben (R87.1-Pattern auf Adress-Feldern). Fix: validateProviderAddress() in sanitize.ts – Raw-Input, max 500 mit ApiError(400), Blacklist <, >, Tab + alle Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt in stripProviderStrings für contactAddress/cancellationAddress. R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst sagt "kein Risiko", sind in Adressen legitim. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
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 <a href={portalUrl}> → 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<T extends object>(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<string, string> = {
|
||
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<string, string> = {
|
||
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 } });
|
||
}
|