Compare commits
2 Commits
4e05906556
...
48199b0ed7
| Author | SHA1 | Date | |
|---|---|---|---|
| 48199b0ed7 | |||
| bb18a974dd |
@@ -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<string, any> = {
|
||||
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
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { historyitems } from "../../db/schema";
|
||||
import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization";
|
||||
|
||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
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)
|
||||
|
||||
45
backend/src/utils/historySanitization.ts
Normal file
45
backend/src/utils/historySanitization.ts
Normal file
@@ -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<T extends Record<string, any>>(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),
|
||||
}
|
||||
}
|
||||
55
backend/tests/historySanitization.test.ts
Normal file
55
backend/tests/historySanitization.test.ts
Normal file
@@ -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" })
|
||||
})
|
||||
@@ -22,7 +22,7 @@ const props = defineProps({
|
||||
inModal: {
|
||||
type: Boolean,
|
||||
},
|
||||
draggable: {
|
||||
floatingWindow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -531,9 +531,8 @@ const updateItem = async () => {
|
||||
>
|
||||
<template #center>
|
||||
<h1
|
||||
v-if="item"
|
||||
:data-draggable-handle="props.draggable ? '' : undefined"
|
||||
:class="['text-xl', 'font-medium', props.draggable ? 'cursor-move select-none' : '']"
|
||||
v-if="item && !props.floatingWindow"
|
||||
:class="['text-xl', 'font-medium']"
|
||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||
</template>
|
||||
<template #right>
|
||||
@@ -552,8 +551,8 @@ const updateItem = async () => {
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton
|
||||
@pointerdown.stop
|
||||
@click.stop="modal.close()"
|
||||
v-if="!props.floatingWindow"
|
||||
@click="modal.close()"
|
||||
color="red"
|
||||
class="ml-2"
|
||||
icon="i-heroicons-x-mark"
|
||||
|
||||
@@ -33,7 +33,9 @@ const items = ref([])
|
||||
const item = ref({})
|
||||
|
||||
const isDraggableVendorCreate = computed(() => props.type === "vendors" && props.mode === "create")
|
||||
const isDraggableWindowOpen = ref(true)
|
||||
const draggableWindow = ref(null)
|
||||
const draggableWindowHandle = ref(null)
|
||||
const initialWindowPosition = import.meta.client
|
||||
? {
|
||||
x: Math.max(16, (window.innerWidth - Math.min(1024, window.innerWidth - 32)) / 2),
|
||||
@@ -43,12 +45,14 @@ const initialWindowPosition = import.meta.client
|
||||
|
||||
const { style: draggableWindowStyle } = useDraggable(draggableWindow, {
|
||||
initialValue: initialWindowPosition,
|
||||
onStart: (_position, event) => {
|
||||
const target = event.target
|
||||
return target instanceof Element && Boolean(target.closest('[data-draggable-handle]'))
|
||||
},
|
||||
handle: draggableWindowHandle,
|
||||
})
|
||||
|
||||
const closeDraggableWindow = () => {
|
||||
isDraggableWindowOpen.value = false
|
||||
modal.close()
|
||||
}
|
||||
|
||||
const setupPage = async () => {
|
||||
if(props.mode === "show") {
|
||||
//Load Data for Show
|
||||
@@ -79,17 +83,36 @@ setupPage()
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isDraggableVendorCreate"
|
||||
v-if="isDraggableVendorCreate && isDraggableWindowOpen"
|
||||
ref="draggableWindow"
|
||||
:style="draggableWindowStyle"
|
||||
class="fixed z-[999] flex h-[80vh] max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-5xl flex-col overflow-hidden resize rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
ref="draggableWindowHandle"
|
||||
class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-building-storefront" />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Lieferant erstellen</span>
|
||||
</div>
|
||||
<UTooltip text="Schließen">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark"
|
||||
size="xs"
|
||||
@pointerdown.stop
|
||||
@click.stop="closeDraggableWindow"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
<EntityEdit
|
||||
v-if="loaded"
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
:inModal="true"
|
||||
:draggable="true"
|
||||
:floating-window="true"
|
||||
@return-data="(data) => emit('returnData', data)"
|
||||
:createQuery="props.createQuery"
|
||||
:mode="props.mode"
|
||||
@@ -101,7 +124,7 @@ setupPage()
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal v-else :fullscreen="props.mode === 'show'">
|
||||
<UModal v-else-if="!isDraggableVendorCreate" :fullscreen="props.mode === 'show'">
|
||||
<template #content>
|
||||
<EntityShow
|
||||
v-if="loaded && props.mode === 'show'"
|
||||
|
||||
Reference in New Issue
Block a user