Find the injection
Why it matters XSS hides in three different places, and each one is fixed in a different spot: the server's page, the stored data's display, or the page's own JavaScript. Knowing which kind you have tells you where to put the fix. Testing your own pages for it is a normal part of building a site.
An analogy: three ways a note reaches the stage
Back at the theatre. A note can be handed up and read out straight away, once, to the person who handed it up. It can be pinned to the noticeboard and read out every night to everyone. Or the theatre's own announcer can pick it up from the lobby and read it out without the stage manager ever seeing it. Same problem, three routes.
Reflected XSS: the server takes something from the request, such as ?q= in the address, and puts it straight back into the page it sends. It hits whoever opens that exact address, often someone tricked into clicking a link.
Stored XSS: the text is saved (a review, a comment, a profile name) and shown later to every visitor. One bad entry, many victims. The guestbook in Lesson 58 was stored XSS.
DOM-based XSS: the server's page is fine, but the page's own JavaScript copies untrusted text into the page with a risky method such as innerHTML. The DOM (Document Object Model) is the browser's live, in-memory version of the page that scripts change.
How testers find it: a harmless marker
To check a page, put in a small, harmless piece of HTML with a unique label, such as <b id="probe-123">probe</b>, and then look at what came back. If the page now contains a real <b> element with that id, your text became markup: the page is weak. If it shows the characters <b id=... as text, it escaped them: the page is fine.
Why <b> and not a script? Because the question is "does my text turn into markup at all?", and a bold tag answers it without running anything. It also catches a case a <script> test misses: browsers do not run a <script> element inserted with innerHTML, but they do run event handlers such as onerror on inserted elements (checked October 2026). A page can look safe to a script test and still be weak.
from watched.leg3c_toypages import PracticeSite
site = PracticeSite(seed=3)
page = site.search('<b id="probe-1">probe</b>')
print(page.html) # what the server sent back
print(page.has_element("b", "id", "probe-1")) # True means: weakThe toy site's three pages are site.search(q) (reflected), site.post_review(text) followed by site.reviews_page() (stored), and site.greet(fragment) (DOM-based: its own code copies the part of the address after # into a greeting). Each returns a toy page. Which ones are weak depends on the seed, so you must actually test.
The lab
Write check_site(site), which probes all three and returns {"reflected": ..., "stored": ..., "dom": ...} with True for each weak page. Then write fix_search(q): the search page's HTML, done safely.
Where each fix goes
- Reflected: escape the value in the server's template when you put it in the page.
- Stored: escape when you show the saved text, on every page that shows it (and keep storing it as typed).
- DOM-based: in the page's JavaScript, use
textContentinstead ofinnerHTML.
A common misconception
"The server's HTML is escaped, so we are safe." Not if your own front-end code later puts untrusted text in with innerHTML. DOM-based XSS never touches the server's template, which is why server-side checks miss it.
Only test what you own. Probing your own pages, or practice labs built for it, is good practice. Probing other people's sites without permission is not.
Try it In your own browser, open DevTools on a page you built (F12 or Cmd+Option+I), go to the Console and type document.body.textContent = "<b>hi</b>". The page shows the tags as text. That is what textContent does, every time.
Want to go deeper? PortSwigger's free Web Security Academy has a full XSS track with its own practice labs, built for learning safely.
In other languages
The DOM-based fix in JavaScript, and the same probe idea in a JavaScript test:
// weak: the text after # is parsed as HTML
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById("hello").innerHTML = "Hello, " + name;
// fixed: always text
document.getElementById("hello").textContent = "Hello, " + name;
// a test for your own page: did the marker become a real element?
const weak = document.querySelector('b#probe-1') !== null;Recap
- Reflected: the request's value comes straight back in the page. Fix in the server template.
- Stored: saved text is shown to everyone later. Escape wherever it is shown.
- DOM-based: the page's own JavaScript inserts text with innerHTML. Use textContent.
- A harmless marker tag answers "did my text become markup?" on pages you own.
Write check_site(site): probe a PracticeSite's three pages with a harmless marker such as <b id="probe-123">probe</b> and return a dict {"reflected": bool, "stored": bool, "dom": bool}, True where the marker came back as a real element (page.has_element("b", "id", "probe-123")). Use site.search(marker), site.post_review(marker) then site.reviews_page(), and site.greet(marker). Then write fix_search(q), returning <h1>Search</h1><p>Results for: Q</p> with Q escaped.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.