Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 8 – Files, paths and data formats

XML and HTML parsing without security holes

Read and build XML with ElementTree, refuse the DOCTYPE features behind XML attacks, escape user text with html.escape and parse simple HTML in Python.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 20 minutes

What you will learn

  • Parse and build XML with xml.etree.ElementTree
  • Explain the XML attacks the Python documentation lists, such as entity expansion, and their defences
  • Write a parser for untrusted XML that refuses any DOCTYPE and any document over a size limit
  • Use html.escape for HTML output, once, at the moment a page is built
  • Extract links from simple HTML and keep only the schemes on an allowlist

Before you start

What you’ll learn · Before you start

What you will learn

  • Parse and build XML with xml.etree.ElementTree
  • Explain the XML attacks the Python documentation lists, such as entity expansion, and their defences
  • Write a parser for untrusted XML that refuses any DOCTYPE and any document over a size limit
  • Use html.escape for HTML output, once, at the moment a page is built
  • Extract links from simple HTML and keep only the schemes on an allowlist

Before you start

On this page

A small XML document can declare entities that expand into several gigabytes of text, as the Python documentation warns, and a one-line comment from a reader can add a heading, or worse, to every page that shows it. Python reads XML with xml.etree.ElementTree and helps with HTML through html.escape() and html.parser.HTMLParser; the everyday work is easy, and the two habits that keep it safe are short: refuse what you do not need when reading XML, and escape what you write into HTML.

You will read and build XML with ElementTree, see where the XML tricks live and write a parser that refuses them, escape readers’ text for HTML at the right moment, and collect the links of an untidy page, keeping only safe ones.

An XML document is a tree. Every element (XML): One node of an XML tree: a tag such as item, optional attributes such as version="2.0", optional text and child elements. ElementTree gives each element its tag, attrib, text and children. has a tag (item), optional attributes (version="2.0"), optional text and child elements. The example reads an RSS-style feed of notices and then builds a new document from it:

Read a feed, then build a document from it Python · feed_titles.py
import xml.etree.ElementTree as ET

# An RSS-style feed, as a server might send it.
feed = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Campus notices</title>
    <item>
      <title>Library opens late on Friday</title>
      <link>https://example.org/notices/library</link>
      <category>library</category>
    </item>
    <item>
      <title>Hostel fees &amp; deadlines</title>
      <link>https://example.org/notices/fees</link>
      <category>hostel</category>
      <category>fees</category>
    </item>
    <item>
      <title>Sports day volunteers</title>
    </item>
  </channel>
