skip to content

A Django product-review page renders {{ review.body|safe }} so that line breaks display; why is that an XSS hole, and how do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. who wrote the value
  2. one filter switches protection off
  3. a filter that escapes then formats
  4. safe only after an allowlist
  5. grep for every escape hatch

basics

~20 s

The |safe filter marks the reviewer's text as trusted HTML, so any script or event-handler markup they typed runs for every visitor; remove it and use |linebreaks, which escapes the text before adding <p> and <br>, or sanitize real rich text first.

solid answer

~40 s

`|safe` calls `mark_safe` on the value, which switches off autoescaping for it, so a review body containing `<img src=x onerror=...>` is delivered as live markup to every visitor: a stored XSS. The intent was only to show line breaks, and Django has a filter for exactly that: `{{ review.body|linebreaks }}` (or `|linebreaksbr`) escapes the text first and then wraps paragraphs in `<p>` and newlines in `<br>`, returning a safe result. Two caveats: `linebreaks` does not escape a value that is already safe, and it follows the surrounding setting, so inside `{% autoescape off %}` it escapes nothing. If reviews genuinely need formatting, sanitize the HTML with an allowlist sanitizer and mark only the sanitizer's output safe. Adding `|escape` after `|safe` does not help: Django documents that `var|safe|escape` still prints the value unescaped.

code

django · 8 lines
django
{# vulnerable: reviewer HTML is trusted #}
<div class="review-body">{{ review.body|safe }}</div>

{# still vulnerable: escape leaves safe strings alone #}
<div class="review-body">{{ review.body|safe|escape }}</div>

{# fixed: escapes the text, then adds <p> and <br> #}
<div class="review-body">{{ review.body|linebreaks }}</div>

go deeper

for a junior

Remember that |safe turns escaping off for that value, so it must never touch text a user wrote.

for a middle

Explain why linebreaks is the right filter for plain text and why it escapes only non-safe input while autoescaping is on.

for a senior

Diagnose the stored XSS, choose between escaping and allowlist sanitizing, and audit every safe, mark_safe and autoescape off in the codebase.

for a principal

Decide whether user rich text is worth the sanitizer's ongoing risk, and back escaping with CSP and review rules rather than relying on one filter.

## The bug A reviewer writes a multi-paragraph review. Rendered with `{{ review.body }}`, the newlines collapse into one line, so someone adds `|safe` hoping the text will format. It does not add any formatting, and it does something much worse: ```django <div class="review-body">{{ review.body|safe }}</div> ``` `safe` is implemented as `mark_safe(value)`. The value becomes a `SafeString`, the autoescaping pass skips it, and whatever the reviewer typed is written into the page as HTML. A body of `<img src=x onerror="fetch('/steal?c='+document.cookie)">` now runs in every visitor's browser, on a page the site serves as its own. Because the payload is stored in the database, one malicious review attacks every future reader. ## The Django-native fix The requirement was line breaks, and the DTL has filters for that: | Filter | Output | Escapes the input? | |---|---|---| | `linebreaks` | blank line becomes `</p><p>`, single newline becomes `<br>` | yes, unless the value is already safe or autoescaping is off | | `linebreaksbr` | every newline becomes `<br>` | same rule | | `safe` | nothing changes | **no — escaping is disabled** | ```django <div class="review-body">{{ review.body|linebreaks }}</div> ``` `linebreaks` escapes the review text and then inserts its own `<p>`/`<br>` tags, returning a safe string so the tags it added are not escaped again. The reviewer's `<img>` shows as text. Two details matter when you rely on it: 1. It skips escaping when the input is **already a safe string** — so `{{ review.body|safe|linebreaks }}` is still vulnerable. 2. It follows the **current autoescape setting**, so inside `{% autoescape off %}` it escapes nothing. ## When reviews really need rich text If the product decision is that reviews may contain bold, lists or links, escaping is the wrong tool — it would show the tags. The value must be passed through an **allowlist HTML sanitizer** (a library that keeps only permitted tags and attributes and drops everything else, including event handlers and `javascript:` URLs). Then: - sanitize on write (store clean HTML) or on render, but always before marking; - mark only the sanitizer's return value safe, in one well-named helper; - never mark the raw body safe anywhere else. Django does not ship such a sanitizer; `striptags` is not one, and Django's documentation warns that its output is not guaranteed to be HTML-safe. ## Fixes that do not work - **`{{ review.body|safe|escape }}`** — the `escape` filter uses `conditional_escape`, which leaves safe strings alone; Django's docs show this prints the value unescaped. - **`{{ review.body|force_escape|safe }}`** — this is safe but shows literal tags and no line breaks, which is the original problem. - **`{% autoescape off %}` around the block** — the same hole as `|safe`, applied to every variable inside the block and carried into included templates. ## How to find the rest A review-page bug rarely lives alone. In an audit, search for every escape hatch and justify each: - `|safe` and `|safeseq` in templates, - `{% autoescape off %}` blocks, - `mark_safe(` and `SafeString(` in Python, especially around f-strings or `%` formatting, - custom filters registered as safe that return unescaped input. As defence in depth, a Content Security Policy that forbids inline script limits what a missed payload can do — Django 6.0 added built-in CSP support — but it does not replace escaping.

  • What is the difference between Django's |escape and |force_escape filters?
    `escape` uses `conditional_escape`: it escapes a plain string once and leaves safe strings alone, so it never causes double escaping under autoescape. `force_escape` calls `escape()` immediately and always, returning escaped text you can pass to further filters, for example `{{ body|linebreaks|force_escape }}` to display the generated tags.
  • Why is {{ review.body|linebreaks }} unsafe inside {% autoescape off %}?
    `linebreaks` is registered with `needs_autoescape`, so it escapes only when the surrounding context has autoescaping on. Inside an off block it inserts the raw text between its `<p>` tags and marks the result safe, reproducing the XSS.

Autoescaping is a mailroom that X-rays every parcel before it enters the building. The safe filter is a sticker saying 'already inspected'; put it on a parcel from a stranger and it walks straight past the scanner.

saying these in an interview costs you the question

  • The |safe filter is how you make Django display line breaks.
  • Adding |escape after |safe re-escapes the value.
  • |linebreaks escapes its input even inside {% autoescape off %}.
  • striptags makes user HTML safe to mark as safe.
  • Stored review text is trustworthy because it passed form validation.