159 lines
4.5 KiB
JavaScript
159 lines
4.5 KiB
JavaScript
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(/<!--[\s\S]*?-->/g, "")
|
|
.replace(/<\/?[^>]+>/g, "")
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
}
|
|
|
|
function escapeHtmlAttribute(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.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("</")
|
|
|
|
output += escapeHtml(raw.slice(cursor, match.index))
|
|
|
|
if (ALLOWED_DESCRIPTION_TAGS.has(tagName)) {
|
|
if (isClosing) {
|
|
if (!SELF_CLOSING_DESCRIPTION_TAGS.has(tagName)) {
|
|
const idx = stack.lastIndexOf(tagName)
|
|
if (idx !== -1) {
|
|
for (let i = stack.length - 1; i >= idx; i -= 1) {
|
|
output += `</${stack.pop()}>`
|
|
}
|
|
}
|
|
}
|
|
} else if (tagName === "br") {
|
|
output += "<br>"
|
|
} 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 += `<img src="${escapeHtmlAttribute(attrs.src)}"${altPart}${titlePart}>`
|
|
}
|
|
} else {
|
|
output += `<${tagName}>`
|
|
stack.push(tagName)
|
|
}
|
|
}
|
|
|
|
cursor = tagRegex.lastIndex
|
|
}
|
|
|
|
output += escapeHtml(raw.slice(cursor))
|
|
|
|
while (stack.length) {
|
|
output += `</${stack.pop()}>`
|
|
}
|
|
|
|
const normalized = output.trim()
|
|
return normalized || null
|
|
}
|
|
|
|
module.exports = {
|
|
sanitizeOptionalPlainText,
|
|
sanitizeRequiredPlainText,
|
|
sanitizeDealDescriptionHtml,
|
|
}
|