← 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 };