</rss>"""

root = ET.fromstring(feed)               # the <rss> element
print(root.tag, root.get("version"))     # attributes come from get()
channel = root.find("channel")           # the first matching child
print("feed:", channel.findtext("title"))

for item in channel.findall("item"):     # every matching child, in order
    title = item.findtext("title")
    link = item.findtext("link", default="(no link)")
    tags = [c.text for c in item.iter("category")]
    print(f"- {title} | {link} | {tags}")

# Building XML: elements, attributes and text. ElementTree escapes & and < itself.
out = ET.Element("notices", count=str(len(channel.findall("item"))))
for item in channel.findall("item"):
    ET.SubElement(out, "notice").text = item.findtext("title")
ET.indent(out)
print(ET.tostring(out, encoding="unicode"))

Output

rss 2.0
feed: Campus notices
- Library opens late on Friday | https://example.org/notices/library | ['library']
- Hostel fees & deadlines | https://example.org/notices/fees | ['hostel', 'fees']
- Sports day volunteers | (no link) | []
<notices count="3">
  <notice>Library opens late on Friday</notice>
  <notice>Hostel fees &amp; deadlines</notice>
  <notice>Sports day volunteers</notice>
</notices>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 feed_titles.py

The feed is the tree in the diagram: rss at the root, its channel, and under the channel its own title and three items:

The feed as a tree: rss holds channel, which holds the channel title and three item elements, each with its own children.rssattribute version = 2.0channelthe children of channel, in ordertitle: Campus noticesitem 1: Library opens late on Fridayone link, one category: libraryitem 2: Hostel fees & deadlinesone link, two categories: hostel, feesitem 3: Sports day volunteersno link, no category

The tree ElementTree builds from the feed

Text description of the diagram

The diagram is a column, from the root of the tree down.

  1. The root element, rss, with the attribute version set to 2.0.
  2. Its only child, channel.
  3. The children of channel, in the order of the document: first the channel's own title element, whose text is Campus notices, then three item elements. Item 1 has the title Library opens late on Friday, one link and one category, library. Item 2 has the title Hostel fees & deadlines, one link and two categories, hostel and fees. Item 3 has only a title, Sports day volunteers: no link and no category.

Each call of the example looks at a different part of that tree:

Call in the example What it returns
ET.fromstring(feed) the root element, rss
root.get("version") the attribute’s value, '2.0'
root.find("channel") the first child named channel
channel.findtext("title") 'Campus notices', the channel’s own title
channel.findall("item") the three items, in document order
item.findtext("link", default="(no link)") '(no link)' for the third item
item.iter("category") the second item’s two category elements, hostel and fees

find() and findall() look one level down unless the path says more, such as "channel/item" or ".//category" for any depth, while iter() walks every matching element below, however deep. For a file, ET.parse("feed.xml") returns an ElementTree whose getroot() is the same root element. Entities such as &amp; arrive decoded: the second title reads Hostel fees & deadlines.

For files too big to hold as one tree, ET.iterparse(path, events=("end",)) hands over each element as soon as it is complete. The tree still grows as it is read, so call elem.clear() once you have used an element, and for a long run of records also remove each finished one from its parent, as the documentation’s pull-parsing section shows. Namespaced documents, such as Atom feeds, write tags as {http://www.w3.org/2005/Atom}entry; pass a namespaces dictionary to find() and findall() to use short prefixes instead.

Checkpoint

Check: Reading an XML tree

  1. Question 1 of 2 These two lines are added at the end of feed_titles.py. What do they print?

    Read the code, then choose one answer.

    root = ET.fromstring(feed)
    print(root.find("item"), len(root.findall("channel/item")))
    Show the answer: Question 1 of 2

    Answer: None 3

    find() and findall() look at the children of the element they are called on unless the path says more. The items sit under channel, so root.find("item") is None and root.findall("channel/item") finds all three.

  2. Question 2 of 2 The third item of the feed has no <link>. What do item.findtext("link") and item.findtext("link", default="(no link)") return for it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None, and '(no link)'

    findtext(path) returns the text of the first matching child, None when there is none, or the default when you pass one, which is how the example prints (no link) for the third notice.

Element and SubElement build XML that escapes itself

The last part of the first example builds a document. ET.Element(tag, attributes) makes the root, ET.SubElement(parent, tag) adds a child and returns it, and assigning .text sets its text. ET.indent(), added in Python 3.9, adds line breaks and spaces so the result is readable, and ET.tostring(elem, encoding="unicode") returns a string. To save a file, wrap the root in ET.ElementTree(root) and call write(path, encoding="utf-8", xml_declaration=True).

Notice the &amp; in its output: ElementTree escapes &, < and > in text and attribute values itself. The next example builds the same kind of document twice, once by joining strings and once with SubElement, and checks each result by parsing it back:

Joined strings against SubElement, checked by parsing back Python · build_vs_join.py
import xml.etree.ElementTree as ET

titles = ["Library opens late on Friday", "Hostel fees & deadlines", "Exam in room <B12>"]

# Joining strings: the titles go into the document exactly as they are.
joined = "<notices>" + "".join(f"<notice>{t}</notice>" for t in titles) + "</notices>"

# Building elements: ElementTree escapes &, < and > in text as it writes.
root = ET.Element("notices")
for t in titles:
    ET.SubElement(root, "notice").text = t
built = ET.tostring(root, encoding="unicode")

# The check: parse each document back and compare the titles with the ones that went in.
for label, text in [("joined strings", joined), ("SubElement", built)]:
    try:
        back = [n.text for n in ET.fromstring(text)]
        print(f"{label}: parses, titles come back unchanged: {back == titles}")
    except ET.ParseError as err:
        print(f"{label}: not XML, ParseError: {err}")
print(built)

Output

joined strings: not XML, ParseError: not well-formed (invalid token): line 1, column 75
SubElement: parses, titles come back unchanged: True
<notices><notice>Library opens late on Friday</notice><notice>Hostel fees &amp; deadlines</notice><notice>Exam in room &lt;B12&gt;</notice></notices>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_vs_join.py

The two titles that cause the trouble, and what each way of building did with them:

Title Joined into the string Written by SubElement
Hostel fees & deadlines a bare &, which XML does not allow Hostel fees &amp; deadlines
Exam in room <B12> a <B12> tag nobody meant Exam in room &lt;B12&gt;

The joined document is not XML at all: the parser stops at the second title’s &. The built one parses back to exactly the titles that went in. Build XML this way rather than by joining strings, for the same reason that SQL is built with placeholders: the library knows the escaping rules, and string formatting does not.

Checkpoint

Check: Building XML that stays XML

  1. One question Code builds XML with f"<notice>{title}</notice>", and one title is "Hostel fees & deadlines". What happens when another program parses the result?

    Choose one answer.

    Show the answer: One question

    Answer: It is refused as not well-formed, because a bare & is not allowed in XML text

    Joined strings carry the titles into the document unchanged, so a & or a < breaks the structure. Elements built with SubElement are written with &amp; and &lt;, and parse back to exactly the titles that went in.

The entity tricks of untrusted XML live in the DOCTYPE

XML is older and richer than the data it usually carries. A document may start with a DOCTYPE: A document type declaration near the start of an XML document, <!DOCTYPE …>, which may declare entities. Feeds, invoices and API responses never need one, so code that parses untrusted XML can refuse it., a document type declaration, and inside it declare entity (XML): A named piece of text that a reference such as &name; stands for. &amp;, &lt;, &gt;, &quot; and &apos; are built in; any other entity is declared in a DOCTYPE, which the expansion tricks depend on.: named pieces of text that &name; later expands. That feature is the root of the first two attacks of the Python documentation’s XML security section; the other two need no DOCTYPE:

  • Exponential entity expansion, also called “billion laughs”: entities that refer to other entities several times each, so a tiny file expands into gigabytes of text.
  • Quadratic blowup: one large entity repeated thousands of times, which avoids limits on nesting.
  • Large tokens: a token so long that the parser re-reads it over and over, which costs quadratic time.
  • Decompression bombs: a small compressed stream, such as a gzipped upload, that unpacks into an enormous one. The documentation names xmlrpc as vulnerable to it.

Python’s parsers are built on the Expat library. The documentation states that Expat does not open local files or network connections by default, and that Expat versions below 2.7.2 may be vulnerable to the first three problems. Python bundles a copy of Expat, but it can also be built against the operating system’s copy, so the Python version alone does not tell you which Expat runs: check pyexpat.EXPAT_VERSION on the machines that run your code.

XML from outside is checked for size and for a DOCTYPE before parsing; values that go into a page are escaped first.XML from outside(an upload, a feed, an API)Too large?Has a DOCTYPE?ElementTree builds the tree;your code reads plain valueshtml.escape() every valuethat goes into a pageHTML pageRefuse the documentwith a clear erroryesnoyesno

Two checks on the way in, escaping on the way out

Text description of the diagram

The flowchart follows text from outside the program, from top to bottom.

  1. XML arrives from outside: an upload, a feed or an API response.
  2. The first check asks whether the document is too large. If it is, the document is refused with a clear error.
  3. The second check asks whether the document declares a DOCTYPE. If it does, it is refused as well, because a DOCTYPE is where entity declarations live, and untrusted data has no need for them.
  4. Only then does ElementTree build the tree, and the program reads plain values from it.
  5. Every value that goes into an HTML page passes through html.escape() first, and only then becomes part of the page.

The defence that removes the entity tricks altogether is simple, because feeds, invoices and API responses never need a DOCTYPE: refuse any document that has one. The OWASP cheat sheet on XML entity attacks also advises switching DTDs off wherever possible, and points out that this alone does not stop every denial of service, so a size limit belongs beside it. The diagram puts both checks on the way in, and the escaping of the later sections on the way out.

Checkpoint

Check: Where XML tricks live

  1. Question 1 of 2 What do the entity-expansion tricks of the Python documentation's XML security section need in the document?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A DOCTYPE that declares entities

    Exponential entity expansion and quadratic blowup both work through declared entities, and declarations live in the DOCTYPE. Data formats such as feeds and invoices never need one.

  2. Question 2 of 2 Your servers run Python 3.14. How do you find out whether their XML parser is affected by the Expat versions the documentation warns about?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Print pyexpat.EXPAT_VERSION with the interpreter that runs the code, on those servers

    The documentation says Expat below 2.7.2 may be vulnerable and that Python can use a bundled or a system copy. pyexpat.EXPAT_VERSION reports the one the interpreter actually uses, expat_2.8.5 in the lesson's run.

A parser for untrusted XML refuses any DOCTYPE

ElementTree builds its tree through a target, normally a TreeBuilder. A target may define a doctype() method, and the parser calls it when it reaches a DOCTYPE. A subclass whose doctype() raises therefore stops parsing right there, before the declarations inside are used. The example runs four samples through such a parser, then gives the sample with the DOCTYPE to ET.fromstring() without the guard:

A parser for untrusted XML: a size limit and no DOCTYPE Python · refuse_doctype.py
import pyexpat
import xml.etree.ElementTree as ET

print("Expat:", pyexpat.EXPAT_VERSION)


class NoDoctype(ET.TreeBuilder):
    """Builds the tree as usual, but refuses any document that has a DOCTYPE."""

    def doctype(self, name, pubid, system):
        raise ValueError(f"a DOCTYPE is not accepted here (found {name!r})")


def parse_untrusted(text, max_chars=10_000):
    """Parse XML from outside the program: a size limit first, then no DOCTYPE at all."""
    if len(text) > max_chars:
        raise ValueError(f"document too large: {len(text):,} characters")
    return ET.fromstring(text, parser=ET.XMLParser(target=NoDoctype()))


samples = {
    "plain": "<order id='17'><item>rice</item><item>tea</item></order>",
    "with DOCTYPE": '<!DOCTYPE order [<!ENTITY shop "Gupta Stores">]><order>&shop;</order>',
    "too large": "<log>" + "x" * 20_000 + "</log>",
    "broken": "<order><item>rice</order>",
}
for label, text in samples.items():
    try:
        root = parse_untrusted(text)
        print(f"{label}: accepted, {len(root)} child elements")
    except ValueError as err:
        print(f"{label}: refused, {err}")
    except ET.ParseError as err:
        print(f"{label}: not well-formed, {err}")

# The same document without the guard: fromstring() accepts the DOCTYPE and expands the entity it declares.
unguarded = ET.fromstring(samples["with DOCTYPE"])
print(f"without the guard: accepted, &shop; became {unguarded.text!r}")

Output

Expat: expat_2.8.5
plain: accepted, 2 child elements
with DOCTYPE: refused, a DOCTYPE is not accepted here (found 'order')
too large: refused, document too large: 20,011 characters
broken: not well-formed, mismatched tag: line 1, column 19
without the guard: accepted, &shop; became 'Gupta Stores'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refuse_doctype.py

Version note

The first line differs in the browser, which runs the Expat that Pyodide bundles, 2.7.3, instead of the 2.8.5 linked into this lesson’s CPython 3.14.8. Both are above the 2.7.2 that the documentation names; the rest of the output is the same.

In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:

Expat: expat_2.7.3
plain: accepted, 2 child elements
with DOCTYPE: refused, a DOCTYPE is not accepted here (found 'order')
too large: refused, document too large: 20,011 characters
broken: not well-formed, mismatched tag: line 1, column 19
without the guard: accepted, &shop; became 'Gupta Stores'

Step through the four samples, and the one without the guard, check by check:

Interactive · stage by stage Four documents through parse_untrusted() Step through the samples of refuse_doctype.py. Each one goes down the checks until it is refused or becomes a tree.
Four documents through parse_untrusted(), step 5 of 5. Showing Text from outside (plain: an order), Over 10,000 characters?, XMLParser reads it, A DOCTYPE? (no guard: entities expand), Well-formed?, A tree of plain values. In focus: A DOCTYPE?.Text from outsideplain: an orderOver 10,000 characters?checked before parsingXMLParser reads itwith target=NoDoctype()A DOCTYPE?no guard: entities expandWell-formed?else ET.ParseErrorA tree of plain valuesfind(), findtext(), iter()&shop; became 'Gupta Stores'

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. too large. A log element with 20,011 characters is refused before the parser reads any of it, so an oversized document costs almost nothing.
  2. with DOCTYPE. This document declares an entity in a DOCTYPE. The parser calls doctype() as soon as it reaches the DOCTYPE, and NoDoctype's doctype() raises, so the declaration is never used.
  3. broken. <order><item>rice</order> closes order while item is still open. The parser gives up with ParseError, mismatched tag, at line 1, column 19.
  4. plain. An order with two items, no DOCTYPE and well under the limit passes every check, and the root of its tree has 2 child elements.
  5. Without the guard. Parsed by ET.fromstring() alone, the document with the DOCTYPE is accepted and &shop; becomes 'Gupta Stores'. Harmless here, it is the feature that every entity trick of the documentation depends on.

The plain order is accepted. The document with a DOCTYPE is refused even though its entity is harmless, which is the point: the rule needs no judgement about what a DOCTYPE contains. Without the guard the same document was accepted and &shop; expanded to 'Gupta Stores', the feature every entity trick depends on. The size check runs before any parsing, so an oversized document costs nothing, and malformed XML still raises ET.ParseError, with the line and column where the parser gave up. Callers can catch ValueError and ET.ParseError and answer with a clear error message.

For a ready-made alternative, the third-party package defusedxml offers drop-in versions of the standard parsers. By default it refuses entity declarations and external references, and with forbid_dtd=True it refuses any DOCTYPE, as the example does. The OWASP cheat sheet recommends it for untrusted XML in Python. Whichever you use, keep the limits that no parser setting replaces: a maximum size, a time limit for the request, and iterparse() with clear() for large documents.

Key idea

Refuse what untrusted XML never needs: check the size before parsing, refuse any DOCTYPE while parsing, and read only plain values from the tree.

Checkpoint

Check: A parser for untrusted XML

  1. Question 1 of 2 Why does parse_untrusted() compare the length of the text with the limit before it parses anything?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An oversized document is then refused without any parsing work at all

    Check what is cheap first: the length costs nothing to measure, while parsing a huge document can cost memory and time. A DOCTYPE refusal does not replace the size limit, because a document without a DOCTYPE can still be huge.

  2. Question 2 of 2 A partner says: "Our DOCTYPE only declares a harmless company name, so please accept it." What does the lesson's parser do, and is that right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It refuses the document, and that is right, because the rule needs no judgement about what a DOCTYPE contains

    Refusing any DOCTYPE is simple to state, simple to test and impossible to talk around. The partner can send the company name as ordinary element text instead.

Python Online Compiler Paste refuse_doctype.py and try your own documents against the size limit and the DOCTYPE rule.

html.escape() keeps a reader’s text from becoming markup

The problem in HTML runs the other way: your program writes text into a page, and if that text contains markup, the browser reads it as markup. html.escape(s) replaces &, < and > with the entities &amp;, &lt; and &gt;, and, with quote=True, the default, both quote characters too:

The same comment pasted in raw and escaped Python · escape_text.py
from html import escape, unescape

name = 'Priya "PJ" D\'Souza'
comment = "Use <h1> for the title & <p> for each paragraph."

# Pasted in as it is, the comment's tags become real tags in the page.
print(f"<p>{comment}</p>")
# Escaped, the browser shows the same characters as text.
print(f"<p>{escape(comment)}</p>")
# Inside a quoted attribute, the quotes in the name must be escaped too (quote=True is the default).
print(f'<span title="{escape(name)}">{escape(name, quote=False)}</span>')
print(unescape(escape(comment)) == comment)

Output

<p>Use <h1> for the title & <p> for each paragraph.</p>
<p>Use &lt;h1&gt; for the title &amp; &lt;p&gt; for each paragraph.</p>
<span title="Priya &quot;PJ&quot; D&#x27;Souza">Priya "PJ" D'Souza</span>
True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 escape_text.py

These are the five characters it replaces, and where each one matters:

Character escape() writes Where it matters
& &amp; text and attribute values
< &lt; text and attribute values
> &gt; text and attribute values
" &quot; quoted attribute values, with quote=True
' &#x27; quoted attribute values, with quote=True

The first line of the output shows the problem: pasted in as it is, the comment’s <h1> and <p> would become real elements of the page and change its layout, the first step of the weakness called cross-site scripting: A weakness in which text from one user reaches a page without escaping, so the browser reads it as markup and can run code in other visitors' browsers. Escaping output for its place in the page prevents it.. Escaped, the browser shows the exact characters the reader typed. The third line shows the attribute case: a title attribute is wrapped in double quotes, so a double quote inside the name would end the attribute early, and escaping it to &quot; keeps the value whole. Between tags, quote=False is enough, as the visible name shows. html.unescape() turns entities back into characters.

Where escaping happens matters as much as whether it happens. Follow one comment from the reader who writes it to the reader who sees it:

Interactive · message by message A reader's comment, stored as typed and escaped on the way out Step through one comment from the reader who writes it to the reader who sees it. Watch where escape() runs.
A reader's comment, stored as typed and escaped on the way out, step 6 of 6. Messages so far: Priya, who writes to Your server: Use <h1> for the title & …; Another reader to Your server: opens the comments page; Your server to Another reader: &lt;h1&gt; … &amp; ….Priya, who writesposts a commentYour serverescape() as it buildsAnother readeropens the pageUse <h1> for the title & …opens the comments page&lt;h1&gt; … &amp; …raw: <h1> becomes a heading

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The comment. Priya posts "Use <h1> for the title & <p> for each paragraph." The comment explains HTML, so it contains tags.
  2. Stored as typed. The server stores the comment exactly as it was typed. Escaped now, the database would hold &lt; instead of <, and escaping again when the page is built would show readers &lt; on the page.
  3. A page is requested. Another reader opens the page of comments, and the server builds it for them.
  4. Escaped on the way out. The server puts escape(comment) into the page, so <h1> becomes &lt;h1&gt; and & becomes &amp;.
  5. Shown as text. The browser turns the entities back into characters and shows exactly what Priya typed, with no new heading on the page.
  6. Without escape(). Pasted in raw, the comment's <h1> and <p> would become real elements of the page and change its layout. A comment written to add elements that run code would reach every reader the same way.

Three rules keep escaping reliable. Escape at the moment you write into the page, not when you store the text, so the database keeps what the user typed and nothing is escaped twice. Quote every attribute value, because an unquoted value ends at the first space. And remember what escaping cannot do: it keeps text from becoming markup, but it does not make a link safe or code inside a <script> element safe. The OWASP cheat sheet on cross-site scripting asks for an encoder that matches each context, and for links, an allowlist: A list of what is accepted, such as the URL schemes http and https; anything not on it is refused. Unlike a denylist, it also refuses values nobody thought of. of http and https addresses. Most template engines can escape values automatically, a setting called autoescaping: A template-engine setting that escapes every value put into an HTML template unless the template marks it as safe, so a forgotten call to an escape function cannot let markup through.; check that it is switched on.

Escaping twice

escape(escape("Fees & forms")) gives Fees &amp;amp; forms, and the reader sees Fees &amp; forms on the page. Store the text as typed and escape it once, when the page is built.

Checkpoint

Check: Escaping text for HTML

  1. Question 1 of 2 name = 'Priya "PJ" D\'Souza'. Which line keeps the whole name inside the title attribute?

    Read the code, then choose one answer.

    a = f'<span title="{escape(name)}">'
    b = f'<span title="{escape(name, quote=False)}">'
    Show the answer: Question 1 of 2

    Answer: a, because quote=True, the default, escapes the double quotes inside the name

    Inside a quoted attribute, the quote characters matter, so use escape(name), which escapes both kinds. Between tags, quote=False is enough, which is why the example uses it for the visible name.

  2. Question 2 of 2 When should a program run html.escape() on a comment that a reader posts?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When it writes the comment into a page, not when it stores it

    Escape at the moment you write into the page, for the place in the page the value goes. Stored text stays as the reader typed it.

HTML Entity Encoder & Decoder See which entity each character becomes, and compare it with what html.escape() produces.

HTMLParser calls your methods, tag by tag

html.parser.HTMLParser reads HTML and calls methods you define: handle_starttag(tag, attrs), handle_endtag(tag) and handle_data(data). Tag names arrive in lower case and attributes as a list of (name, value) pairs, and with convert_charrefs=True, the default, entities such as &amp; arrive as plain characters. The example collects a page’s title and its links:

Collect the title and links of an untidy page Python · link_parser.py
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit


class LinkCollector(HTMLParser):
    """Collects the page title and every link's address and text."""

    def __init__(self):
        super().__init__()  # convert_charrefs=True: &amp; and friends arrive as plain characters
        self.title = ""
        self.links = []
        self._in_title = False
        self._href = None

    def handle_starttag(self, tag, attrs):
        if tag == "title":
            self._in_title = True
        elif tag == "a":
            self._href = dict(attrs).get("href")
            self.links.append([self._href, ""])

    def handle_endtag(self, tag):
        if tag == "title":
            self._in_title = False
        elif tag == "a":
            self._href = None

    def handle_data(self, data):
        if self._in_title:
            self.title += data
        elif self._href is not None:
            self.links[-1][1] += data.strip()


