How the HTML Parser Works
Level 1 showed that browsers repair broken markup. The repairs follow a precise algorithm in the HTML standard, and knowing it explains many "impossible" bugs.
Two stages
- The tokenizer reads characters and produces tokens: start tags,
end tags, text, comments, the doctype. It's a state machine — for
example, inside
<script>or<style>it treats everything as raw text until the matching end tag, and inside<textarea>and<title>it reads text but still decodes character references. - The tree builder turns tokens into DOM nodes. It keeps a stack of open elements and an insertion mode ("in head", "in body", "in table", ...) that decides what each token means.
Implied elements and auto-closing
DOMParser runs the same parser from JavaScript, so you can watch it
work:
<script></script>
<html><head></head><body>Hello</body></html>
<p>One</p><p>Two</p><ul><li>A</li><li>B</li></ul>
<table><tbody><tr><td>1</td></tr></tbody></table>
html,head, andbodyare created even if you never write them.- A new
<p>closes the open one; so does<ul>. A new<li>closes the previousli. - Rows are always put inside a
tbody— which is why a CSS selector liketable > trnever matches in an HTML page.
Foster parenting
Content that isn't allowed in a table is moved before it:
<script></script>
<p>stray text</p><table><tbody><tr><td>cell</td></tr></tbody></table>
A template language that accidentally emits a div or text between
table rows produces exactly this — content that jumps above the table.
The adoption agency algorithm
Misnested formatting elements (b, i, a, em, strong, ...) are
repaired by cloning them, as you saw in Level 1. Links can't nest, so a
second <a> closes the first:
<script>
const doc = new DOMParser().parseFromString(
'<a href="/one">one <a href="/two">two</a></a>', "text/html");
console.log(doc.body.innerHTML);
console.log(doc.querySelectorAll("a").length, "links");
</script>
<a href="/one">one </a><a href="/two">two</a>
2 links
Raw text and script injection
Inside <script>, the tokenizer looks only for </script. So a string
containing that sequence ends the script early — a classic bug when
embedding JSON from the server:
<script>
const doc = new DOMParser().parseFromString(
'<script>const note = "</' + 'script><b>oops</b>";</' + 'script>', "text/html");
console.log(doc.head.innerHTML);
console.log(doc.body.innerHTML);
</script>
<script>const note = "</script>
<b>oops</b>";
When you output JSON inside a script tag, escape < as < (most
server frameworks' JSON encoders can do this for you).
Why this matters
- The DOM you style and script may differ from the HTML you wrote. When a
selector or
querySelector"doesn't work", inspect the real DOM. innerHTMLuses the same parser, with the same repairs.- Sanitizers must parse exactly like browsers, or attackers exploit the differences (Sanitizing User Content, later in this level).