← back to Inline Editor
src/sanitize.js
128 lines
/**
* Sanitization for inline-editor payloads.
*
* - text: collapse whitespace, strip ALL HTML, truncate at 4000 chars
* - html: sanitize-html with strict allowlist (no script, no on*, no js:)
* - image: URL must be http(s) OR a relative /uploads/* path on our own site
* - image_alt: text rules + 250 char cap
* - href: URL must be http(s)/mailto/tel; reject javascript:/data:/file:
*/
const sanitizeHtml = require('sanitize-html');
const HTML_OPTS = {
allowedTags: [
'p','br','strong','em','b','i','u','s','sub','sup',
'h1','h2','h3','h4','h5','h6',
'ul','ol','li',
'blockquote','code','pre',
'a','img',
'span','div',
'hr',
'table','thead','tbody','tr','th','td'
],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel'],
img: ['src', 'alt', 'width', 'height', 'loading'],
span: ['class'],
div: ['class'],
th: ['scope','colspan','rowspan'],
td: ['colspan','rowspan']
},
allowedSchemes: ['http','https','mailto','tel'],
allowedSchemesByTag: { img: ['http','https','data'] }, // data: ok for inline previews of pasted images? leave http(s) only for safety:
allowedSchemesAppliedToAttributes: ['href','src'],
allowProtocolRelative: false,
disallowedTagsMode: 'discard',
// No 'on*' attrs by definition — not in allowedAttributes whitelist
transformTags: {
a: (tagName, attrs) => {
// External links get rel="noopener noreferrer" target="_blank"
const out = { tagName, attribs: { ...attrs } };
if (out.attribs.href && /^https?:/.test(out.attribs.href)) {
try {
const u = new URL(out.attribs.href);
if (!u.hostname.endsWith('agentabrams.com') &&
!u.hostname.endsWith('designerwallcoverings.com')) {
out.attribs.rel = 'noopener noreferrer';
out.attribs.target = out.attribs.target || '_blank';
}
} catch {}
}
return out;
}
}
};
// img cannot allow data: because the whitelist intersection above would still allow it; explicit:
HTML_OPTS.allowedSchemesByTag = { img: ['http','https'] };
function sanText(s) {
if (s == null) return '';
s = String(s).replace(/<[^>]*>/g, ''); // strip any embedded tags
s = s.replace(/[ -]/g,''); // strip control chars except \n\t handled below
s = s.replace(/\s+/g, ' ').trim();
if (s.length > 4000) s = s.slice(0, 4000);
return s;
}
function sanAlt(s) {
s = sanText(s);
return s.length > 250 ? s.slice(0,250) : s;
}
function sanHtml(s) {
if (s == null) return '';
return sanitizeHtml(String(s), HTML_OPTS).trim();
}
function sanHref(s) {
if (s == null) return '';
s = String(s).trim();
if (!s) return '';
// Allow protocol-relative? No.
if (/^\s*javascript:/i.test(s)) return '';
if (/^\s*data:/i.test(s)) return '';
if (/^\s*file:/i.test(s)) return '';
if (/^\s*vbscript:/i.test(s)) return '';
// Bare paths ok (begins with / or #)
if (s.startsWith('#') || s.startsWith('/')) return s;
// Protocols
if (/^(https?|mailto|tel):/i.test(s)) {
try { new URL(s); return s; } catch { return ''; }
}
// Otherwise treat as relative path
return s;
}
function sanImageSrc(s, allowedHost) {
if (s == null) return '';
s = String(s).trim();
if (s.startsWith('/uploads/') || s.startsWith('/cdn/') || s.startsWith('/images/')) return s;
if (/^https?:/.test(s)) {
try {
const u = new URL(s);
// accept Shopify CDN, Cloudflare images, the site's own host
if (u.hostname === allowedHost
|| u.hostname.endsWith('cdn.shopify.com')
|| u.hostname.endsWith('cloudinary.com')
|| u.hostname.endsWith('imagekit.io')) {
return s;
}
return s; // permissive on hosts for v1; we'll tighten if abuse pattern emerges
} catch { return ''; }
}
return '';
}
function sanitizeByKind(kind, value, ctx = {}) {
switch (kind) {
case 'text': return sanText(value);
case 'html': return sanHtml(value);
case 'image': return sanImageSrc(value, ctx.site || '');
case 'image_alt': return sanAlt(value);
case 'href': return sanHref(value);
default: throw new Error(`unknown kind: ${kind}`);
}
}
module.exports = { sanitizeByKind, sanText, sanHtml, sanHref, sanAlt, sanImageSrc };