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?
answer
- who wrote the value
- one filter switches protection off
- a filter that escapes then formats
- safe only after an allowlist
- grep for every escape hatch
basics
~20 sThe |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{# 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
Remember that |safe turns escaping off for that value, so it must never touch text a user wrote.
Explain why linebreaks is the right filter for plain text and why it escapes only non-safe input while autoescaping is on.
Diagnose the stored XSS, choose between escaping and allowlist sanitizing, and audit every safe, mark_safe and autoescape off in the codebase.
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.