DEV Community

137Foundry
137Foundry

Posted on

Why Templating Engines' Auto-Escaping Breaks the Moment You Bypass It

Most developers today have never had to write an HTML escaping function by hand, and that's a genuine improvement. React escapes interpolated values before rendering. Jinja2 and Django templates auto-escape by default. Vue and Angular do the same. This is why XSS in modern frameworks usually isn't a "the framework failed" story. It's a "someone explicitly turned the safety off for one specific value" story.

The three ways teams bypass auto-escaping

dangerouslySetInnerHTML in React. The name is honest about what it does, but that honesty doesn't stop it from getting used. It usually shows up when a team needs to render HTML from a rich text editor, a CMS field, or markdown that's already been converted to HTML upstream. The moment that HTML contains anything from an untrusted source, auto-escaping is off for that entire block.

mark_safe and {% autoescape false %} in Django. Same pattern, different framework. mark_safe tells Django "trust this string as-is," which is exactly right for content your own templates generated and exactly wrong for anything that passed through a user-editable field at some point in its life.

Raw string concatenation into a response. Less common in frameworks with built-in templating, more common in smaller scripts, internal tools, or older codebases where someone builds an HTML string by hand and returns it directly. There's no auto-escaping to bypass here because there was never any templating engine involved.

Terminal screen showing monospace text close up
Photo by Josh Sorenson on Pexels

Why this specific bypass is so dangerous

Auto-escaping being on by default means a codebase that uses it consistently develops a kind of collective blind spot: nobody re-checks whether output is safe, because the framework has been handling it correctly for every other line of code they've written. The one line that opts out doesn't get extra scrutiny just because it's the exception. If anything it gets less, because it's usually justified as "we need this for formatting" and moves on.

The Wikipedia entry on cross-site scripting is a good refresher on how little an attacker needs once a bypass exists, a single unescaped <script> tag or event handler attribute is enough to run arbitrary JavaScript in another user's session.

What actually needs to happen before you bypass escaping

If you genuinely need to render HTML that didn't originate entirely from your own server-side code, sanitize it with a dedicated HTML sanitization library before it reaches the bypass, not instead of using the bypass. A sanitizer parses the HTML and strips or neutralizes dangerous elements and attributes, script tags, event handlers, javascript: URLs, while leaving safe formatting tags intact.

// Don't do this with untrusted content
<div dangerouslySetInnerHTML={{ __html: userSuppliedHtml }} />

// Sanitize first, then render
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userSuppliedHtml) }} />
Enter fullscreen mode Exit fullscreen mode

The same principle applies in Django: sanitize before mark_safe, never use mark_safe as a substitute for sanitization. The MDN Web Docs has solid background on which HTML constructs are actually dangerous if you want to understand what a sanitizer needs to strip and why.

The attribute context is a separate trap

Even when element content is properly escaped, attribute values need their own treatment. A string interpolated into an href, src, or style attribute can be dangerous even with HTML entity encoding applied, if it contains a javascript: URL scheme or a CSS expression that executes code. Escaping the quote character isn't enough if the content itself is a malicious URL that browsers will happily follow.

Chalkboard filled with handwritten formulas and diagrams
Photo by Yan Krukau on Pexels

The OWASP cheat sheet series covers this distinction directly, since it's one of the more common gaps between "we escape our output" and "our output is actually safe."

Server-rendered templates have the same problem, just an older version of it

This isn't a new-framework problem. Jinja2, ERB, and classic PHP templates have had auto-escaping bypasses for as long as they've existed, {{ value | safe }} in Jinja2, raw() helpers in various Ruby templating libraries, and direct echo of unescaped variables in older PHP code. The pattern is identical across every generation of templating technology: a default that's safe, and an explicit opt-out that a developer reaches for when the default output doesn't look right.

The recurring reason developers reach for the opt-out is formatting, not malice. Someone wants to render a bit of bold text, a link, or a line break that a user typed using some lightweight markup, and the safe default renders the markup characters literally instead of interpreting them. The fix that actually solves the underlying need is running that markup through a proper parser, converting trusted lightweight syntax into HTML server-side and sanitizing the result, not disabling escaping for the whole block.

How to review a pull request that introduces one of these bypasses

When one of these bypasses shows up in a diff, the review question isn't "does this work." It almost always works, in the sense that it renders the intended formatting correctly for normal input. The actual question is: what is the full set of possible values that can reach this code path, and has every one of them been sanitized before this point.

Trace the value backward from the bypass to its origin the same way you would for a second-order SQL injection review. If the value could ever contain a database-stored comment, a user profile field, or content synced from an external API you don't fully control, the bypass needs a sanitizer immediately upstream of it, not "input validation" somewhere else in the pipeline that the reviewer has to trust happened correctly. If the value only ever comes from your own server-rendered constants, the bypass is safe, but it's worth a comment in the code explaining why, so the next person who touches this line doesn't have to redo the same trace.

A rule for code review

Any pull request touching dangerouslySetInnerHTML, mark_safe, {% autoescape false %}, or raw HTML string construction should get a second reviewer by default, the same way a database migration or an auth change would. That's a small process cost that catches a disproportionate share of real XSS risk, because these bypasses are rare enough in a well-maintained codebase that flagging all of them isn't a burden.

A note on framework upgrades changing the rules underneath you

One more failure mode worth watching for: a framework's auto-escaping defaults can change between major versions, sometimes tightening in ways that break existing bypasses' assumptions, occasionally loosening in edge cases nobody expects. A template that was safe under one version's default escaping behavior isn't guaranteed to stay safe after an upgrade, particularly around less common contexts like escaping inside inline event handler attributes or style blocks.

Treat any major framework upgrade that touches templating as a reason to re-audit your existing dangerouslySetInnerHTML, mark_safe, and equivalent bypasses, not just run the existing test suite and assume green means safe. A test suite only catches what it was written to check, and if nobody wrote a test for the specific escaping edge case that changed, the suite will pass while the actual behavior has shifted underneath it.

Closing thought

Auto-escaping is one of the best security defaults modern web frameworks ship with, precisely because it removes a decision developers used to get wrong constantly. The failure mode isn't the default, it's the exception nobody re-audits after the initial justification. If your team is working through a security review of a codebase with any of these bypasses in it, that's a focused engagement AI automation agency 137Foundry handles regularly, and the full write-up on escaping across HTML, SQL, and shell contexts is a good next read if you want the broader picture.

Top comments (0)