Files
opencrm/backend/src/services/provider.service.ts
T
duffyduck f02824fe7d Pentest R89: Provider-Adressfelder härten
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>
2026-06-21 13:35:56 +02:00

158 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } });
}