ESC

Type to search the knowledge base.

Tagged Template Sanitization Idea

Use tagged templates to auto-escape interpolated values for HTML — why it beats string concat, and limits vs real sanitizers.

advanced3 min read
  • javascript
  • tagged-template

XSS often starts as HTML built with string concatenation and untrusted input. Tagged templates receive raw string chunks and interpolated values separately, so you can escape values by default and only allow trusted HTML intentionally.

function escapeHtml(s) {
  return String(s)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function html(strings, ...values) {
  return strings.reduce((out, str, i) => {
    const v = i < values.length ? escapeHtml(values[i]) : '';
    return out + str + v;
  }, '');
}

const user = '<img src=x onerror=alert(1)>';
const out = html`<p>Hello ${user}</p>`;
// <p>Hello &lt;img src=x onerror=alert(1)&gt;</p>

Why the split matters

// Dangerous
el.innerHTML = `<p>Hello ${user}</p>`;

// Tag sees values separately — can force escape
el.innerHTML = html`<p>Hello ${user}</p>`;

The tag function is just a function: first arg is TemplateStringsArray, rest are values.

html(['a', 'b'], user); // same mechanism without sugar — sugar is the point for DX

Trusted raw HTML

Sometimes you need a trusted fragment (Markdown render output you already sanitized). Use a branded type:

class SafeHtml {
  constructor(s) { this.s = s; }
  toString() { return this.s; }
}

function html(strings, ...values) {
  return strings.reduce((out, str, i) => {
    let v = '';
    if (i < values.length) {
      const raw = values[i];
      v = raw instanceof SafeHtml ? raw.s : escapeHtml(raw);
    }
    return out + str + v;
  }, '');
}

const body = new SafeHtml(sanitizedFromDOMPurify);
html`<article>${body}</article>`;

Without a SafeHtml escape hatch, every value is escaped — good default.

Limits (don’t stop at escapeHtml)

Attack surface Simple entity escape
Text in HTML body mostly OK
Attribute contexts need quote-aware rules
javascript: URLs not fixed by <> escape alone
CSS / JS contexts different escaping
SVG / MathML extra rules

For rich HTML from users, run DOMPurify (or server sanitizer) and then mark SafeHtml. Tagged templates are a composition UX, not a full HTML security program.

Libraries (lit-html, Emotion’s older APIs, GraphQL gql) use tagged templates for DSLs. Same idea: parse strings + values into a safer structure than one big string.

// lit-style mental model: return a TemplateResult, not immediately dump to DOM

Interview answer (out loud)

“A tagged template gets string chunks and interpolations separately so I can HTML-escape values by default. That’s safer than concatenating untrusted strings into innerHTML. I still use a real sanitizer for rich HTML and special-case attributes/URLs. SafeHtml branding marks pre-sanitized content.”

Attribute-aware sketch (still incomplete)

function attr(s) {
  return escapeHtml(s).replace(/`/g, '&#96;');
}
// Real systems use context-specific encoders (HTML body vs attr vs URL)

Frameworks that claim XSS safety (React’s default text children, lit’s html tag) implement context tracking. Replicating that in 20 lines is educational — shipping it alone is not.

CSP defense in depth

Even with escaping, Content-Security-Policy without unsafe-inline limits damage if something slips through. Tags + CSP + sanitizer for rich HTML is the layered approach.

Further reading

Related guides