# Real pages are untidy: upper-case tags, a missing </p>, an entity inside an attribute.
page = """<HTML><head><title>Timetable &amp; rooms</title></head>
<body><p>Lectures move to <A HREF="/rooms?block=B&amp;floor=2">Block B</A>
<p>Questions? <a href="mailto:office@example.org">Write to the office</a>
or see <a href="https://example.org/faq">the FAQ</a>, <a href="ftp://files.example.org/old">the old files</a>.
</body></HTML>"""

parser = LinkCollector()
parser.feed(page)
parser.close()
print("title:", parser.title)
base = "https://college.example.org/notices/"
for href, text in parser.links:
    url = urljoin(base, href)  # a relative link becomes a full address
    keep = urlsplit(url).scheme in {"http", "https"}  # an allowlist of schemes, never a denylist
    print(f"{'keep' if keep else 'skip'}  {text:<20} {url}")

Output

title: Timetable & rooms
keep  Block B              https://college.example.org/rooms?block=B&floor=2
skip  Write to the office  mailto:office@example.org
keep  the FAQ              https://example.org/faq
skip  the old files        ftp://files.example.org/old

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 link_parser.py

Step through the first calls the parser makes on that untidy page:

Interactive · message by message The calls HTMLParser makes while it reads the page Step through the start of link_parser.py's page. Each message is one call from the parser to a method you wrote.
The calls HTMLParser makes while it reads the page, step 6 of 6. Messages so far: HTMLParser to LinkCollector: starttag('title', []); HTMLParser to LinkCollector: data('Timetable & rooms'); HTMLParser to LinkCollector: starttag('a', [('href', …)]); HTMLParser to LinkCollector: data('Block B'); HTMLParser to LinkCollector: endtag('a'); HTMLParser to LinkCollector: starttag('p', []).HTMLParserreads the page in orderLinkCollector1 link: Block Bstarttag('title', [])data('Timetable & rooms')starttag('a', [('href', …)])data('Block B')endtag('a')starttag('p', [])no endtag for the first p

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A start tag. The parser meets <title> and calls handle_starttag('title', []). LinkCollector notes that it is now inside the title.
  2. Text, decoded. The text Timetable &amp; rooms arrives through handle_data() as "Timetable & rooms": with convert_charrefs=True, the default, entities come as plain characters.
  3. Upper case becomes lower. <A HREF="/rooms?block=B&amp;floor=2"> arrives as handle_starttag('a', [('href', '/rooms?block=B&floor=2')]): the tag and the attribute name in lower case, the entity in the value decoded.
  4. The link's text. handle_data('Block B') comes while LinkCollector holds a link's address, so it becomes that link's text.
  5. The end tag. </A> arrives as handle_endtag('a'), and LinkCollector stops collecting text for that link.
  6. A missing </p>. The first <p> is never closed. The parser does not mind; it reports the next <p> as another start tag and carries on, because it reports what it meets instead of checking the document.

