From 3f0b14a726d459a6f13ac238722d619f8ad5b419 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 11 Jul 2026 18:37:51 +0200 Subject: [PATCH] Harden campaign import handling --- .../features/campaigns/utils/bulkImport.ts | 19 +++++- .../features/campaigns/utils/campaignView.ts | 7 +- .../features/campaigns/utils/draftEditor.ts | 29 ++++++--- .../campaigns/utils/templatePlaceholders.ts | 64 +++++++++++++++---- 4 files changed, 95 insertions(+), 24 deletions(-) diff --git a/webui/src/features/campaigns/utils/bulkImport.ts b/webui/src/features/campaigns/utils/bulkImport.ts index 19405b4..9dbf9c0 100644 --- a/webui/src/features/campaigns/utils/bulkImport.ts +++ b/webui/src/features/campaigns/utils/bulkImport.ts @@ -749,9 +749,22 @@ function parseAddress(value: string): ImportedAddress { } function splitCell(value: string, separators: string): string[] { - const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(""); - if (!escaped) return [value.trim()].filter(Boolean); - return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean); + const separatorSet = new Set(separators.split("")); + if (separatorSet.size === 0) return [value.trim()].filter(Boolean); + const items: string[] = []; + let current = ""; + for (const char of value) { + if (separatorSet.has(char)) { + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + current = ""; + continue; + } + current += char; + } + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + return items; } function parseOptionalBoolean(value: string, fallback: boolean): boolean { diff --git a/webui/src/features/campaigns/utils/campaignView.ts b/webui/src/features/campaigns/utils/campaignView.ts index 419ed16..7c2c887 100644 --- a/webui/src/features/campaigns/utils/campaignView.ts +++ b/webui/src/features/campaigns/utils/campaignView.ts @@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +export function isSafeObjectPathSegment(segment: string): boolean { + return segment !== "__proto__" && segment !== "prototype" && segment !== "constructor"; +} + export function getCampaignJson(version: CampaignVersionDetail | null): Record { return version?.raw_json ?? version?.campaign_json ?? {}; } @@ -179,10 +183,11 @@ export function getString(record: Record, key: string, fallback } export function getNestedString(record: Record, path: string[], fallback = "—"): string { + if (!path.every(isSafeObjectPathSegment)) return fallback; let current: unknown = record; for (const part of path) { if (!isRecord(current)) return fallback; - current = current[part]; + current = Object.getOwnPropertyDescriptor(current, part)?.value; } if (typeof current === "string" && current.trim()) return current; if (typeof current === "number" || typeof current === "boolean") return String(current); diff --git a/webui/src/features/campaigns/utils/draftEditor.ts b/webui/src/features/campaigns/utils/draftEditor.ts index 39fabed..15f51d7 100644 --- a/webui/src/features/campaigns/utils/draftEditor.ts +++ b/webui/src/features/campaigns/utils/draftEditor.ts @@ -1,5 +1,5 @@ import type { CampaignVersionDetail } from "../../../api/campaigns"; -import { asRecord, getCampaignJson, isRecord } from "./campaignView"; +import { asRecord, getCampaignJson, isRecord, isSafeObjectPathSegment } from "./campaignView"; export type DraftPatch = (draft: Record) => Record; @@ -46,19 +46,32 @@ export function updateNested( path: string[], value: unknown ): Record { + if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft); const next = cloneJson(draft); let current: Record = next; - path.forEach((segment, index) => { + for (const [index, segment] of path.entries()) { if (index === path.length - 1) { - current[segment] = value; - return; + Object.defineProperty(current, segment, { + configurable: true, + enumerable: true, + value, + writable: true + }); + break; } - const existing = current[segment]; + const existing = Object.getOwnPropertyDescriptor(current, segment)?.value; if (!isRecord(existing)) { - current[segment] = {}; + Object.defineProperty(current, segment, { + configurable: true, + enumerable: true, + value: {}, + writable: true + }); } - current = current[segment] as Record; - }); + const child = Object.getOwnPropertyDescriptor(current, segment)?.value; + if (!isRecord(child)) return next; + current = child; + } return next; } diff --git a/webui/src/features/campaigns/utils/templatePlaceholders.ts b/webui/src/features/campaigns/utils/templatePlaceholders.ts index 3909fd3..bed6a19 100644 --- a/webui/src/features/campaigns/utils/templatePlaceholders.ts +++ b/webui/src/features/campaigns/utils/templatePlaceholders.ts @@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la } export function isRecipientAddressPlaceholderName(name: string): boolean { - const field = "(?:from|to|reply_to|cc|bcc)"; - return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name) - || new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name) - || new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name); + const normalized = name.trim(); + const allPrefix = "all_"; + const fieldName = recipientAddressFieldFromPlaceholder(normalized); + if (!fieldName) return false; + if (normalized === fieldName || normalized === `${allPrefix}${fieldName}`) return true; + if (["email", "name"].some((suffix) => normalized === `${fieldName}.${suffix}` || normalized === `${allPrefix}${fieldName}.${suffix}`)) return true; + if (isIndexedRecipientAddressPlaceholder(normalized, fieldName)) return true; + return isDottedRecipientAddressPlaceholder(normalized, fieldName); +} + +function recipientAddressFieldFromPlaceholder(value: string): string | null { + const candidates = [...RECIPIENT_ADDRESS_FIELD_IDS].sort((left, right) => right.length - left.length); + for (const field of candidates) { + if (value === field || value.startsWith(`${field}.`) || value.startsWith(`${field}[`)) return field; + const allField = `all_${field}`; + if (value === allField || value.startsWith(`${allField}.`)) return field; + } + return null; +} + +function isPositiveInteger(value: string): boolean { + return value.length > 0 && value[0] !== "0" && [...value].every((char) => char >= "0" && char <= "9"); +} + +function isNonNegativeInteger(value: string): boolean { + return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9"); +} + +function isIndexedRecipientAddressPlaceholder(value: string, fieldName: string): boolean { + if (!value.startsWith(`${fieldName}[`)) return false; + const closeIndex = value.indexOf("]", fieldName.length + 1); + if (closeIndex < 0) return false; + const index = value.slice(fieldName.length + 1, closeIndex); + const suffix = value.slice(closeIndex + 1); + return isPositiveInteger(index) && (suffix === "" || suffix === ".email" || suffix === ".name"); +} + +function isDottedRecipientAddressPlaceholder(value: string, fieldName: string): boolean { + const prefix = `${fieldName}.`; + if (!value.startsWith(prefix)) return false; + const [index, suffix, extra] = value.slice(prefix.length).split("."); + return extra === undefined && isNonNegativeInteger(index) && ["email", "name", "type"].includes(suffix); } export type TemplatePlaceholder = { @@ -211,14 +249,20 @@ export function valueToPreview(value: unknown): string { export function removePlaceholderFromText(text: string, raw: string): string { if (!text) return text; - const escaped = escapeRegExp(raw.trim()); - return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), ""); + return replaceMatchingPlaceholders(text, raw, ""); } export function replacePlaceholderInText(text: string, raw: string, replacement: string): string { if (!text) return text; - const escaped = escapeRegExp(raw.trim()); - return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement); + return replaceMatchingPlaceholders(text, raw, replacement); +} + +function replaceMatchingPlaceholders(text: string, raw: string, replacement: string): string { + const target = raw.trim(); + if (!target) return text; + return text.replace(/\{\{\s*([^}]+?)\s*\}\}|\$\{\s*([^}]+?)\s*\}/g, (match, braceRaw: string | undefined, dollarRaw: string | undefined) => + (braceRaw ?? dollarRaw ?? "").trim() === target ? replacement : match + ); } function fieldOverridePolicy(draft: Record | null): Map { @@ -255,7 +299,3 @@ function previewValueFor(raw: string, context: Record, ignoreEmp if (value !== undefined) return value; return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`; } - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -}