Cross-Site Scripting (XSS): Types, Vectors, and Mitigations

Cross-Site Scripting (XSS): Types, Vectors, and Mitigations

pr0h0
xssweb-securitycybersecurityapplication-security
AI Usage (92%)

What XSS actually is

XSS is not just “someone pasted a script tag.” It is a trust boundary failure: untrusted data reaches a browser sink that the browser treats as executable markup or script. Once that happens, the payload runs in the origin of the vulnerable app, which is why XSS still matters for session theft, UI redress, request forgery, and account actions.

I usually start by tracing one value: where it came from, where it was stored, and where it was rendered. If you can answer those three questions, the bug usually stops looking mysterious.

The three main XSS types

Reflected XSS in request/response flows

Reflected XSS happens when request input is echoed back immediately in the response. Search pages and error handlers are common examples.

A typical pattern looks like this:

app.get("/search", (req, res) => {
  res.send(`<p>Results for ${req.query.q}</p>`);
});

If q is inserted into HTML without encoding, the browser parses it as markup, not text. The request alone is enough to trigger the payload.

Stored XSS in database-backed features

Stored XSS lands in a database, cache, or message queue and comes back later in another user's browser. Comments, tickets, profile bios, and admin dashboards are the usual places.

The issue is not storage itself. The problem is rendering unsafe content later, often in a different privilege context.

DOM-based XSS in client-side JavaScript

DOM XSS happens when client-side code takes data from the URL, hash, or local state and writes it into a dangerous sink.

A small example:

const q = new URLSearchParams(location.search).get("q");
document.querySelector("#result").innerHTML = q;

No server response is required here. The browser executes the bug entirely in the page's JavaScript path.

Common delivery vectors I test first

Search fields and error pages

Search is a good first check because teams often treat it as harmless display text. Error pages are similar: they copy the user's input into a message and assume the path is not sensitive.

I test whether the application reflects input in the page, in an attribute, or inside a script block. That context decides whether encoding is enough and what kind of encoding is needed.

Comment systems, profile fields, and rich text editors

These are stored-XSS magnets because teams want some HTML support but do not want the full risk of raw markup.

Rich text editors are especially tricky. Many sanitize on the client, but the server later accepts unsanitized HTML from another API path. That mismatch is where bypasses live.

URL parameters, hash fragments, and router state

Client-side routers often read from location.search or location.hash. That data looks harmless because it came from the URL, but it is still attacker-controlled.

If a page uses router state to build DOM nodes, modal content, breadcrumbs, or preview panels, I check those first.

Why the browser executes the payload

Dangerous sinks like innerHTML and insertAdjacentHTML

The browser does not “run strings.” It parses them into DOM. That is why sinks such as innerHTML, insertAdjacentHTML, document.write, and outerHTML are high risk.

Safe text APIs exist for a reason:

const el = document.querySelector("#result");
el.textContent = userInput;

The difference is simple: textContent treats the value as text, while innerHTML tells the browser to interpret it.

Context matters: HTML, attribute, URL, and script contexts

Encoding has to match the context. HTML text, HTML attributes, URLs, and script blocks all need different handling.

ContextExample sinkMain defense
HTML textelement.innerHTMLescape <, >, &
Attributetemplate string in markupattribute encoding
URLhref, srcURL validation plus encoding
Scriptinline JS dataavoid inline script, serialize safely

This is where many fixes fail. Teams encode for HTML text, then reuse the same helper inside an attribute or script block and assume they are done.

Practical mitigation layers

Output encoding by context

Context-aware output encoding is the first line of defense. If the browser should show text, emit text. If it must show a value in HTML, encode for HTML. If it must go in an attribute, encode for that attribute.

The safe rule is boring but effective: encode at the last moment, for the exact context where the value lands.

Sanitization for trusted HTML features

If your product intentionally supports user HTML, sanitize it with a real allowlist. Do not invent your own parser.

Sanitization is for “some HTML is allowed,” not “any HTML but we removed a few bad tags.” The difference matters because attackers do not need many features if one dangerous path survives.

CSP as a defense layer, not a replacement

Content Security Policy helps reduce impact, especially when you remove inline scripts and restrict script sources. It is useful, but it does not fix unsafe DOM writes.

A weak CSP can still allow payload execution if the app already trusts attacker-controlled HTML. Treat CSP as a backup seatbelt, not the brake pedal.

Framework defaults and where they still fail

Modern frameworks usually escape template output by default, which helps a lot. That does not cover:

  • dangerouslySetInnerHTML
  • raw HTML rendering helpers
  • unsafe router rendering
  • direct DOM manipulation in effects or legacy code

Framework defaults reduce the surface. They do not eliminate it.

A safe testing workflow

Reproduce with harmless payloads

Use harmless markers first, such as visible text that confirms whether the app escapes or reinterprets input. You are not trying to break anything yet; you are trying to map the rendering path.

Trace data from input to sink

I like to follow the value from request parameter, to server response, to DOM insertion, to final sink. Once you know the sink, the fix becomes obvious.

A quick review checklist:

  1. Find the input source.
  2. Find every transformation.
  3. Find the final sink.
  4. Check whether the sink matches the context.
  5. Verify server-side and client-side paths separately.

Verify the fix in multiple browsers

Do not test only in the browser you normally use. Rendering and parser edge cases differ enough that a fix can look safe in one browser and fail in another.

I also re-test after framework upgrades. A dependency change can reintroduce a sink through a component that used to escape by default.

Checklist for developers and reviewers

  • Use textContent or equivalent for plain text.
  • Encode output for the exact target context.
  • Avoid raw HTML sinks unless you truly need them.
  • Sanitize all user-controlled HTML on the server.
  • Keep CSP strict, but do not rely on it alone.
  • Review URL, hash, and router-driven rendering paths.
  • Audit any code that uses innerHTML or insertAdjacentHTML.
  • Test reflected, stored, and DOM-based flows separately.

Conclusion

XSS is usually a boring bug with expensive consequences. The dangerous part is how normal the code looks before it fails: a search result, a profile bio, a router fragment, a quick innerHTML assignment.

If you want to catch it early, trace untrusted data to the sink and ask one question: should the browser treat this as text or code? If the answer is text, keep it text all the way down.

Share this post

More posts

Comments