From bb18a974ddd1134a2e33abf50049d89d68e2832c Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 9 Sep 2026 13:04:13 +0200 Subject: [PATCH] fix: IBANs im Logbuch maskieren --- backend/src/routes/history.ts | 17 +++---- backend/src/utils/history.ts | 7 +-- backend/src/utils/historySanitization.ts | 45 +++++++++++++++++++ backend/tests/historySanitization.test.ts | 55 +++++++++++++++++++++++ 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 backend/src/utils/historySanitization.ts create mode 100644 backend/tests/historySanitization.test.ts diff --git a/backend/src/routes/history.ts b/backend/src/routes/history.ts index 0052c02..5a8c509 100644 --- a/backend/src/routes/history.ts +++ b/backend/src/routes/history.ts @@ -2,6 +2,7 @@ import { FastifyInstance } from "fastify"; import { and, asc, eq, inArray } from "drizzle-orm"; import { authProfiles, historyitems } from "../../db/schema"; +import { sanitizeHistoryItem, sanitizeHistoryText, sanitizeHistoryValue } from "../utils/historySanitization"; const columnMap: Record = { customers: historyitems.customer, @@ -95,7 +96,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { profiles.map((profile) => [profile.user_id, profile]) ); - return data.map((historyitem) => ({ + return data.map((historyitem) => sanitizeHistoryItem({ ...historyitem, created_at: historyitem.createdAt, created_by: historyitem.createdBy, @@ -153,7 +154,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { profiles.map((profile) => [profile.user_id, profile]) ) - const dataCombined = data.map((historyitem) => ({ + const dataCombined = data.map((historyitem) => sanitizeHistoryItem({ ...historyitem, created_at: historyitem.createdAt, created_by: historyitem.createdBy, @@ -223,11 +224,11 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { const inserted = await server.db .insert(historyitems) .values({ - text, + text: sanitizeHistoryText(text), [fkField]: parseId(id), - oldVal: old_val || null, - newVal: new_val || null, - config: config || null, + oldVal: sanitizeHistoryValue(old_val) || null, + newVal: sanitizeHistoryValue(new_val) || null, + config: sanitizeHistoryValue(config) || null, tenant: (req.user as any)?.tenant_id, createdBy: userId }) @@ -238,10 +239,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { return reply.code(500).send({ error: "Failed to create history entry" }); } - return reply.code(201).send({ + return reply.code(201).send(sanitizeHistoryItem({ ...data, created_at: data.createdAt, created_by: data.createdBy - }); + })); }); } diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index e5a37c2..a8c6d6c 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from "fastify" import { historyitems } from "../../db/schema"; +import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization"; const HISTORY_ENTITY_LABELS: Record = { customers: "Kunden", @@ -114,11 +115,11 @@ export async function insertHistoryItem( const entry = { tenant: params.tenant_id, createdBy: params.created_by, - text: params.text || textMap[params.action], + text: sanitizeHistoryText(params.text || textMap[params.action]), action: params.action, [fkColumn]: params.entityId, - oldVal: stringifyHistoryValue(params.oldVal), - newVal: stringifyHistoryValue(params.newVal) + oldVal: stringifyHistoryValue(sanitizeHistoryValue(params.oldVal)), + newVal: stringifyHistoryValue(sanitizeHistoryValue(params.newVal)) } await server.db.insert(historyitems).values(entry as any) diff --git a/backend/src/utils/historySanitization.ts b/backend/src/utils/historySanitization.ts new file mode 100644 index 0000000..e0e012c --- /dev/null +++ b/backend/src/utils/historySanitization.ts @@ -0,0 +1,45 @@ +const IBAN_IN_TEXT_PATTERN = /\b([A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30})(?=["'\]},.;:!?)]|$)/gi + +export function maskIban(iban: string): string { + const normalized = iban.replace(/[\s-]+/g, "").toUpperCase() + if (normalized.length <= 8) return normalized + return `${normalized.slice(0, 4)} **** **** ${normalized.slice(-4)}` +} + +export function sanitizeHistoryText(text: string): string { + return text.replace(IBAN_IN_TEXT_PATTERN, (candidate) => maskIban(candidate)) +} + +function sanitizeIbanField(value: any): any { + if (typeof value === "string") return maskIban(value) + if (Array.isArray(value)) return value.map(sanitizeIbanField) + return sanitizeHistoryValue(value) +} + +export function sanitizeHistoryValue(value: any): any { + if (typeof value === "string") return sanitizeHistoryText(value) + if (Array.isArray(value)) return value.map(sanitizeHistoryValue) + if (!value || typeof value !== "object" || value instanceof Date) return value + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return value + + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + key.toLowerCase().includes("iban") + ? sanitizeIbanField(nestedValue) + : sanitizeHistoryValue(nestedValue), + ]) + ) +} + +export function sanitizeHistoryItem>(item: T): T { + return { + ...item, + text: typeof item.text === "string" ? sanitizeHistoryText(item.text) : item.text, + oldVal: sanitizeHistoryValue(item.oldVal), + newVal: sanitizeHistoryValue(item.newVal), + config: sanitizeHistoryValue(item.config), + } +} diff --git a/backend/tests/historySanitization.test.ts b/backend/tests/historySanitization.test.ts new file mode 100644 index 0000000..ead6011 --- /dev/null +++ b/backend/tests/historySanitization.test.ts @@ -0,0 +1,55 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { + maskIban, + sanitizeHistoryItem, + sanitizeHistoryText, + sanitizeHistoryValue, +} from "../src/utils/historySanitization" + +const IBAN = "DE89370400440532013000" + +test("masks all but the first and last four IBAN characters", () => { + assert.equal(maskIban(IBAN), "DE89 **** **** 3000") + assert.equal(maskIban("DE89 3704 0044 0532 0130 00"), "DE89 **** **** 3000") +}) + +test("masks IBAN values in nested history data", () => { + const sanitized = sanitizeHistoryValue({ + name: "Beispielkunde", + infoData: { + bankingIban: IBAN, + bankingIbans: [IBAN, "AT61 1904 3002 3457 3201"], + }, + }) + + assert.deepEqual(sanitized, { + name: "Beispielkunde", + infoData: { + bankingIban: "DE89 **** **** 3000", + bankingIbans: ["DE89 **** **** 3000", "AT61 **** **** 3201"], + }, + }) +}) + +test("masks IBANs embedded in generated history text", () => { + const text = `Kunden: Info Daten geƤndert von "{\"bankingIbans\":[\"${IBAN}\"]}"` + const sanitized = sanitizeHistoryText(text) + + assert.equal(sanitized.includes(IBAN), false) + assert.equal(sanitized.includes("DE89 **** **** 3000"), true) +}) + +test("sanitizes existing history items before they are returned", () => { + const sanitized = sanitizeHistoryItem({ + text: `IBAN: ${IBAN}.`, + oldVal: JSON.stringify({ bankingIban: IBAN }), + newVal: { iban: IBAN }, + config: null, + }) + + assert.equal(sanitized.text, "IBAN: DE89 **** **** 3000.") + assert.equal(sanitized.oldVal.includes(IBAN), false) + assert.deepEqual(sanitized.newVal, { iban: "DE89 **** **** 3000" }) +})