The page has upper-case tags, a missing </p> and an entity inside an attribute, and the parser copes with all three, because it reports what it sees instead of insisting on a correct document. HTMLParser suits small jobs on pages you know. For scraping many untidy real-world pages, the third-party package BeautifulSoup builds a searchable tree and finds elements by tag, class or CSS selector, and it can use the standard library’s parser underneath. Regular expressions are a poor fit for HTML: attributes in any order, comments and optional closing tags break them.

Checkpoint

Check: Reading HTML with callbacks

  1. One question HTMLParser reads <A HREF="/rooms?block=B&amp;floor=2">. What does handle_starttag() receive?

    Choose one answer.

    Show the answer: One question

    Answer: 'a' and [('href', '/rooms?block=B&floor=2')]

    handle_starttag(tag, attrs) gets the tag name in lower case and the attributes as a list of (name, value) pairs, with names lower-cased and references such as &amp; decoded.

The collector’s last loop decides which of the four links to keep. urljoin() turns the relative link into a full address, and urlsplit(url).scheme gives each link’s scheme, which the code checks against an allowlist: it keeps http and https and skips the rest, here a mailto: and an ftp: link. Step through the four links:

Interactive · stage by stage Four links through the scheme allowlist Step through the links of link_parser.py. Each one is made a full address, and only http and https addresses are kept.
Four links through the scheme allowlist, step 5 of 5. Showing The href from the page (any other scheme), urljoin(base, href), urlsplit(url).scheme (not http or https), In {http, https}?, Keep the link. In focus: In {http, https}?.The href from the pageany other schemeurljoin(base, href)a full addressurlsplit(url).schemenot http or httpsIn {http, https}?the allowlistKeep the linkevery other link is skipped

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Block B. The href /rooms?block=B&floor=2 is relative. urljoin() puts it on the page's base address, https://college.example.org/notices/, and the scheme of the full address is https, so the link is kept.
  2. Write to the office. mailto:office@example.org is already a full address. Its scheme, mailto, is not on the allowlist, so the link is skipped.
  3. the FAQ. https://example.org/faq stays as it is, because it is a full address already, and its scheme is https, so it is kept.
  4. the old files. ftp://files.example.org/old has the scheme ftp, which is not on the allowlist, so it is skipped too.
  5. A scheme nobody listed. The allowlist names what is accepted, so a scheme nobody thought of, such as javascript:, is refused without anyone writing a rule for it. A denylist would have to name every bad scheme in advance.

