August 5, 2026
Bypassing a “Safe” Input Field: Reflected XSS via Unescaped HTML Attribute Injection
How a form parameter that never touches <script> tags can still execute arbitrary JavaScript — and why blacklisting < and > isn't enough.

By Reza
5 min read
TL;DR
A redirect-target parameter was reflected directly inside an HTML attribute value without proper encoding. Because the application only filtered obvious tag characters, it was still possible to break out of the attribute using a double quote and inject a new attribute — onfocus combined with autofocus — to execute arbitrary JavaScript without ever writing a <script> tag. The double quote itself only worked once the request was switched from GET to POST, which turned out to be a lesson in its own right. Severity: P3 / Medium, per standard VRT classification for reflected XSS.
Background
Many web apps pass along a "return to" or "next page" value through the request chain — after a login, a form submission, or a multi-step wizard, the app remembers where to send the user back to. This value often ends up echoed straight into the page, frequently inside an HTML attribute like a hidden form field or a redirect link.
The assumption behind treating this as "safe" is usually: user input isn't rendered as HTML, so injecting a script tag won't execute. That assumption breaks down the moment the input lands inside an attribute value instead of the page body — a completely different injection context with different rules.
The Vulnerable Pattern
Imagine a page at https://vulnerable-app.com/account/redirect that accepts a return_path parameter and reflects it into a hidden form field, roughly like this:
<input type="hidden" name="return_path" value="USER_INPUT_HERE" /><input type="hidden" name="return_path" value="USER_INPUT_HERE" />If USER_INPUT_HERE is inserted without HTML-attribute encoding, the only character that matters is the double quote (") that opened the value attribute. Closing it early lets an attacker introduce arbitrary new attributes on the same tag.
Building the Payload
A naive first attempt might try:
return_path="><script>alert(document.cookie)</script>return_path="><script>alert(document.cookie)</script>This often gets blocked, because many WAFs and filters specifically look for <script>, <img, <svg, and similar tag-opening patterns. But script tags aren't the only way to execute JavaScript in HTML — event handler attributes are just as effective, and far less commonly filtered.
The working payload:
return_path=R3za" onfocus=alert(origin) autofocus tabindex=1return_path=R3za" onfocus=alert(origin) autofocus tabindex=1Breaking this down:
R3za"— closes the originalvalue="..."attribute earlyonfocus=alert(origin)— adds a new event-handler attribute; whatever JavaScript is assigned here runs when the element receives focusautofocus— tells the browser to focus this element automatically the instant the page loads, with no user interaction requiredtabindex=1— ensures the element is focusable at all (some input types and elements need an explicit tabindex to receive programmatic focus)
The result renders as:
<input type="hidden" name="return_path" value="R3za" onfocus=alert(origin) autofocus tabindex=1 /><input type="hidden" name="return_path" value="R3za" onfocus=alert(origin) autofocus tabindex=1 />No <script> tag, no <, no > — just a self-closing attribute injection that the browser happily parses as three brand-new attributes on the existing tag. The moment the page loads, autofocus triggers onfocus, and the JavaScript executes.
Why GET Wasn't Enough — and How POST Solved It
The first natural test is a simple GET request, since that's what a clickable link would use:
https://vulnerable-app.com/account/redirect?return_path=R3za" onfocus=alert(origin) autofocus tabindex=1https://vulnerable-app.com/account/redirect?return_path=R3za" onfocus=alert(origin) autofocus tabindex=1This didn't work. The browser (and, in testing, the intercepting proxy) automatically URL-encodes the double quote in a GET query string, turning " into %27. By the time the value reached the server and was reflected back, the payload arrived as a harmless, still-quoted string — no attribute breakout, no execution. The reflection point was intact; the delivery mechanism was neutering the payload before it ever got there.
Switching the request to POST changed that. Submitting the same parameter as an HTML form field (application/x-www-form-urlencoded body via an actual <form> submission) delivered the raw " character to the endpoint without the same encoding interference, and the attribute breakout worked exactly as expected. This is a useful reminder during testing: a payload that fails on one HTTP method isn't necessarily blocked by a filter — it might just be getting mangled in transit, and it's worth re-testing the same injection point across GET, POST, and even different content types before ruling it out.
Since the working delivery method required POST, a clickable link alone wasn't enough for a PoC. The standard way around this is a self-submitting HTML form, the same primitive used for CSRF PoCs:
<html>
<body>
<form action="https://vulnerable-app.com/account/redirect" method="POST">
<input type="hidden" name="return_path"
value="R3za" onfocus=alert(origin) autofocus tabindex=1" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html><html>
<body>
<form action="https://vulnerable-app.com/account/redirect" method="POST">
<input type="hidden" name="return_path"
value="R3za" onfocus=alert(origin) autofocus tabindex=1" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>Host this on any attacker-controlled page, get a victim to open it, and the form auto-submits on load — no click required. history.pushState is a cosmetic touch that cleans up the browser's address bar so the redirect looks less suspicious.
Why This Matters
This is a textbook example of reflected XSS via attribute-context injection, and it's worth internalizing three things from it:
- Filtering < and > is not the same as encoding output. A filter that strips or blocks angle brackets stops tag injection but does nothing to stop attribute injection — the double quote is the character that actually matters in this context, and it's frequently overlooked.
- Event handlers are a full substitute for
<script>.onfocus,onerror,onmouseover, and friends all execute arbitrary JavaScript. Any XSS filter that only pattern-matches for script tags orjavascript:URIs will miss this entire class of payload. autofocusremoves the need for user interaction. Combined with an event handler, it turns what looks like a passive hidden field into a zero-click execution trigger the instant the page renders.- A failed payload isn't always a blocked payload. Before writing off an injection point as filtered, re-test it across HTTP methods and encodings — GET-based query strings and POST-based form bodies don't always transport special characters the same way.
Impact
Reflected XSS in this pattern allows an attacker to execute arbitrary JavaScript in the context of the victim's authenticated session on the vulnerable origin. Depending on what the application exposes to client-side JS, that can mean:
- Session token / cookie theft (where cookies aren't
HttpOnly) - Performing actions as the victim (account changes, data submission) via the DOM
- Credential harvesting through injected fake login prompts
- Pivoting into further attacks that "appear" to originate from a legitimate, authenticated user
Because the request typically needs to be delivered via a self-submitting form (POST) or a crafted link (GET), real-world exploitation usually requires some social engineering to get a victim to open the malicious page — but zero additional interaction is needed once they do.
The Fix
The root cause is a single missing step: HTML-attribute encoding on output. Specifically:
- Encode ", <, >, &, and ' before reflecting any user-controlled value into an HTML attribute (" →
"at minimum; ideally use a context-aware auto-escaping template engine rather than manual encoding). - Prefer allow-listing expected values for parameters like "next page" (e.g., validate against a known set of internal paths) rather than reflecting arbitrary user input at all.
- Add a strict Content-Security-Policy that disallows inline event handlers (
script-srcwithout'unsafe-inline') as defense-in-depth — this alone would have neutralized this exact payload even if the encoding bug still existed.
Closing Thoughts
"We don't allow <script> tags" is a common but incomplete mental model for XSS prevention. Attribute-context injection is one of the most frequently underestimated XSS variants precisely because it doesn't look like a script injection at first glance — there's no <script>, no javascript: URI, nothing that trips up a naive blacklist. Testing every reflected parameter in every context it appears in (attribute, tag body, JS string, URL) — not just assuming a filter that blocks tags is sufficient — is what catches bugs like this one before an attacker does.
This write-up describes a vulnerability pattern encountered during authorized security testing. Target details have been generalized in accordance with the relevant program's disclosure policy.
Connect with me:
LinkedIn: linkedin.com/in/reza-sadoughi-439344285
GitHub: github.com/sadoughi