XSS: someone else’s script in your page
Problem
A search that steals
The shop has a search box. The results page shows what the buyer typed: “Results for: running shoes”. The server glues the search text into HTML:
import html
def search_page(query): return "<p>Results for: <b>" + query + "</b></p>"
print(search_page("running shoes"))print(search_page("<img src=x onerror=\"steal(shopSession)\">"))<p>Results for: <b>running shoes</b></p><p>Results for: <b><img src=x onerror="steal(shopSession)"></b></p>The first line is an ordinary page. In the second, the search is <img src=x onerror="steal(shopSession)">, and it reached the HTML whole. The browser sees an image tag, the image x does not load, and it runs the code in onerror. Here steal is a made-up function; in a real attack the code sends the cookies or the session to the attacker’s server.
The attacker does not type into their own search box. They send buyers a link to a search in the shop with this text in the address. Whoever opens it runs the foreign code in the shop, with their own session. This is XSS (cross-site scripting): someone else’s script in your page.
The idea
HTML is one more language
This is the injection from the previous lesson, in one more language. In HTML < starts a tag, and " closes an attribute value. The code glued the buyer’s text into the browser’s language, and the characters in the text became part of the page.
Two kinds are the most common:
- Reflected: the text comes from the address and goes back in the response, like the search above. The attacker has to get someone to open the link.
- Stored: the text is saved in the database, for example a review, and is shown to everyone who opens the product. The attacker writes once, and the script runs for everyone.
The server ran nothing. The code runs in the buyer’s browser, with the buyer’s session, as if it were part of the shop.
See it
Try it yourself
Type text into the shop’s search, pick a defence and see what happens. The page is in an isolated iframe, with no network, and it sees nothing of this site.
Sandbox: hostile input in a shop page
The page (in an isolated iframe, no network)
What happened
What reaches the browser
The sandbox is isolated: no network, and it sees nothing of the site. steal() here is the attacker’s pretend server; a real attack sends the data with fetch.
Try the three ready inputs with no defence, then with escaping, then with CSP. Look at “What reaches the browser” to see the HTML in each case.
The idea
Escaping the output
The fix is escaping: before you put text into HTML, you replace the five characters HTML interprets with their entities. The browser shows the entity as a character, but does not read it as part of the page:
| Character | Becomes | Why |
|---|---|---|
& |
& |
starts an entity; replaced first |
< |
< |
starts a tag |
> |
> |
ends a tag |
" |
" |
closes an attribute value |
' |
' |
closes a value in single quotes |
html.escape replaces the same five characters (' becomes ', with the same meaning).import html
def search_page(query): return "<p>Results for: <b>" + html.escape(query) + "</b></p>"
print(search_page("running shoes"))print(search_page("<img src=x onerror=\"steal(shopSession)\">"))<p>Results for: <b>running shoes</b></p><p>Results for: <b><img src=x onerror="steal(shopSession)"></b></p>Now the search reached the browser as text. The buyer sees exactly what they typed, but there is no tag.
The rule is escape the output: when showing, at the place where the text enters HTML. Not when receiving: then you do not know where the text will go (HTML, JSON, an email), and the database holds < instead of what the buyer typed.
More precisely, if you are curious
-
In a real project HTML is made with templates, and they escape for you: Jinja2 in Flask for
.htmlfiles, and Django’s templates. The|safefilter andMarkup(…)turn escaping off and are the place to check in a code review. - Escaping depends on the place. The five characters are enough for text between tags and for a quoted attribute value. An unquoted attribute, text inside
<script>, styles and an address inhrefhave other rules. The simplest: put outside text only between tags and in quoted attributes. - The address in
href.javascript:steal(shopSession)has none of the five characters, and still runs code on click. Addresses from users need an allow-list, as in the lesson “Untrusted input”: the address starts withhttps://orhttp://, otherwise it does not become a link.
Try it
Find the field
The reviews page shows a title, an author and a text. Which field reaches the browser raw?
Pick the line with the bug.
The last one: the review text. The title and the author go through html.escape, while text is glued in as it is. This is stored XSS: one review, and the script runs for everyone who opens the product.
Try it
What will it print
What does escaping print for Tom & Jerry <3?
import html
print(html.escape("Tom & Jerry <3"))& becomes &, and < becomes <. The second answer forgot &. The third is an escape that replaces & last: then it also replaces the & in the < it has just added, and the buyer sees <3 on the page instead of <3.
The idea
CSP: the second line
A big site has hundreds of places where text enters HTML. Missing one is enough. So there is a second defence: Content-Security-Policy (CSP). It is a header in the response that tells the browser which scripts may run:
Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'script-src 'self' means: only scripts from files on the same site. A script written inside the page (<script>…</script> or onerror="…") does not run, and the browser records the violation. That is why the site’s own JavaScript has to live in files too. If you do need a script in the page, CSP accepts one with a nonce: a random value, new in every response, that the attacker does not know. That is how the “CSP” defence in the sandbox works.
CSP does not replace escaping. It stops the script, but not foreign HTML: a fake “Log in again” form or a changed price is not a script. Escaping is the first line, CSP is the net below it.
More precisely, if you are curious
- Try it without breaking things.
Content-Security-Policy-Report-Onlyonly reports what the policy would block. That is how CSP is brought into an old site: first you look at the violations, then you switch it on. - The session cookie. Even if the script runs, a cookie with the
HttpOnlyflag is not visible to JavaScript. That comes in the lesson on sessions.
Exercise
The review
Solve the task in your editor.
A review with an author. Write render_review(author, text). It returns <article><h3>author</h3><p>text</p></article>, with both the author and the text escaped. Try an ordinary review and an author called <script>steal(shopSession)</script>:
<article><h3>Ann</h3><p>Comfy <3</p></article><article><h3><script>steal(shopSession)</script></h3><p>Great!</p></article>More tasks
- A link in the review. Add an “author’s website” field to the review. Show it as
<a href="…">only if the address starts withhttps://orhttp://; otherwise show it as plain text. Tryjavascript:steal(shopSession). - Double escaping. What will the buyer see if the review is escaped once when it is saved to the database and once more when it is shown? Try
<3.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Parameters and return · Strings · Untrusted input · Injections: when data becomes a command
- Tasks on this topic: Security for developers