An allowlist names what is accepted, so a scheme nobody thought of is refused by default; a denylist, which names what is refused, lets through every scheme its author forgot. The same pattern fits wherever a value from outside decides what a program does, such as the file extensions an upload may have. A link that passes still goes into the page through html.escape(), quoted, as the previous section showed: allowing and escaping are two separate steps.

Checkpoint

Check: Allowing only safe links

  1. One question The link collector keeps a link only when urlsplit(url).scheme in {"http", "https"}. Which of these does it keep?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • /rooms?block=B&floor=2, after urljoin()
    • https://example.org/faq

    An allowlist keeps only what it names. The relative link becomes an https address first, so it is kept; every other scheme is skipped, including ones nobody thought of.

XML Formatter Pretty-print a feed or an API response and find the line of a mismatched tag before you parse it in Python.

Practise: read a feed safely and show comments escaped

The exercise asks for parse_feed(), which refuses a document over a size limit or with any DOCTYPE and returns each item’s title and link, with empty strings for missing ones, and for render_comment(), which escapes a name and a comment into one line of HTML. Its sample tests run in your browser:

Exercise · Medium · Python

Read a feed safely and show comments escaped

Write two functions in feed_tools.py. Keep the names and parameters: the tests import them.

parse_feed(xml_text, max_chars=200_000) reads an RSS-style feed that came from outside the program and returns a list of (title, link) tuples, one per <item> of the <channel>, in order.

  • A missing <title> or <link> gives an empty string in its place.
  • Raise ValueError when the text is longer than max_chars characters, and when the document declares a DOCTYPE, whatever the DOCTYPE contains. Check the length before parsing.
  • Text that is not well-formed XML may raise xml.etree.ElementTree.ParseError, as fromstring() does.

