Syllabus Lesson 61 of 156 · Script injection (XSS)
Script injection (XSS)

Why blacklists fail

Why it matters A tempting shortcut is to "remove the bad stuff" from what visitors type. It feels safe and it is not: you have to guess every bad thing in advance, and you damage honest text along the way. Seeing it fail on a tiny example saves you from shipping it.

An analogy: a bouncer with a list of faces

A bouncer with a list of ten banned faces stops those ten people. Anyone not on the list walks in, including the eleventh troublemaker, and an honest guest who happens to look like someone on the list gets turned away. Escaping is a different idea entirely: everybody gets in, but nobody gets to go backstage.

Blacklist filter: code that searches text for things known to be bad and deletes or rejects them. Also called a blocklist or denylist.

Our toy filter

For this lab we wrote a deliberately simple filter in watched.leg3c_toypages. It only deletes the exact text <script> and </script>:

def toy_filter(text):
    return text.replace("<script>", "").replace("</script>", "")

def filtered_comment(text):
    return '<p class="comment">' + toy_filter(text) + "</p>"

Here are the four cases in FILTER_CASES, run through it:

TypedScript ran?Text kept?
<script>alert(1)</script>NoNo: shows alert(1)
<SCRIPT>alert(1)</SCRIPT>YesNo
<img src="x" onerror="alert(1)">YesNo
Tip: put the <script> tag at the end of the body.NoNo: the word vanished

What each row teaches:

  • Row 1: the filter catches the one thing it was written for. This is why filters look like they work in a quick test.
  • Row 2: HTML tag names ignore letter case, but our filter does not. One missed variation is enough.
  • Row 3: script does not need a <script> tag at all. Event-handler attributes run script too, and there are many of them.
  • Row 4: an honest visitor writing about HTML had their words eaten. Filters hurt real users.

You could patch the filter for each row. Then there would be a fifth case, and a sixth. The list of things HTML can do is long and keeps growing, so a filter is always one step behind. That race is not one to enter; this course does not go further into it.

The fix: escape, do not filter

Escaping does not need a list of bad things. It changes the five special characters, so nothing typed can become a tag or an attribute. Every row above becomes: script ran No, text kept Yes. Honest text survives because nothing is deleted.

The lab

Write escaped_comment(text), and compare(cases), which measures both approaches on a list of cases and returns one dict per case.

A common misconception

"Filters are fine as a second layer." Defence in depth is good, but the second layer should be something designed for it, such as the Content Security Policy in the next module (Lesson 64), not a word list that also breaks honest text. Allow-lists have their place for structure ("this field is a number", "links must start with https://") or for rich text through a well-tested sanitiser library. They do not replace escaping.

Try it After you pass, add your own harmless case to the list, such as {"label": "maths", "text": "if a < b and b > c"}, and run compare again. Escaping keeps it exactly; check what the filter does.

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 same comparison in JavaScript. The filter is a toy; do not copy it.

// toy filter (bad idea)
const toyFilter = (t) => t.replaceAll("<script>", "").replaceAll("</script>", "");
el.innerHTML = toyFilter(comment);       // still runs <img onerror>

// the fix
el.textContent = comment;                // nothing typed becomes markup

// Go: html/template escapes for you; never build HTML with string glue
tmpl := template.Must(template.New("c").Parse(`<p class="comment">{{.}}</p>`))
tmpl.Execute(w, comment)

Recap

  • A blacklist filter must guess every bad input in advance, and HTML has endless variations.
  • Our toy filter missed an uppercase tag and an event handler, and ate an honest visitor's words.
  • Escaping needs no list: nothing typed can become markup, and nothing is deleted.
  • Use allow-lists for structure (numbers, link schemes), and escaping for text.
Your turn

Write escaped_comment(text), returning <p class="comment"> + the text escaped with html.escape + </p>. Then write compare(cases): for each case dict (keys label and text) load both filtered_comment(text) and escaped_comment(text) in the toy browser and return a list of dicts with keys label, filter_ran, escape_ran (the pages' script_ran), filter_kept and escape_kept (True when the page's text equals the typed text exactly), in the same order as the cases.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output