const { toSafeRedirectUrl } = require("./urlSafety") const ALLOWED_DESCRIPTION_TAGS = new Set(["p", "strong", "ul", "ol", "li", "img", "br"]) const SELF_CLOSING_DESCRIPTION_TAGS = new Set(["img", "br"]) function stripControlChars(value) { return String(value || "").replace(/[\u0000-\u001F\u007F]/g, "") } function stripHtmlTags(value) { return String(value || "") .replace(//g, "") .replace(/<\/?[^>]+>/g, "") } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") } function escapeHtmlAttribute(value) { return String(value || "") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">") } function sanitizeOptionalPlainText(value, { maxLength } = {}) { if (value === undefined || value === null) return null let normalized = stripControlChars(value) normalized = stripHtmlTags(normalized).trim() if (!normalized) return null if (Number.isInteger(maxLength) && maxLength > 0 && normalized.length > maxLength) { normalized = normalized.slice(0, maxLength) } return normalized } function sanitizeRequiredPlainText(value, { fieldName = "field", maxLength } = {}) { const normalized = sanitizeOptionalPlainText(value, { maxLength }) if (!normalized) { const err = new Error(`${fieldName}_REQUIRED`) err.statusCode = 400 throw err } return normalized } function sanitizeImageSrc(value) { const trimmed = stripControlChars(value).trim() if (!trimmed) return null const lower = trimmed.toLowerCase() if ( lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") ) { return null } const isHttp = lower.startsWith("http://") || lower.startsWith("https://") const isProtocolRelative = lower.startsWith("//") const isRootRelative = trimmed.startsWith("/") if (!isHttp && !isProtocolRelative && !isRootRelative) return null return toSafeRedirectUrl(trimmed) } function sanitizeImgTagAttributes(rawAttrs = "") { const attrs = {} const attrRegex = /([a-zA-Z0-9:-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g let match while ((match = attrRegex.exec(rawAttrs)) !== null) { const name = String(match[1] || "").toLowerCase() const rawValue = match[3] ?? match[4] ?? match[5] ?? "" if (name === "src") { const safeSrc = sanitizeImageSrc(rawValue) if (safeSrc) attrs.src = safeSrc continue } if (name === "alt" || name === "title") { const safeText = sanitizeOptionalPlainText(rawValue, { maxLength: 300 }) if (safeText) attrs[name] = safeText } } return attrs } function sanitizeDealDescriptionHtml(value) { if (value === undefined || value === null) return null const raw = stripControlChars(value).trim() if (!raw) return null const tagRegex = /<\/?([a-zA-Z0-9]+)((?:\s+[a-zA-Z0-9:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g const stack = [] let output = "" let cursor = 0 let match while ((match = tagRegex.exec(raw)) !== null) { const [fullTag, rawTagName, rawAttrs = ""] = match const tagName = String(rawTagName || "").toLowerCase() const isClosing = fullTag.startsWith("= idx; i -= 1) { output += `` } } } } else if (tagName === "br") { output += "
" } else if (tagName === "img") { const attrs = sanitizeImgTagAttributes(rawAttrs) if (attrs.src) { const altPart = attrs.alt ? ` alt="${escapeHtmlAttribute(attrs.alt)}"` : "" const titlePart = attrs.title ? ` title="${escapeHtmlAttribute(attrs.title)}"` : "" output += `` } } else { output += `<${tagName}>` stack.push(tagName) } } cursor = tagRegex.lastIndex } output += escapeHtml(raw.slice(cursor)) while (stack.length) { output += `` } const normalized = output.trim() return normalized || null } module.exports = { sanitizeOptionalPlainText, sanitizeRequiredPlainText, sanitizeDealDescriptionHtml, }