render_comment(name, text) returns one comment as HTML, exactly in this shape:

<p class="comment"><b>NAME</b>: TEXT</p>

where NAME and TEXT are the arguments with &, <, >, " and ' escaped, so a reader's text always appears as text.

Starter code · feed_tools.py

import xml.etree.ElementTree as ET
from html import escape


def parse_feed(xml_text, max_chars=200_000):
    """Return [(title, link), ...] for the feed's items; ValueError for a DOCTYPE or a document that is too long."""
    root = ET.fromstring(xml_text)
    return [(item.findtext("title"), item.findtext("link")) for item in root.iter("item")]


def render_comment(name, text):
    """One comment as HTML: <p class="comment"><b>NAME</b>: TEXT</p>, with both values escaped."""
    return f'<p class="comment"><b>{name}</b>: {text}</p>'
The sample tests · test_feed_tools.py
import xml.etree.ElementTree as ET

from feed_tools import parse_feed, render_comment

FEED = """<?xml version="1.0"?>
<rss version="2.0"><channel>
  <title>Notices</title>
  <item><title>Exam timetable</title><link>https://example.org/exams</link></item>
  <item><title>Fees &amp; forms</title></item>
  <item><link>https://example.org/untitled</link></item>
</channel></rss>"""


def test_titles_and_links():
    """every item's title and link, in order, with empty strings for missing ones"""
    assert parse_feed(FEED) == [
        ("Exam timetable", "https://example.org/exams"),
        ("Fees & forms", ""),
        ("", "https://example.org/untitled"),
    ]


def test_channel_title_is_not_an_item():
    """only items count, not the channel's own title"""
    assert len(parse_feed(FEED)) == 3


def test_refuses_doctype():
    """a document that declares a DOCTYPE is refused, even a harmless one"""
    text = '<!DOCTYPE rss [<!ENTITY org "Example College">]><rss><channel><item><title>&org;</title></item></channel></rss>'
    try:
        parse_feed(text)
    except ValueError:
        return
    assert False, "the DOCTYPE was accepted"


def test_refuses_long_documents():
    """a document longer than max_chars is refused"""
    try:
        parse_feed(FEED, max_chars=len(FEED) - 1)
    except ValueError:
        assert parse_feed(FEED, max_chars=len(FEED))  # exactly at the limit is fine
        return
    assert False, "max_chars was ignored"


def test_broken_xml_still_raises_parse_error():
    """text that is not XML raises ParseError"""
    try:
        parse_feed("<rss><channel></rss>")
    except ET.ParseError:
        return
    assert False, "broken XML was accepted"


def test_render_comment_escapes():
    """markup characters in the name and the text appear as text"""
    html = render_comment('Ravi "R" <admin>', "Use <b> & 'quotes'")
    assert html == '<p class="comment"><b>Ravi &quot;R&quot; &lt;admin&gt;</b>: Use &lt;b&gt; &amp; &#x27;quotes&#x27;</p>'


def test_render_plain_text_unchanged():
    """plain text passes through as it is"""
    assert render_comment("Asha", "Thanks!") == '<p class="comment"><b>Asha</b>: Thanks!</p>'
A hint

Subclass xml.etree.ElementTree.TreeBuilder and give it a doctype(self, name, pubid, system) method that raises ValueError; pass it to the parser with ET.fromstring(text, parser=ET.XMLParser(target=YourBuilder())). Then root.find("channel"), findall("item") and findtext("title", default="") give you each item's values. For render_comment(), html.escape() escapes all five characters when quote is left at its default.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Reading trees, building XML, the DOCTYPE rule, escaping, parsing HTML and the link allowlist all come back in the practice below, starting from the idea your answers show you know least; a wrong pick names the slip behind it:

Practice that adapts to you

Practise this lesson

