AdvancedJavaScript · Lesson 8 of 9

Front-End Security: XSS & CSP

How cross-site scripting happens, safe DOM APIs, sanitising HTML, safe URLs and Content Security Policy.

Cross-site scripting (XSS) happens when text from users or other systems is inserted into a page as HTML, letting an attacker run their own JavaScript — stealing data, acting as the user, or defacing the page. It's the most common front-end vulnerability.

Prevention: insert text with textContent (or setAttribute), never innerHTML, outerHTML, insertAdjacentHTML or document.write with untrusted data. Never pass strings to eval, new Function or setTimeout("code"). If you truly must render user HTML (a rich-text editor), sanitise it with a vetted library such as DOMPurify. Validate URLs before using them in href or src — javascript: URLs run code.

A Content Security Policy (CSP) header is a second line of defence: it tells the browser which scripts may run, blocking inline and third-party scripts an attacker injects. This website itself sends a CSP header.

safe-dom.jsJavaScript
const comment = '<img src=x onerror="alert(document.cookie)"> Great lesson!';
const website = "javascript:alert('hacked')";

const el = document.querySelector("#comments");

// UNSAFE: the browser parses the string as HTML and runs the onerror code
// el.innerHTML = `<p>${comment}</p>`;

// SAFE: build elements and set text
const p = document.createElement("p");
p.textContent = comment;              // shown literally, never executed
el.append(p);

// SAFE URLs: allow only http(s)
function safeUrl(input) {
  try {
    const url = new URL(input, location.origin);
    return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
  } catch {
    return null;
  }
}

const link = document.createElement("a");
const href = safeUrl(website);
link.textContent = href ? "Visit website" : "(invalid link removed)";
if (href) link.href = href;
el.append(link);

// When escaping is needed for a template string
function escapeHTML(text) {
  return String(text).replace(/[&<>"']/g, (ch) => (
    { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]
  ));
}
console.log(escapeHTML(comment));
index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <!-- Only scripts from this site may run; inline <script> and injected handlers are blocked -->
    <meta http-equiv="Content-Security-Policy"
          content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'" />
    <title>Comments</title>
    <script src="safe-dom.js" defer></script>
  </head>
  <body>
    <div id="comments"></div>
  </body>
</html>

Key points

  • Treat all external text as untrusted: use textContent, never innerHTML.
  • Allow only http(s): URLs in links; never eval strings.
  • Sanitise rich HTML with DOMPurify and add a Content Security Policy as backup.

Exercise

Revisit your to-do app and search for every use of innerHTML. Add a CSP meta tag to it, then try adding a task containing <img src=x onerror=alert(1)> and confirm it is displayed as text and nothing runs.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The to-do app already uses textContent everywhere, so there's no innerHTML to fix. Add the CSP meta tag to its <head>: inline scripts and injected event handlers are now blocked even if a mistake slips in later. Then add a task containing an <img onerror> payload and confirm it shows as text and nothing runs.

index.html (head)HTML
<head>
  <meta charset="utf-8" />
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" />
  <title>Tasks</title>
  <script src="app.js" defer></script>
</head>
DevTools consoleJavaScript
// Find risky DOM API calls in the page's scripts (should print nothing for the to-do app)
const RISKY = [/\.innerHTML\s*=/, /\.outerHTML\s*=/, /insertAdjacentHTML\(/, /document\.write\(/, /\beval\(/];
[...document.scripts].forEach(async (s) => {
  if (!s.src) return;
  const code = await (await fetch(s.src)).text();
  for (const pattern of RISKY) {
    if (pattern.test(code)) console.warn(s.src, "matches", pattern);
  }
});

Check your understanding

  1. What is cross-site scripting (XSS)?

  2. Which is safe for displaying a user's comment?

  3. Why check that a URL starts with http(s) before using it as a link?

  4. What does a Content Security Policy add?

Ask AI