← Blog
November 5, 202611 min readAppSecVulnerability ResearchBug bountyXSSSecurity Engineering

An alert(1) is not proof: verifying an exploitable cross-site scripting vulnerability

You paste a script tag into a field, a dialog box pops, and the report is already half-written: “stored XSS.” But an alert(1) only proves a sink ran some script in your own browser — not that a real user would ever trigger it, that the app's CSP and HttpOnly cookies let it do anything, or that the context has anything worth stealing. A practitioner's guide to telling self-XSS and defence-neutralized popups apart from real impact: finding the injection context, proving a victim (not just you) can reach it, demonstrating one concrete effect against the defences actually deployed, the pretalx stored-XSS-to-account-takeover CVE from 2026, and the contextual-encoding, DOMPurify, Trusted Types and HttpOnly fixes that hold.

By The Xalgorix Team

It is the most screenshotted finding in web security: you paste <script>alert(1)</script> into a field, a dialog box says 1, and the report is half-written before the box is even dismissed — “stored XSS.” The popup is real, and on its own it is almost meaningless. All it proves is that a sink ran some script in your browser, under conditions you arranged for yourself. It says nothing about whether a real user would ever trigger it, whether the application's own defences let that script do anything, or whether “anything” here is worth more than a nuisance. Cross-site scripting is graded almost entirely on that second question, and an alert(1) never answers it. The finding is not the dialog box; it is a demonstrated, reproducible effect in a victim's authenticated session — the data you could read, the action you could take, the account you could reach.

What cross-site scripting actually is

PortSwigger's Web Security Academy defines XSS as a flaw that “allows an attacker to compromise the interactions that users have with a vulnerable application,” letting attacker-controlled script run in a victim's browser and sidestep the same-origin policy that normally keeps sites apart. It is CWE-79, and it comes in three shapes that differ only by where the malicious script comes from. Reflected XSS is echoed straight back out of the current request; stored XSS is saved in the application's database and served to whoever views it later; and DOM-based XSS lives in client-side code, where untrusted data flows into a dangerous sink — innerHTML, document.write, eval — without being made safe first. The sink is the part that matters, because the sink is what turns a string into executing markup, and, as PortSwigger notes for the DOM case, finding it means there is “no substitute for reviewing JavaScript code.”

Why an alert box is not proof

The asymmetry that runs through this whole series is especially sharp for XSS: the signal everyone chases — “my script executed” — is identical in the trophy case and in several duds. One popup is true in all of the following, and only one of them is a vulnerability worth reporting:

  • It ran for you, and only you. The payload sits in a field only your own account can set, you fired it from your own DevTools console, or the “reflected” value has no delivery path that puts it in anyone else's request. This is self-XSS: a popup in a browser you already control is not an attack on anyone.
  • A deployed defence neutralizes the impact. The lab payload pops, but a weaponized one cannot act: a Content Security Policy blocks the inline script or the exfiltration request, the session cookie is HttpOnly so document.cookie yields nothing useful, or the framework already encodes the one channel you would need. The sink ran; the consequence did not.
  • The context has nothing to steal. PortSwigger is blunt that impact “generally depends on the nature of the application”: on brochureware with no sessions and no sensitive data it “will often be minimal,” while on an app with sensitive data or an admin user it is “serious” or “critical.” The same payload is a non-event on one page and account takeover on another.
alert(1) fired
Self-XSS only
Only your own session or console can trigger it — no victim path
Neutralized
CSP blocks the payload, or HttpOnly / encoding kills the impact
Real impact
Runs in a victim's authenticated session and acts as them
Only the right-hand branch is a reportable vulnerability; the dialog box alone cannot tell them apart.

What the real bug costs: impact in the victim's session

Once script runs in a victim's browser, PortSwigger's catalogue of what XSS can do is expansive: an attacker can “impersonate or masquerade as the victim user,” “carry out any action that the user is able to perform,” “read any data that the user is able to access,” and “capture the user's login credentials.” When the victim holds elevated privileges, that can escalate to “full control of the vulnerable application.” Proof means demonstrating one of those effects — not reciting the list.

