hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Web Security Fundamentals with @hectoday/http

The Attacker's Mindset

  • Thinking Like an Attacker
  • Project Setup

Injection Attacks

  • SQL Injection
  • SQL Injection: Beyond the Basics
  • Command Injection
  • Header Injection

Cross-Site Scripting (XSS)

  • What Is XSS?
  • Output Encoding
  • Content Security Policy in Practice

Broken Access and Redirects

  • Insecure Direct Object References (IDOR)
  • Open Redirects
  • Server-Side Request Forgery (SSRF)

File and Data Handling

  • Path Traversal
  • Mass Assignment
  • Denial of Service via Input

Putting It All Together

  • Security Testing
  • The OWASP Top 10
  • Capstone: Hardened Notes API

Output Encoding

The fix for XSS

SQL injection is fixed by parameterized queries. XSS is fixed by output encoding: converting special characters in untrusted data into a safe representation before inserting them into the output.

The key insight: different contexts require different encoding. HTML, JavaScript, URLs, and CSS each have different special characters, and each needs its own encoding.

HTML context

When inserting untrusted data into HTML content (between tags), encode HTML special characters:

function escapeHtml(input: string): string {
  return input
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, """)
    .replace(/'/g, "'");
}

Before encoding: <script>alert('XSS')</script>

After encoding: &lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;

The browser renders this as visible text, not as a script tag. The user sees the literal string <script>alert('XSS')</script> on the page. The script does not execute.

Apply it to the vulnerable route:

route.get("/notes/:id/view", {
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const note = db
      .prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?")
      .get(c.params.id, user.id) as any;
    if (!note) return Response.json({ error: "Not found" }, { status: 404 });

    // SAFE: HTML-encoded
    const html = `<html><body><h1>${escapeHtml(note.title)}</h1><p>${escapeHtml(note.body)}</p></body></html>`;
    return new Response(html, { headers: { "content-type": "text/html" } });
  },
});

Attribute context

When inserting data into an HTML attribute, use the same HTML encoding plus always quote the attribute:

<!-- WRONG — unquoted attribute -->
<div data-name="${escapeHtml(name)}"></div>

<!-- CORRECT — quoted attribute -->
<div data-name="${escapeHtml(name)}"></div>

Without quotes, the attacker can break out of the attribute with a space and inject new attributes like onmouseover=alert(1).

JavaScript context

When inserting data into JavaScript code (e.g., inside a <script> tag), HTML encoding is not enough. You need JSON encoding:

// WRONG — HTML encoding does not help inside <script>
const html = `<script>var data = "${escapeHtml(input)}";</script>`;

// CORRECT — JSON encoding produces a safe JavaScript string
const html = `<script>var data = ${JSON.stringify(input)};</script>`;

JSON.stringify escapes quotes, backslashes, and control characters, producing a valid JavaScript string literal. It also wraps the value in quotes.

[!TIP] The safest approach is to avoid inserting untrusted data into <script> tags entirely. Instead, put data in a data- attribute (HTML-encoded) and read it from JavaScript:

<div id="app" data-username="${escapeHtml(name)}"></div>
<script>
  const name = document.getElementById("app").dataset.username;
</script>

URL context

When inserting data into a URL, use encodeURIComponent:

// WRONG — unencoded user input in URL
const link = `<a href="/search?q=${query}">Search</a>`;

// CORRECT — URL-encoded
const link = `<a href="/search?q=${encodeURIComponent(query)}">Search</a>`;

encodeURIComponent encodes characters that have special meaning in URLs (&, =, ?, #, spaces, etc.).

For the href attribute itself, also check the scheme. An attacker could use javascript:alert(1) as a URL:

function isSafeUrl(url: string): boolean {
  try {
    const parsed = new URL(url, "http://localhost");
    return parsed.protocol === "http:" || parsed.protocol === "https:";
  } catch {
    return false;
  }
}

The encoding rule

Encode output based on where it goes, not where it comes from. The same data might need different encoding in different contexts:

ContextWhat to encodeHow
HTML content<, >, &, ", 'escapeHtml()
HTML attributeSame as above, plus always quoteescapeHtml() + quotes
JavaScriptQuotes, backslashes, control charsJSON.stringify()
URL parameter&, =, ?, #, spacesencodeURIComponent()
CSSRarely needed in APIsAvoid inserting user data into CSS

API responses and JSON

If your API returns JSON (which ours does for most routes), XSS is not a direct concern for the API response. The browser does not execute JSON as HTML. XSS becomes a concern when your API also serves HTML pages, or when a frontend framework renders API data as HTML.

The escapeHtml function is most important for the HTML-rendering routes we added (the /notes/:id/view endpoint and the search page).

Exercises

Exercise 1: Add escapeHtml and apply it to the note view route. Try the <script>alert('XSS')</script> title again. You should see the text rendered literally, not as a script.

Exercise 2: Try <img src=x onerror=alert(1)>. Does escapeHtml prevent it? (Yes — the < and > are encoded.)

Exercise 3: Create a note with the title " onclick="alert(1). Without quotes on the attribute, this would inject an event handler. Verify that quoting the attribute blocks this.

Why is HTML encoding insufficient when inserting data into a JavaScript context?

← What Is XSS? Content Security Policy in Practice →

© 2026 hectoday. All rights reserved.