HotTRDealsBackend/utils/inputSanitizer.js
2026-02-09 21:47:55 +00:00

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
function escapeHtmlAttribute(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
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,
}