15 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Which call finds every <category> element at any depth below an element?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: elem.iter("category")

    iter(tag) walks the whole subtree, and findall(".//category") does the same with a path. find() and findall() without .// look one level down.

  2. Question 2 of 15 What does feed_titles.py print?

    What does this program print? Choose one answer.

    import xml.etree.ElementTree as ET
    
    # An RSS-style feed, as a server might send it.
    feed = """<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>Campus notices</title>
        <item>
          <title>Library opens late on Friday</title>
          <link>https://example.org/notices/library</link>
          <category>library</category>
        </item>
        <item>
          <title>Hostel fees &amp; deadlines</title>
          <link>https://example.org/notices/fees</link>
          <category>hostel</category>
          <category>fees</category>
        </item>
        <item>
          <title>Sports day volunteers</title>
        </item>
      </channel>
    </rss>"""
    
    root = ET.fromstring(feed)               # the <rss> element
    print(root.tag, root.get("version"))     # attributes come from get()
    channel = root.find("channel")           # the first matching child
    print("feed:", channel.findtext("title"))
    
    for item in channel.findall("item"):     # every matching child, in order
        title = item.findtext("title")
        link = item.findtext("link", default="(no link)")
        tags = [c.text for c in item.iter("category")]
        print(f"- {title} | {link} | {tags}")
    
    # Building XML: elements, attributes and text. ElementTree escapes & and < itself.
    out = ET.Element("notices", count=str(len(channel.findall("item"))))
    for item in channel.findall("item"):
        ET.SubElement(out, "notice").text = item.findtext("title")
    ET.indent(out)
    print(ET.tostring(out, encoding="unicode"))
    Show the answer: Question 2 of 15

    Answer: it prints

    rss 2.0
    feed: Campus notices
    - Library opens late on Friday | https://example.org/notices/library | ['library']
    - Hostel fees & deadlines | https://example.org/notices/fees | ['hostel', 'fees']
    - Sports day volunteers | (no link) | []
    <notices count="3">
      <notice>Library opens late on Friday</notice>
      <notice>Hostel fees &amp; deadlines</notice>
      <notice>Sports day volunteers</notice>
    </notices>

    Reading decodes entities, so the title has a plain &. The missing link gets the default text, and the document written with SubElement carries &amp; again, as XML requires.

  3. Question 3 of 15 Why should a program build XML with Element and SubElement rather than by joining strings?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: ElementTree escapes &, < and > in text and attribute values as it writes, so a value cannot break the document

    Build the tree, then let ElementTree write it. In build_vs_join.py the joined document failed to parse, while the built one gave back every title unchanged.

  4. Question 4 of 15 What does build_vs_join.py print?

    What does this program print? Choose one answer.

    import xml.etree.ElementTree as ET
    
    titles = ["Library opens late on Friday", "Hostel fees & deadlines", "Exam in room <B12>"]
    
    # Joining strings: the titles go into the document exactly as they are.
    joined = "<notices>" + "".join(f"<notice>{t}</notice>" for t in titles) + "</notices>"
    
    # Building elements: ElementTree escapes &, < and > in text as it writes.
    root = ET.Element("notices")
    for t in titles:
        ET.SubElement(root, "notice").text = t
    built = ET.tostring(root, encoding="unicode")
    
    # The check: parse each document back and compare the titles with the ones that went in.
    for label, text in [("joined strings", joined), ("SubElement", built)]:
        try:
            back = [n.text for n in ET.fromstring(text)]
            print(f"{label}: parses, titles come back unchanged: {back == titles}")
        except ET.ParseError as err:
            print(f"{label}: not XML, ParseError: {err}")
    print(built)
    Show the answer: Question 4 of 15

    Answer: it prints

    joined strings: not XML, ParseError: not well-formed (invalid token): line 1, column 75
    SubElement: parses, titles come back unchanged: True
    <notices><notice>Library opens late on Friday</notice><notice>Hostel fees &amp; deadlines</notice><notice>Exam in room &lt;B12&gt;</notice></notices>

    The joined document is not XML at all: the parser stops at the second title's &. The built one escapes & and < as it writes and gives back every title unchanged when it is parsed again.

  5. Question 5 of 15 What does the "billion laughs" trick, exponential entity expansion, do to a parser?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Nested entities each refer to the next several times, so a tiny document expands into gigabytes of text

    Exponential entity expansion uses levels of entities that each repeat the one below, so the expanded text grows exponentially with the levels. Refusing the DOCTYPE removes the declarations it needs.

  6. Question 6 of 15 Partners send XML gzip-compressed. Which danger from the documentation's list does that add, and what limits it?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: A decompression bomb; limit the bytes you decompress, not only the size of the upload

    The documentation lists decompression bombs for any library that parses compressed XML streams. Cap the decompressed size as the archives lesson caps extracted bytes, then parse with the DOCTYPE refused.

  7. Question 7 of 15 Why does refusing every document with a DOCTYPE make parsing untrusted XML safer?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: Because entity declarations live in the DOCTYPE, so refusing it removes the entity tricks before they start

    Entities are declared in a DOCTYPE, and the XML attacks that the Python documentation lists for entity expansion depend on them. Data formats such as feeds and API responses do not need a DOCTYPE, so refusing it costs nothing. Size limits are still needed: a document without a DOCTYPE can be huge.

  8. Question 8 of 15 Which of these belong in code that parses XML uploaded by strangers?

    Choose every answer that is right.

    Show the answer: Question 8 of 15

    Answer:

    • Check pyexpat.EXPAT_VERSION on the servers that run the code
    • Refuse documents that declare a DOCTYPE, or use a hardened parser such as defusedxml
    • Refuse documents longer than a limit before parsing

    An up-to-date Expat is what the Python documentation asks you to check, and the OWASP cheat sheet adds no DTDs and a size limit. Escaping the XML first would turn it into text that is no longer XML, and parsing twice changes nothing.

  9. Question 9 of 15 Which call makes a reader's comment safe to place between two HTML tags, such as inside a <p> element?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: html.escape(comment)

    html.escape() replaces &, < and >, and by default both quote characters, with entities, so the browser shows them as characters instead of reading them as markup. unescape() does the opposite.

  10. Question 10 of 15 What does escape_text.py print?

    What does this program print? Choose one answer.

    from html import escape, unescape
    
    name = 'Priya "PJ" D\'Souza'
    comment = "Use <h1> for the title & <p> for each paragraph."
    
    # Pasted in as it is, the comment's tags become real tags in the page.
    print(f"<p>{comment}</p>")
    # Escaped, the browser shows the same characters as text.
    print(f"<p>{escape(comment)}</p>")
    # Inside a quoted attribute, the quotes in the name must be escaped too (quote=True is the default).
    print(f'<span title="{escape(name)}">{escape(name, quote=False)}</span>')
    print(unescape(escape(comment)) == comment)
    Show the answer: Question 10 of 15

    Answer: it prints

    <p>Use <h1> for the title & <p> for each paragraph.</p>
    <p>Use &lt;h1&gt; for the title &amp; &lt;p&gt; for each paragraph.</p>
    <span title="Priya &quot;PJ&quot; D&#x27;Souza">Priya "PJ" D'Souza</span>
    True

    The first line pastes the comment in as it is, so its tags would become real tags. escape() turns <, > and & into entities, and with the default quote=True also both kinds of quote, which the title attribute needs. Between tags, quote=False is enough. unescape() reverses escape(), so the last line is True.

  11. Question 11 of 15 A comment was escaped when it was stored, and the page escapes it again. What does this print, and what does the reader see?

    Read the code, then choose one answer.

    from html import escape
    
    print(escape(escape("Fees & forms")))
    Show the answer: Question 11 of 15

    Answer: Fees &amp;amp; forms, which the browser shows as Fees &amp; forms

    Escaping is not idempotent: run twice, it escapes its own entities. Store the text as typed and escape once, when the page is built.

  12. Question 12 of 15 The page of link_parser.py has a <p> that is never closed. What does HTMLParser do?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: It carries on and reports the next tags as they come; it never calls handle_endtag() for that p

    HTMLParser is an event reader: a call for each start tag, each piece of text and each end tag it finds. Missing end tags simply produce no call.

  13. Question 13 of 15 When is BeautifulSoup a better choice than html.parser.HTMLParser?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: When you scrape many untidy real-world pages and want to search them by tag, class or CSS selector

    HTMLParser is in the standard library and calls your methods for each tag and piece of text, which suits small, known pages. BeautifulSoup, a third-party package, builds a searchable tree from messy HTML and saves you the bookkeeping on larger jobs.

  14. Question 14 of 15 What does link_parser.py print?
    from html.parser import HTMLParser
    from urllib.parse import urljoin, urlsplit
    
    
    class LinkCollector(HTMLParser):
        """Collects the page title and every link's address and text."""
    
        def __init__(self):
            super().__init__()  # convert_charrefs=True: &amp; and friends arrive as plain characters
            self.title = ""
            self.links = []
            self._in_title = False
            self._href = None
    
        def handle_starttag(self, tag, attrs):
            if tag == "title":
                self._in_title = True
            elif tag == "a":
                self._href = dict(attrs).get("href")
                self.links.append([self._href, ""])
    
        def handle_endtag(self, tag):
            if tag == "title":
                self._in_title = False
            elif tag == "a":
                self._href = None
    
        def handle_data(self, data):
            if self._in_title:
                self.title += data
            elif self._href is not None:
                self.links[-1][1] += data.strip()
    
    
    # Real pages are untidy: upper-case tags, a missing </p>, an entity inside an attribute.
    page = """<HTML><head><title>Timetable &amp; rooms</title></head>
    <body><p>Lectures move to <A HREF="/rooms?block=B&amp;floor=2">Block B</A>
    <p>Questions? <a href="mailto:office@example.org">Write to the office</a>
    or see <a href="https://example.org/faq">the FAQ</a>, <a href="ftp://files.example.org/old">the old files</a>.
    </body></HTML>"""
    
    parser = LinkCollector()
    parser.feed(page)
    parser.close()
    print("title:", parser.title)
    base = "https://college.example.org/notices/"
    for href, text in parser.links:
        url = urljoin(base, href)  # a relative link becomes a full address
        keep = urlsplit(url).scheme in {"http", "https"}  # an allowlist of schemes, never a denylist
        print(f"{'keep' if keep else 'skip'}  {text:<20} {url}")
    Show the answer: Question 14 of 15

    Answer: it prints

    title: Timetable & rooms
    keep  Block B              https://college.example.org/rooms?block=B&floor=2
    skip  Write to the office  mailto:office@example.org
    keep  the FAQ              https://example.org/faq
    skip  the old files        ftp://files.example.org/old

    The parser decodes entities, urljoin() makes the relative link a full https address, and the allowlist keeps http and https and skips mailto and ftp.

  15. Question 15 of 15 A teammate filters links with if scheme != "javascript": keep(link). Why does the lesson use an allowlist instead?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: A denylist refuses only what someone thought of; an allowlist refuses every scheme it does not name

    Name what is accepted, http and https, and refuse the rest. The OWASP cheat sheet on cross-site scripting asks for exactly that for links that users supply.

