Escape output, never innerHTML

Let the framework escape; sanitize HTML with an allow-list.

From security-best-practices: no eval(), no innerHTML.

Same string, two sinks

Type or pick a payload.

el.innerHTML = inputBad
0
DOM it would build
    el.textContent = inputGood
    01 text node
    guestjust now

    comment.textContent = userInput;
    
    <p>{userInput}</p>         // React
    <p>{{ userInput }}</p>     // Vue

    Parse, then allow-list

    Pick a payload, then change the allow-list.

    Regex blacklistBad
    0
    Output
      .replace(/<script[\s\S]*?<\/script>/gi, "").replace(/javascript:/gi, "")
      Parse + allow-listGood
      0
        Output
        Renders as
        import DOMPurify from 'isomorphic-dompurify';
        
        const sanitized = DOMPurify.sanitize(userInput, {
          ALLOWED_TAGS: ['p', 'b', 'strong', 'i', 'em', 'a', 'ul', 'li', 'br'],
          ALLOWED_ATTR: ['href'],
        });
        
        // only ever sanitized output
        <div dangerouslySetInnerHTML={{ __html: sanitized }} />

        A calculator that runs code

        Type or pick an expression.

        eval(input)Bad
        Would execute
        eval()

        calc(input)Good
        Parsed

        0-9 . + - * / % ( )
        function calc(src) {
          let i = 0;
          const skip = () => { while (/\s/.test(src[i] ?? "")) i++; };
          const fail = () => { throw new SyntaxError(`Unexpected "${src[i] ?? "end"}" at ${i}`); };
          const factor = () => {
            skip();
            if (src[i] === "-") { i++; return -factor(); }
            if (src[i] === "(") { i++; const v = sum(); skip(); if (src[i] !== ")") fail(); i++; return v; }
            const m = /^\d*\.?\d+/.exec(src.slice(i));
            if (!m) fail();
            i += m[0].length;
            return Number(m[0]);
          };
          const product = () => { let v = factor(); for (skip(); "*/%".includes(src[i] || "_"); skip()) { const op = src[i++]; const r = factor(); v = op === "*" ? v * r : op === "/" ? v / r : v % r; } return v; };
          const sum = () => { let v = product(); for (skip(); "+-".includes(src[i] || "_"); skip()) { const op = src[i++]; const r = product(); v = op === "+" ? v + r : v - r; } return v; };
          const v = sum(); skip();
          if (i < src.length) fail();
          return v;
        }

        Why it matters, pros and cons

        Why it matters

        Once markup and user text share a string, the browser can't tell them apart.

        Pros

        • Framework escaping is free and default.
        • Kills the bug class, not one payload.
        • One rule a linter can grep for.

        Cons

        • textContent does not make an href safe.
        • A sanitizer adds bundle weight and parse time.
        • Editing sanitized output can rebuild a payload.