Root cause
Injection & unsafe execution introduced
security: add shared backend content sanitizer helper
How AI contributed: Direct introductionPublished May 29, 2026 in
haxtheweb/haxcms-nodejs
Stored XSS bypass in POST /system/api/saveNode: regex-based sanitizer (src/lib/sanitizeContent.js) strips event-handler attrs only when preceded by whitespace (\\s+on[a-z0-9_-]+\\s*=), so href="#"onclick="..." survives; browsers parse onclick as valid handler; payload stored in generated page files.
Root cause
security: add shared backend content sanitizer helper
Fix
The fix treats a quote as well as whitespace as an attribute boundary, allowing the sanitizer to remove adjacent event handlers and unsafe URL-like attributes such as href="#"onclick=....
Case facts
@@ -0,0 +1,69 @@+const UNSAFE_PROTOCOL_RE = /^\s*(?:javascript|vbscript|data\s*:\s*text\/html|data\s*:\s*application\/xhtml\+xml)\s*:/i+const EVENT_ATTRIBUTE_RE = /^on[a-z0-9_-]+$/i+const URL_LIKE_ATTRIBUTE_RE = /(?:^|[-_:])(?:href|src|action|formaction|poster|data|url)(?:$|[-_:])/i++function sanitizeURLValue(value, fallback = '') {+ if (value === null || value === undefined) {+ return fallback+ }+ const stringValue = String(value).trim()+ if (stringValue === '') {+ return fallback+ }+ if (UNSAFE_PROTOCOL_RE.test(stringValue)) {+ return fallback+ }+ return stringValue+}++function sanitizeMetadataValue(value) {+ if (value === null || value === undefined) {+ return ''+ }+ return String(value)+ .replace(/&/g, '&')+ .replace(/</g, '<')+ .replace(/>/g, '>')+ .replace(/"/g, '"')+ .replace(/'/g, ''')+}++function sanitizeHTMLForStorage(html) {+ if (typeof html !== 'string') {+ return ''+ }+ let clean = html+ clean = clean.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')+ clean = clean.replace(/\s+srcdoc\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '')+ clean = clean.replace(/\s+on[a-z0-9_-]+\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '')+ clean = clean.replace(+ /\s+([a-zA-Z_:][a-zA-Z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g,+ function (match, attrName, attrValue, dqValue, sqValue, bareValue) {+ const attributeName = String(attrName).toLowerCase()+ if (EVENT_ATTRIBUTE_RE.test(attributeName)) {+ return ''+ }+ const value = dqValue || sqValue || bareValue || ''+ if (+ URL_LIKE_ATTRIBUTE_RE.test(attributeName) &&+ sanitizeURLValue(value, '') === ''+ ) {+ return ''+ }+ return match+ },+ )+ clean = clean.replace(+ /(<template\b[^>]*>)([\s\S]*?)(<\/template>)/gi,+ function (match, openTag, inner, closeTag) {+ return openTag + sanitizeHTMLForStorage(inner) + closeTag+ },+ )+ return clean+}++module.exports = {+ sanitizeHTMLForStorage,+ sanitizeURLValue,+ sanitizeMetadataValue,+}@@ -47,21 +47,21 @@ function sanitizeHTMLForStorage(html) { } let clean = html clean = clean.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')- clean = clean.replace(/\s+srcdoc\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '')- clean = clean.replace(/\s+on[a-z0-9_-]+\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '')+ clean = clean.replace(/(\s+|["'])srcdoc\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '$1')+ clean = clean.replace(/(\s+|["'])on[a-z0-9_-]+\s*=\s*(".*?"|'.*?'|[^\s>]+)/gi, '$1') clean = clean.replace(- /\s+([a-zA-Z_:][a-zA-Z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g,- function (match, attrName, attrValue, dqValue, sqValue, bareValue) {+ /(\s+|["'])([a-zA-Z_:][a-zA-Z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g,+ function (match, attributeBoundary, attrName, attrValue, dqValue, sqValue, bareValue) { const attributeName = String(attrName).toLowerCase() if (EVENT_ATTRIBUTE_RE.test(attributeName)) {- return ''+ return attributeBoundary } const value = dqValue || sqValue || bareValue || '' if ( URL_LIKE_ATTRIBUTE_RE.test(attributeName) && sanitizeURLValue(value, '') === '' ) {- return ''+ return attributeBoundary } return match },AI-assisted change 44375c5b37af4b4804120f79974653ff1b884f95b8dd377569c0bc600f3a9b12 · Fix 7460e1449ac00bf663d646f1f8c9cbc42aabc67f532dc1c67fa6b4a9397680e4