squirrelscan
GitHub

Unrendered Markup

Detects literal markdown or escaped HTML leaking into rendered copy

Detects literal markdown or escaped HTML leaking into rendered copy

Rule IDcontent/unrendered-markup
CategoryContent
ScopePer-page
Severitywarning
Weight5/10

Solution

Something rendered a value as plain text that was authored as markup. Find the field, not the page: a CMS that stores markdown and a template that prints it without a markdown-to-HTML pass produces literal **bold** and [text](url) everywhere that field appears, and fixing one page leaves the rest broken. Visible <p> or <a href means the opposite mistake: HTML was escaped twice, usually by escaping a value that a templating engine (Jinja, Twig, Blade, JSX) had already escaped, so remove the manual escape rather than marking the value safe. Visible &nbsp; or &amp; means the entity itself was encoded a second time on the way in, which is normally an import or a rich-text editor round-trip, so re-import the affected content. If the markup is meant to be on display, put it in <code> or <pre>, which this rule skips.

What it checks

Seven kinds, in three families, in visible text only:

KindExample on screenMeaning
markdown-emphasis**unlimited audits**a markdown field printed as plain text
markdown-link[our docs](https://example.com/d)the same, for links and images
markdown-headinga line starting ## the same, for headings
markdown-code-fencea line of three backticksthe same, for fenced code
markdown-inline-code`squirrel audit`corroborating only, see below
escaped-html-tag<p>Prices exclude tax.</p>HTML escaped twice
double-encoded-entity&nbsp;, &amp;an entity encoded a second time

The check reads the text a visitor sees, decoded, never the raw HTML. That matters for the HTML families: the page source says &lt;p&gt;, and the browser paints <p>, so the rule looks for the painted form.

Severity depends on certainty. Visible tags and entities fail, because nothing outside a code block renders them on purpose. Literal markdown warns at one or two occurrences and fails at three or more, where it stops looking like a typo and starts looking like a template that never ran its markdown pass.

What is excluded

Displaying markup is not the same as leaking it, and this rule is mostly a list of ways to tell the two apart.

  • <script>, <style>, <noscript> and <template>, because a visitor never reads them.
  • <code>, <pre>, <samp>, <kbd> and <textarea>, because a visitor reads them as a quoted literal. A tutorial showing **bold** in a code span is doing its job, and an editor pre-filled with a post’s markdown source is not a defect.
  • Any element a syntax highlighter marks as its own: the whole class tokens highlight, hljs, shiki, chroma, codehilite, code-block, preformatted and their siblings, any language-* or lang-* class, and the data-language family of attributes. Whole tokens, so a highlight-box callout is still judged as prose.

Five more limits keep the rule quiet on pages that talk about markup rather than mangle it.

  • Underscore emphasis must contain a space. __init__ and __main__ are flanked exactly like bold and appear in ordinary prose, so __bold__ on its own is not reported. **bold** still is.
  • A markdown link needs a URL-shaped target. array[i](arg) and [see note] (below) are not links.
  • A single # needs a capitalised word after it. # is also the number sign, so # of seats in a table header is not a heading, and neither is the URL fragment MDN prints under a specification link. ## and deeper have no second reading and are taken as written, so ## my-package still counts.
  • Escaped tags must be lowercase. <Article> and <Header> are component names a page is showing on purpose. Markup that leaked from a template came from HTML a server emitted, which is lowercase.
  • At least one escaped tag must carry a true closing form or a recognised attribute with a value. A page that writes <pre> or <meta name> in a sentence is documenting an element. A leak brings </p> or class="row" with it. A self-closing <br/> is not a closing form, </path/to/file> is a path rather than a closing tag, and c=1 is not an attribute, so if a<b then c=1>0 stays clean.

False positives

Two classes are known and deliberate.

Prose full of identifiers stays clean: CLOUDFLARE_API_TOKEN, some_file_name.ts, max_pages, width * height and 2 * 3 are none of them emphasis, because both emphasis forms require CommonMark-style flanking.

Backticks alone never trip the rule. They are the one markdown character people put on a page on purpose and often, in a prompt written to be copied into an agent, a chat transcript, or a changelog. Inline code spans are reported in the detail once another family has fired, and never before.

Text is read block by block, so a match is never assembled out of two elements that each hold half of it. A heading ending in * followed by a link starting with * is two stray asterisks, not emphasis.

The residual case is a page whose subject is HTML source, displayed in a container the rule cannot recognise. A tutorial that shows <p>This is a paragraph.</p> inside a plain <div> with a bespoke class is indistinguishable from a page that leaked it. Wrap such examples in <pre> or <code>, which is also what makes them selectable and copyable for readers.

content/mojibake covers the neighbouring problem: bytes decoded with the wrong encoding. The two sit one encoding level apart on entities. Mojibake reports a visible &amp;nbsp;, meaning the source was encoded three times; this rule reports a visible &nbsp;, meaning twice.

They do overlap on triple-encoded text, and deliberately so. A page showing &amp;nbsp; trips mojibake on the whole sequence and this rule on the &amp; inside it, and both findings are true: the entity was encoded once too many, and a reader is looking at raw markup. Expect one finding from each rule on such a page.

Enable / disable

Disable this rule

[rules]
disable = ["content/unrendered-markup"]

Disable all Content rules

[rules]
disable = ["content/*"]

Enable only this rule

[rules]
enable = ["content/unrendered-markup"]
disable = ["*"]

Type to search…

↑↓ navigate↵ openesc close