Cross-site scripting never really died. It just got redistributed across hydration layers, client-side routers, JSON APIs, markdown renderers, third-party widgets, and a browser trust model most teams only partially understand. In modern apps, XSS is rarely just “search box reflects <script>alert(1)</script>.” It’s data crossing boundaries: server to template, API to SPA state, URL fragment to DOM sink, postMessage to widget, markdown to HTML, rich text to preview pane, and “safe” JSON to dangerous rendering logic. This series is about mastering that whole chain—from identifying attack surface to crafting reliable payloads, bypassing defenses, and turning execution into impact. In this first episode, we’re building the map: where XSS lives today, how to threat-model it, and which trust boundaries matter before you ever fire a payload.
Why modern XSS threat modeling matters
Experienced testers already know the classic taxonomy:
- Reflected XSS: attacker-controlled input returns in the immediate response
- Stored XSS: payload is persisted and later rendered to victims
- DOM XSS: client-side JavaScript reads attacker-controlled data and writes it into an executable sink
That taxonomy still matters, but modern apps blur the lines.
A React app may receive attacker input through a REST endpoint, store it in state, pass it through a markdown component, and finally write dangerous HTML through a helper wrapper around dangerouslySetInnerHTML. Is that stored XSS? DOM XSS? Both, depending on where you anchor the analysis.
Threat modeling cuts through that ambiguity by asking four questions:
- What are the attacker-controlled sources?
- What transformations happen on the way?
- Which sinks can execute or reinterpret data?
- Which browser trust boundaries are crossed?
If you can answer those four questions consistently, you can reason about XSS in any stack.
The modern XSS mental model: source → transform → sink → impact
At an advanced level, XSS hunting is taint tracking with browser semantics.
Sources: where attacker control enters
Common sources include:
- Query string parameters
- Path parameters
- Hash fragments
- Form fields
- API responses
- WebSocket messages
postMessagedatalocalStorage/sessionStorage- Cookies readable by JS
- User profile fields, comments, bios, ticket content
- Uploaded filenames, SVGs, HTML documents
- Third-party integrations: chat widgets, analytics metadata, CMS content
Client-side examples:
const q = new URLSearchParams(location.search).get("q");
const hash = location.hash.slice(1);
window.addEventListener("message", (e) => {
renderWidget(e.data.html);
});
Server-side examples:
app.get("/search", (req, res) => {
res.render("results", { query: req.query.q });
});
Transforms: the dangerous middle
Transforms often create false confidence. Teams think “it came from JSON” or “we escaped it once” and stop tracing.
Common transforms:
- Templating engine rendering
- Markdown parsing
- HTML sanitization
- JSON serialization/deserialization
- Base64 decoding
- URL decoding
- DOM parsing
- Framework rendering abstractions
- String concatenation into HTML, JS, CSS, or URLs
Example of a dangerous transform chain:
const raw = new URLSearchParams(location.search).get("bio");
const html = marked.parse(raw); // markdown to HTML
preview.innerHTML = html; // executable sink
Sinks: where data becomes code or active markup
Dangerous sinks are broader than innerHTML.
High-value sinks include:
innerHTMLouterHTMLinsertAdjacentHTML()document.write()eval(),Function(), string-based timers- Script creation with attacker-controlled
srcor content - Event handler attributes
- Dangerous URL assignments like
javascript: - Framework escape hatches
Examples:
element.innerHTML = userData;
location.href = userControlledUrl;
setTimeout(userControlledString, 0);
script.src = attackerInput;
Impact: execution is the start, not the finish
In real assessments, impact depends on context:
- Which users view the payload?
- Which origin executes it?
- Are session tokens readable?
- Can the script invoke privileged actions?
- Is there CSRF protection?
- Is the app admin-heavy?
- Can internal APIs be reached from that browser context?
- Is CSP present, and how strong is it?
We’ll weaponize this later in the series. For now, remember: XSS severity is context-dependent, not payload-dependent.
Browser trust boundaries you must model
Modern XSS lives in misunderstood trust boundaries.
Server-rendered HTML vs client-rendered DOM
A value safely encoded into HTML text may become unsafe later if client-side code reuses it in an HTML sink.
Example:
<div id="msg" data-msg="<img src=x onerror=alert(1)>"></div>
<script>
const msg = document.getElementById("msg").dataset.msg;
document.getElementById("preview").innerHTML = msg;
</script>
The server escaped correctly for an attribute context. The client later turned it back into active HTML.
Origin trust is not application trust
If code runs in the app origin, it inherits that origin’s ambient authority:
- Authenticated API access
- Same-origin DOM access
- Access to non-HttpOnly storage
- Ability to trigger privileged workflows
This is why “just alert” is never the real issue.
Data formats are not safety guarantees
JSON, GraphQL, and protobuf-backed APIs are not inherently safe. The danger appears when parsed data is rendered into HTML, script, URL, or style contexts.
Example API response:
{
"displayName": "<img src=x onerror=alert(1)>"
}
Safe as data. Dangerous when rendered unsafely:
profileName.innerHTML = apiResponse.displayName;
Third-party code and cross-window messaging
A lot of modern client-side attack surface comes from embedded systems:
- payment widgets
- support chat
- analytics dashboards
- CMS previews
- ad-tech
- embedded admin tools
Improper postMessage handling is a frequent bridge from external control to DOM sinks.
window.addEventListener("message", (event) => {
// bad: no origin validation
panel.innerHTML = event.data.content;
});
That is effectively a remotely controllable DOM XSS sink.
Mapping XSS across modern frontend architectures
Traditional server-rendered applications
Classic reflected and stored XSS still show up in:
- search results
- error messages
- profile fields
- support tickets
- admin moderation panels
- export previews
Typical stack indicators:
- Jinja2 / Twig / Handlebars / EJS / ERB
- custom template helpers
- partial templates reused in multiple contexts
The key here is context-specific encoding. One variable may be safe in HTML text but unsafe in a JS string, URL, or attribute.
Bad example:
<script></script>
If template escaping is HTML-oriented rather than JavaScript-string-oriented, this may break out.
Single-page applications
SPAs shift much of the attack surface into the browser:
- route params
- query strings
- hash-based routers
- API-fed state
- client-side templating
- hydration mismatches
- dynamic component rendering
React, Vue, Angular, Svelte, and friends reduce some classes of XSS by default—but every framework has escape hatches.
Examples:
- React:
dangerouslySetInnerHTML - Angular:
bypassSecurityTrustHtml,bypassSecurityTrustUrl - Vue:
v-html - Svelte:
{@html ...}
These aren’t vulnerabilities by themselves. They’re places where the framework stops protecting you.
Hybrid rendering and hydration
Next.js, Nuxt, Remix, Astro, and similar frameworks introduce subtle boundaries:
- server-rendered props embedded into HTML
- hydration data blobs
- client-side re-rendering using serialized state
- route transitions that consume URL or API data differently than initial SSR
Watch for:
- serialized JSON injected into script blocks
- inconsistent escaping between SSR and CSR
- user-controlled content rendered by both server and client code paths
Example pattern:
<script id="__DATA__" type="application/json">
{"title":"...user-controlled..."}
</script>
If serialization is broken or later reused unsafely, this becomes a pivot point.
Rich text, markdown, and preview features
These remain elite XSS territory because they’re often viewed as “content problems,” not code execution problems.
Attack surfaces:
- markdown renderers
- WYSIWYG editors
- HTML import/export
- comment previews
- issue trackers
- chat applications
- knowledge bases
- CMS blocks
If the app allows “some HTML,” your job is to identify:
- what sanitizer is used
- what tags/attributes survive
- whether mutations occur after sanitization
- whether rendered content is later manipulated by JS
File-driven XSS
Don’t ignore upload and import features:
- SVG uploads
- HTML attachments
- PDF previews with embedded scripts in weak viewers
- CSV/Excel formula injection leading to browser pivots
- filenames reflected into DOM
- image metadata shown in admin panels
An uploaded filename like this can become dangerous in a management UI:
"><img src=x onerror=alert(1)>
If displayed into an unsafe sink, that’s stored XSS via metadata, not body content.
Contexts that change exploitability
Advanced XSS work depends on rendering context. The same payload does not work everywhere.
HTML text context
Example sink:
<div>{{userInput}}</div>
Typical payload shape:
<img src=x onerror=alert(1)>
Attribute context
Example:
<input value="{{userInput}}">
Payloads depend on quote handling and whether the attribute is event-capable or URL-based.
Example breakout:
" autofocus onfocus=alert(1) x="
JavaScript string context
Example:
<script></script>
Payload must break out of the string and often the statement:
";alert(1);//
URL context
Example:
<a href="{{userInput}}">click</a>
Danger depends on scheme control:
javascript:alert(1)
Modern frameworks may block some cases, but URL handling remains a high-value edge.
CSS context
Less common than before, but still relevant in legacy code and style injection patterns. Usually lower impact in modern browsers, but can become a pivot.
The lesson: threat model the context before testing payloads. Episode 2 will go deep on this.
A practical methodology for mapping XSS attack surface
1. Enumerate input vectors
Start with every place user influence can enter the system.
Use a proxy and crawl manually:
burpsuite
Or collect URLs and parameters:
cat urls.txt | grep -E '\?|#|='
Look for:
- reflected parameters
- POST bodies
- JSON fields
- route segments
- upload metadata
- hidden admin forms
- import/export workflows
2. Identify rendering points
Find where data comes back out.
Questions to ask:
- Is it rendered in HTML?
- Is it embedded in JSON?
- Is it inserted into script?
- Does the frontend read it and render later?
- Is it visible only to admins?
Use browser devtools to inspect DOM changes and network responses. Search the codebase or bundled JS for dangerous sinks.
Quick grep targets:
grep -R "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write\|eval\|Function\|dangerouslySetInnerHTML\|v-html\|bypassSecurityTrustHtml" .
For minified bundles:
rg "innerHTML|insertAdjacentHTML|dangerouslySetInnerHTML|v-html|postMessage|DOMParser|eval|Function"
3. Trace source-to-sink paths
For DOM XSS, this is everything.
Common source patterns:
location.searchlocation.hashdocument.referrerwindow.namepostMessage- storage APIs
Common sink patterns:
- HTML insertion
- dynamic script creation
- event assignment
- URL assignment
- code evaluation
Example vulnerable path:
const tab = location.hash.substring(1);
document.querySelector("#content").innerHTML = tabs[tab];
If tabs[tab] is attacker-influenced or fallback logic echoes the hash, this is exploitable.
4. Classify by viewer and privilege
A comment XSS only visible to the author is different from one rendered in:
- admin dashboards
- support consoles
- moderation queues
- billing panels
- SSO management pages
Stored XSS against privileged back-office users often beats reflected XSS against normal users.
5. Note defenses and their gaps
Record:
- CSP presence and directives
- Trusted Types
- sanitizer library/version
- framework auto-escaping behavior
- cookie flags
- origin isolation
- iframe sandboxing
Defenses change exploitation strategy, but they also reveal where the app believes it is safe.
Realistic examples of modern XSS surfaces
Example 1: Reflected XSS through SSR template misuse
app.get("/welcome", (req, res) => {
res.send(`<script>const name = "${req.query.name}";</script>`);
});
Request:
GET /welcome?name=";alert(1);// HTTP/1.1
Host: target.local
This is reflected XSS in a JavaScript string context.
Example 2: Stored XSS in markdown preview
app.post("/notes", (req, res) => {
db.save({ body: req.body.body });
});
app.get("/notes/:id", async (req, res) => {
const note = await db.get(req.params.id);
res.send(`<div class="preview">${marked.parse(note.body)}</div>`);
});
If markdown is rendered without sanitization, attacker content may execute when any viewer opens the note.
Example 3: DOM XSS via hash router helper
function renderRoute() {
const route = decodeURIComponent(location.hash.slice(1));
document.getElementById("view").innerHTML = route;
}
window.addEventListener("hashchange", renderRoute);
renderRoute();
Payload:
#<img src=x onerror=alert(1)>
This is pure client-side DOM XSS.
Example 4: postMessage bridge abuse
window.addEventListener("message", (e) => {
if (e.data.type === "render") {
modal.innerHTML = e.data.html;
}
});
If the page accepts messages from arbitrary origins, an attacker-controlled page can send HTML into the victim app.
Defensive thinking during threat modeling
Even while mapping attack surface, think like a defender.
Prefer safe sinks
Use:
textContentinnerTextwhere appropriate- safe DOM construction APIs
- framework-native escaped rendering
Instead of:
el.innerHTML = userInput;
Use:
el.textContent = userInput;
Apply context-aware output encoding
Encoding must match the destination:
- HTML entity encoding for HTML text
- attribute encoding for attributes
- JavaScript string encoding for JS contexts
- URL validation and allowlisting for URL sinks
One-size-fits-all escaping is a myth.
Sanitize only when HTML is intentionally allowed
If rich HTML is required, use a mature sanitizer and keep it updated.
Example with DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);
container.innerHTML = clean;
But remember: sanitizer configuration, parser behavior, and post-sanitization DOM mutations all matter. We’ll break this apart later in the series.
Deploy CSP and Trusted Types properly
A good CSP can reduce exploitability. Trusted Types can kill many DOM XSS patterns at the sink layer. But weak CSPs and partial TT deployments create false assurance.
Good defensive posture includes:
- no inline scripts unless nonced/hash-controlled
- no
unsafe-eval - restrictive script sources
- Trusted Types enforcement for dangerous sinks
- reduction of legacy rendering patterns
What experienced testers should prioritize first
When dropped into a modern target, prioritize these attack surfaces:
- User content rendered to other users
- Admin/moderation interfaces
- Markdown, WYSIWYG, and preview features
- Client-side rendering of API fields
- Hash/query-driven UI state
- postMessage handlers
- Framework escape hatches
- Serialized state in SSR/hydration
- Upload metadata and filenames
- Third-party widget integrations
That list will find more real XSS than blindly spraying payloads across every parameter.
Building your XSS map before exploitation
By now, the pattern should be clear: modern XSS is less about memorizing payloads and more about understanding data flow and browser interpretation. Reflected, stored, and DOM XSS are still useful labels, but the real work is mapping attacker-controlled input across rendering layers and trust boundaries. If you can identify sources, transformations, sinks, and execution context, you can predict where XSS will emerge—even in frameworks that claim to make it impossible.
That map is what the rest of this series will build on. In the next episode, we move from theory to weaponization: precision payloads for reflected and stored XSS, crafted for specific contexts like HTML, attributes, scripts, and URLs so your probes stop being noisy guesses and start becoming reliable execution.