Reading — step 1 of 5
Learn
Raw HTML and Safe Rendering
The escaping lesson ended on an uncomfortable note: escaping is not enough, because CommonMark lets raw HTML through on purpose. That is not a loophole or a bug in some library — it is a conformance requirement, and it is the construct every Markdown security advisory is really about. Time to look at it directly.
What the spec actually requires
Two separate rules produce raw HTML, and a parser needs both.
An HTML block is a run of lines that the block parser hands to the output untouched. Seven kinds exist, each defined by a start line and a stop line:
| # | Starts with | Ends at |
|---|---|---|
| 1 | <pre <script <style <textarea | the matching close tag |
| 2 | <!-- | a line containing --> |
| 3 | <? | a line containing ?> |
| 4 | <! then a letter | a line containing > |
| 5 | <![CDATA[ | a line containing ]]> |
| 6 | < or </ then a known block tag name | a blank line |
| 7 | any other complete tag alone on its line | a blank line |
Inline raw HTML is the second rule: inside a paragraph, any substring shaped
like a tag is emitted verbatim rather than escaped. So a <b>word</b> keeps its
tags while 5 < 6 does not, and the difference is decided entirely by whether
the text matches the tag grammar.
What does pass-through actually look like?
Markdown inside an HTML block is not parsed — the block is opaque, which is its whole point:
The first output is what a conforming renderer must produce. The onclick
attribute reaches the browser, and no amount of escaping elsewhere in the parser
changes that, because escaping is not applied here by design.
So how do you render untrusted Markdown?
Three answers exist, and only the middle one is complete.
- Turn raw HTML off. Most libraries have a flag; the reference C
implementation replaces each raw block with a comment, which is where the
<!-- raw HTML omitted -->line above comes from. Cheap, and it changes what conforming means, so say so in your docs. - Sanitize the OUTPUT html with an allow-list of elements and attributes.
This is the only approach that also catches a
javascript:link destination, which no raw-HTML switch touches. - Filter specific tags, the GFM approach: pass everything through except a
short list of tag names, whose leading
<is escaped. Narrow by construction — anything not on the list still goes straight to the browser.
What does not work is escaping the source before parsing. That turns every
author's © into visible noise, breaks link destinations, and still leaves
javascript: URLs alone. Escaping belongs at render time, per output position.
Your exercise
Implement the third policy: pass tags through, escape the ones on a list.
The mistake the grader catches is escaping the whole disallowed tag. A
visible test feeds A <script>alert(1)</script> attack. and expects
A <script>alert(1)</script> attack. — only the leading < changes, and
the > stays a >. Escaping the whole match gives you <script> and a
diff on both tags.
The second is the tag name. A hidden test uses <IFRAME>, so compare
lowercased, and it also uses <I>, which is not on the list and must survive
untouched. And a < that is not part of a tag at all is ordinary text: <3 and
<> both become escaped text, not markup.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…