Find a cross-site scripting (XSS) flaw in a small comment board and fix it by treating user input as text.
Why it matters
Cross-site scripting (XSS) is one of the most common web vulnerabilities, and it is part of the OWASP Top 10 under Injection. It happens when an app takes text from a user and puts it into a page in a way that the browser runs as code. The attacker's script then runs in the victim's browser, on your site's origin, so it can read what the page shows, act as the logged-in user, change the page to show a fake login form, or send data to the attacker.
In this lesson you will look at a tiny comment board, find the flaw, and fix it. Everything happens in the sandboxed exercise on the Do tab, which is a system you are allowed to test. Never try XSS payloads on websites you do not own or have written permission to test.
The core idea: data versus markup
A browser reads HTML as a mix of markup (tags such as <b> or <script>) and text. The bug happens when user input crosses from the "text" side to the "markup" side.
Here is the vulnerable pattern in JavaScript:
const comment = input.value; // whatever the user typed
const li = document.createElement('li');
li.innerHTML = comment; // parsed as HTML: the flaw
list.appendChild(li);
If someone types Nice recipe!, all is well. If they type:
<img src="x" onerror="alert(document.domain)">
the browser parses it as a real image tag, the image fails to load, and the onerror handler runs the attacker's JavaScript. A real attacker would replace the alert with code that does something harmful.
The fix is to tell the browser the input is text, not markup:
li.textContent = comment; // shown literally, never parsed as HTML
Now the same input appears on the page as the literal characters <img src="x" ...>, which is harmless.
The three kinds of XSS
| Type | Where the script comes from | Example |
|---|---|---|
| Stored | Saved on the server and shown to every visitor | A comment containing a script, shown to everyone who opens the page |
| Reflected | Part of the request, echoed straight back | /search?q=<script>...</script> sent to a victim as a link |
| DOM-based | Client-side JavaScript moves data from the URL or input into the page | el.innerHTML = location.hash.slice(1) |
All three share the same root cause: untrusted data written into an HTML or JavaScript context without being treated as text.
How defenders prevent XSS
- Use safe APIs. In the browser, prefer
textContent,createElementandsetAttributewith fixed attribute names overinnerHTML,outerHTML,insertAdjacentHTMLanddocument.write. - Let the framework escape. React, Vue, Angular and most server templates escape output by default. Danger starts when you bypass that, for example with React's
dangerouslySetInnerHTML. - Encode for the context. Text inside HTML, an attribute value, a URL and a JavaScript string each need different encoding. Template engines handle this; hand-built strings usually get it wrong.
- Sanitise when you must allow HTML. If users really need formatting, use a well-maintained sanitiser library with an allow list of tags, or offer Markdown rendered by a library that escapes raw HTML.
- Add a Content Security Policy. A CSP header such as
script-src 'self'stops inline scripts and scripts from other sites, which blocks many XSS payloads even if a bug slips through. It is a second layer, not a replacement for fixing the bug. - Protect session cookies.
HttpOnlycookies cannot be read by JavaScript, which limits what a successful XSS can steal.
Worked example: reviewing the code
When you review code for XSS, search for the dangerous sinks:
grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write\|dangerouslySetInnerHTML" src/
For each hit, trace backwards: where does the value come from? If any part of it comes from a user, the URL, a database field users can edit, or another system, treat it as untrusted. Then either switch to a safe API or prove the value is sanitised.
A quick way to test your own form during development is to submit a harmless marker such as <b id="probe">hi</b>. If the word "hi" appears in bold, the input is being parsed as HTML, and a real payload would work too. If you see the literal tags, the input is being treated as text.
Common mistakes
- Blocklisting
<script>. Attackers have hundreds of other ways in (onerror,onload,javascript:URLs, SVG). Fix the sink instead. - Escaping on input and then decoding later. Store the original text and encode it at output time, for the context it is used in.
- Thinking HTTPS prevents XSS. The payload arrives over HTTPS like any other request.
- Trusting data from your own database. Stored XSS is exactly that: data your app saved earlier.
Quick recap
- XSS happens when user input is parsed as HTML or JavaScript in someone's browser.
innerHTMLwith untrusted data is the classic sink;textContentis the classic fix.- Stored, reflected and DOM-based XSS share one root cause.
- Layer defences: safe APIs, framework escaping, sanitisers when HTML is needed, CSP and HttpOnly cookies.
Try it
Open the Do tab, find the flaw in the comment board and fix it.