Interview questions

Why must text from users go through html.escape() before it is put into an HTML page?

Because the browser cannot tell your markup from theirs. A page is one string of HTML, and anything in it that looks like a tag is treated as one, so a comment containing <h1> or an unclosed <b> changes the page’s structure, and a carefully written one can add elements that run code in other visitors’ browsers, which is called cross-site scripting. html.escape() replaces &, < and > with entities, and by default both quote characters, so the browser displays the characters as text instead of reading them as markup. It should run when the page is built, not when the text is stored, so nothing is escaped twice. It is not the whole story: a link a user supplies also needs its scheme checked against an allowlist, because escaping does not change what an address does.

Key takeaways

  • ElementTree reads a document into a tree of elements: find() and findall() look one level down unless the path says more, findtext() takes a default, and iter() walks any depth.
  • Build XML with Element and SubElement, which escape & and < as they write; a joined string with a bare & is not XML at all.
  • The entity tricks of untrusted XML need a DOCTYPE, and Expat below 2.7.2 may be vulnerable: check pyexpat.EXPAT_VERSION where your code runs.
  • For untrusted XML, cap the size before parsing and refuse any DOCTYPE, through a TreeBuilder whose doctype() raises or defusedxml’s forbid_dtd=True.
  • html.escape() every value you write into a page once, when the page is built, with quote=True inside attributes.
  • HTMLParser reports tags and text through your methods; keep only the links whose scheme is on an allowlist of http and https.

Terms in this lesson

allowlist
A list of what is accepted, such as the URL schemes http and https; anything not on it is refused. Unlike a denylist, it also refuses values nobody thought of.
autoescaping
A template-engine setting that escapes every value put into an HTML template unless the template marks it as safe, so a forgotten call to an escape function cannot let markup through.
cross-site scripting
A weakness in which text from one user reaches a page without escaping, so the browser reads it as markup and can run code in other visitors' browsers. Escaping output for its place in the page prevents it.
DOCTYPE
A document type declaration near the start of an XML document, <!DOCTYPE …>, which may declare entities. Feeds, invoices and API responses never need one, so code that parses untrusted XML can refuse it.
element (XML)
One node of an XML tree: a tag such as item, optional attributes such as version="2.0", optional text and child elements. ElementTree gives each element its tag, attrib, text and children.
entity (XML)
A named piece of text that a reference such as &name; stands for. &amp;, &lt;, &gt;, &quot; and &apos; are built in; any other entity is declared in a DOCTYPE, which the expansion tricks depend on.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.