import sanitizeHtml from 'sanitize-html' const ALLOWED_REL_VALUES = new Set(['nofollow', 'noopener', 'noreferrer']) const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) const URL_KEYS = new Set(['href', 'url', 'externalUrl']) const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = { allowedTags: [ 'a', 'b', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 's', 'span', 'strong', 'u', 'ul', ], allowedAttributes: { '*': ['class', 'title'], 'a': ['href', 'target', 'rel'], }, allowedSchemes: ['http', 'https', 'mailto', 'tel'], allowProtocolRelative: true, disallowedTagsMode: 'discard', nonTextTags: [ 'script', 'style', 'textarea', 'option', 'iframe', 'object', 'embed', 'form', 'svg', 'math', ], transformTags: { a: (tagName, attribs) => { if (attribs.target !== undefined && !['_blank', '_self'].includes(attribs.target)) { delete attribs.target } if (attribs.rel !== undefined) { const rel = attribs.rel .split(/\s+/) .filter((value) => ALLOWED_REL_VALUES.has(value)) .join(' ') if (rel) attribs.rel = rel else delete attribs.rel } if (attribs.target === '_blank') attribs.rel = 'noopener noreferrer' return { tagName, attribs } }, }, } export function sanitizeRichText(value: string) { return sanitizeHtml(value, RICH_TEXT_OPTIONS) } export function sanitizeUrl(value: string) { try { const parsed = new URL(value, 'https://relative.invalid') return ALLOWED_URL_PROTOCOLS.has(parsed.protocol) ? value : '' } catch { return '' } } export function sanitizePayload(value: unknown, key = ''): unknown { if (typeof value === 'string') { return URL_KEYS.has(key) ? sanitizeUrl(value) : sanitizeRichText(value) } if (Array.isArray(value)) return value.map((item) => sanitizePayload(item)) if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([childKey, childValue]) => [ childKey, sanitizePayload(childValue, childKey), ]) ) } return value }