Skip to content
wannacode

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:

static String searchPage(String query) {
return "<p>Results for: <b>" + query + "</b></p>";
}
System.out.println(searchPage("running shoes"));
System.out.println(searchPage("<img src=x onerror=\"steal(shopSession)\">"));
Output
<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

Defence

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
& &amp; starts an entity; replaced first
< &lt; starts a tag
> &gt; ends a tag
" &quot; closes an attribute value
' &#39; closes a value in single quotes
The Java standard library has no such function, so we write it:
static String escape(String text) {
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
static String searchPage(String query) {
return "<p>Results for: <b>" + escape(query) + "</b></p>";
}
System.out.println(searchPage("running shoes"));
System.out.println(searchPage("<img src=x onerror=\"steal(shopSession)\">"));
Output
<p>Results for: <b>running shoes</b></p>
<p>Results for: <b>&lt;img src=x onerror=&quot;steal(shopSession)&quot;&gt;</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 &lt; 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. In Thymeleaf th:text escapes and th:utext does not. In JSP <c:out> escapes, while ${…} on its own does not. For escaping by hand there are ready libraries: the OWASP Java Encoder or HtmlUtils.htmlEscape in Spring.
  • 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 in href have 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 with https:// or http://, 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.

Try it

What will it print

What does escaping print for Tom & Jerry <3?

System.out.println(escape("Tom & Jerry <3"));
Answer

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-Only only 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 HttpOnly flag 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 renderReview(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>:

Expected
<article><h3>Ann</h3><p>Comfy &lt;3</p></article>
<article><h3>&lt;script&gt;steal(shopSession)&lt;/script&gt;</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 with https:// or http://; otherwise show it as plain text. Try javascript: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

1. The search only shows what the buyer typed. Why does <img src=x onerror="…"> in the search run code?
2. Escaping turns < into &lt;. What does the browser do with &lt;?
3. Why does escaping replace & first, before < and >?
4. A review has a link <a href="…"> with an address from the buyer, escaped. The buyer types javascript:steal(shopSession). Does escaping stop it?
5. The page has CSP: script-src 'self'. Do you still need escaping?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·