The honest part is that every demonstration technique has real limits, and knowing them is what keeps a report credible. PortSwigger's guidance on exploiting XSS is candid about them. Stealing cookies has “some significant limitations”: “the victim might not be logged in,” “many applications hide their cookies from JavaScript using the HttpOnly flag,” sessions may be bound to the user's IP, and a session may time out before you can use it. Capturing a password by injecting a form that a password manager auto-fills “avoids most of the problems associated with stealing cookies,” but only works on victims whose manager auto-fills. The most reliable path is usually to just act: XSS gives the attacker two-way interaction with the page, so a script can read an anti-CSRF token and then send a state-changing request — change the victim's email, trigger a password reset — which is why XSS defeats the very cross-site request forgery defences that would otherwise stop a cross-site attacker. The lesson for verification is to prove the one effect the target actually exposes, rather than reaching for cookie theft out of habit against an HttpOnly app where it was never going to work.

A real one: a popup that was really account takeover

CVE-2026-41241, disclosed in April 2026 against the conference-management platform pretalx, is a clean illustration. The CVE record describes a stored XSS in the organiser search typeahead: the backend “rendered submission titles, speaker display names, and user names/emails into the result dropdown using innerHTML string interpolation.” Those fields are set by ordinary, unprivileged users — anyone who submits a talk or sets a display name — so the malicious record is planted by a low-privilege account and lies dormant until an organiser's search matches it, at which point the script runs in the organiser's authenticated browser. That is why it carries a CVSS 3.1 base of 8.7 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N) — a changed scope with high confidentiality and integrity impact, not the minimal rating a popup on a public page would earn. It is fixed in pretalx 2026.1.0.

What makes it a proof story rather than a popup story is what the finder, Novee's Elad Meged, actually demonstrated. Per the disclosure, the injected script ran with an organiser's privileges — enough for account hijacking and data theft, not a cosmetic alert — and the researcher also chained a script-free variant: an <img> tag placed in a submission title whose request reached a privileged “demote superuser” endpoint, performing a state-changing action with no JavaScript execution at all. Notably, the application had a Content Security Policy, so reaching script execution required serving the payload from a same-origin uploaded file to satisfy it. Every one of those details is a reason the alert(1) test alone would have mis-graded the bug: a naive inline popup would have been blocked by the CSP, and the real severity lived in what an organiser's session could do, not in the fact that a sink existed. The sink, for the record, was the DOM-XSS classic — data flowing into innerHTML — exactly the sink a Trusted Types policy is built to neutralize.

Verifying it safely: context, reach, impact

A defensible XSS verification answers three questions in order, and each one retires one of the innocent explanations above. Context: submit a unique, harmless marker into each entry point, find where it is reflected, and identify the context it lands in — HTML body, an HTML attribute, a JavaScript string, a URL. The payload that escapes an attribute ("> or an onerror handler) is not the one that escapes a JS string (";), and encoding that is correct for one context is a false negative in another. Reach: prove a victim — not just you — can trigger it. For stored XSS, log in as a different account or role and confirm it fires when they view the content; for reflected XSS, confirm a real GET link carries the payload rather than a value only your own session can set. Impact: demonstrate exactly one real effect, and do it against the defences that are actually deployed.

That last step is where honesty lives. Demonstrate impact with a benign, scoped action: send a canary you planted to a collaborator server you control, or perform a reversible action on a second test account you own and read back the persisted change — a changed email, a flipped setting. This is the moment a Content Security Policy and HttpOnly are tested for real rather than assumed away: if the CSP blocks your exfiltration, say so, because the bug may genuinely be contained, and an honest “executes but cannot act under the deployed CSP” is worth more than an inflated “critical.” Keep the blast radius to artefacts you created — your own accounts, your own canaries — and never pop a payload on shared production content that real users will load.

# Verifying an XSS is exploitable, not just that a sink runs.
# Use your OWN accounts and planted canaries — never a real user's data.

# 1) CONTEXT: submit a unique marker (e.g. xqz8842) into each input.
#    Find where it reflects, and in WHICH context:
#      HTML body        ->  <div>xqz8842</div>      break out with a new tag
#      HTML attribute   ->  value="xqz8842"         break out with "> or onX=
#      JS string        ->  var n = "xqz8842";      break out with "; ... //
#    The working payload depends ENTIRELY on the context; the right encoding
#    in the wrong context is a false negative.

# 2) EXECUTES: confirm the sink runs — alert(1), or print() inside a
#    cross-origin iframe (Chrome 92+ blocks alert() there). This is the FLOOR.

# 3) REACH: stored -> log in as a DIFFERENT account/role and confirm it fires
#    when THEY view it. reflected -> confirm a real GET link delivers it, not a
#    value only your own session can set. If only you can trigger it: self-XSS,
#    not a finding.

# 4) IMPACT vs the DEPLOYED defences: demonstrate ONE effect, benignly —
#    exfiltrate a canary YOU planted to a collaborator you control, or perform a
#    reversible action on your SECOND test account and read the persisted change.
#    If CSP / HttpOnly blocks it, report that honestly — it may be contained.

# 5) REPRODUCE and scope: minimal payload, repeat it, keep the blast radius to
#    artefacts you created. The proof is the effect — not the popup from step 2.
Sink runs
alert(1) / print()
Fires for a victim
other account, real delivery
Beats the defences
CSP / HttpOnly tested
Effect reproduced
on your own canary
Proof
effect in victim context
Each rung retires one innocent explanation; the proof is the reproduced effect, not the dialog box.

This is also where autonomous testing, ours included, meets an honest ceiling. A scanner is good at the floor: it can inject a marker, find the reflection, and confirm a sink executes. What it struggles to decide on its own is everything above that line — whether a human victim has any path to the payload or it is self-XSS, whether the deployed CSP and cookie flags leave any real consequence intact, and which authenticated action would actually matter on this application. Those are judgments about reachability and business context, not properties of the request, and telling a harmless popup from a re-exploited, reproduced effect is precisely the detection-to-proof gap that exploit verification exists to close.

The traps

  • Self-XSS is not XSS. A payload only your own account or console can trigger harms no one. Prove a separate victim identity reaches it.
  • HttpOnly quietly defeats cookie theft. If the session cookie is out of JavaScript's reach, stop trying to steal it and demonstrate an action instead.
  • A CSP can contain the bug — test, don't assume. The sink firing does not mean the payload can exfiltrate or load remote script. Verify against the policy that is actually shipped.
  • Wrong-context encoding fools you both ways. Input escaped in the HTML body may be raw inside an attribute or a JS string. Match the probe to the context.
  • No <script> is required. An <img onerror>, an <svg>, or a plain markup injection that triggers a request can be as damaging as script — and may be what lands when script is blocked.

The fix that holds

You close XSS in code, and the core move is context-aware output encoding: encode untrusted data for the exact place it lands — HTML entities in body text, quoted-and-encoded attribute values, a JavaScript-string encoder inside scripts. OWASP's strongest, simplest advice is to lean on a framework that “escape[s] every value they render by default,” because that does not depend on correctly labelling each value as trusted — and to remember that “an application that protects 99 places and misses one is still vulnerable.” Treat the escape hatches (dangerouslySetInnerHTML, Angular's bypassSecurityTrust*, and raw innerHTML) as code that must justify itself in review. Where users genuinely need to submit HTML, do not encode it — sanitize it, and OWASP “recommends DOMPurify” for exactly that.

// Vulnerable: untrusted fields interpolated into the DOM as live markup.
// A stored speaker name or talk title becomes HTML in an organiser's
// authenticated page -- the alert(1) sink and the real sink are one line.
dropdown.innerHTML = results
  .map((r) => "<li data-id=" + r.id + ">" + r.title + " - " + r.speaker + "</li>")
  .join("");

// Fixed: build nodes and write TEXT, never markup. The browser treats the
// value as data, so an <img onerror=...> is literal characters, not a tag.
for (const r of results) {
  const li = document.createElement("li");
  li.dataset.id = r.id;
  li.textContent = r.title + " - " + r.speaker;
  dropdown.appendChild(li);
}

// Defence in depth: make the dangerous sink refuse raw strings outright.
//   Content-Security-Policy: require-trusted-types-for 'script';
// now  el.innerHTML = userString  throws unless a reviewed policy approved it.

Then add the layers that blunt whatever still slips through. A Trusted Types policy — enabled with Content-Security-Policy: require-trusted-types-for 'script' — makes DOM sinks like innerHTML reject plain strings, so the only place untrusted data can become markup is a small, reviewable policy function instead of every DOM write in the codebase. Set session cookies HttpOnly so a successful XSS still cannot read them directly. And keep OWASP's framing straight: a CSP is “an additional layer of defense” and “should not be your primary defense mechanism.” Encoding and safe sinks remove the bug; CSP, Trusted Types, and HttpOnly decide how much a missed one can cost.

The one-line version: an alert(1) proves a sink ran in your browser, not that the bug is exploitable. Find the context, prove a real victim — not just you — can trigger it, and demonstrate one concrete effect against the CSP and cookie flags the target actually ships. The reproduced effect in a victim's session, not the dialog box, is the distance between a popup and proof.

Ready to see it prove a bug?

Start scanning on Cloud →