Never run django.template.Template() on HTML you didn’t write
A single stored record took an endpoint down for a week. It contained valid, working markup — for a different template language. Django compiled it anyway, because that is what you asked it to do.
The endpoint served embeddable widgets. Each partner had a row in the database with a body field holding their own HTML snippet, and the view rendered it with a couple of our values substituted in. The code doing the substituting was three lines, and had been there for years:
# widgets/services.py from django.template import Context, Template def render_widget(widget, viewer): tpl = Template(widget.body) return tpl.render(Context({"viewer": viewer, "price": widget.default_price}))
Then somebody added a partner called northwind-events, and /embed/w/8842/ started returning 500s.
The traceback that contains none of your code
Traceback (most recent call last):
File ".../django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
File "/srv/app/widgets/views.py", line 41, in embed
html = render_widget(widget, viewer)
File "/srv/app/widgets/services.py", line 18, in render_widget
tpl = Template(widget.body)
File ".../django/template/base.py", line 154, in __init__
self.nodelist = self.compile_nodelist()
File ".../django/template/base.py", line 196, in compile_nodelist
return parser.parse()
File ".../django/template/base.py", line 510, in parse
filter_expression = self.compile_filter(token.contents)
File ".../django/template/base.py", line 600, in compile_filter
return FilterExpression(token, self)
django.template.exceptions.TemplateSyntaxError: Could not parse the remainder: ' soldout' from 'if soldout'(Line numbers move between Django versions. The frames don’t.)
Every frame below your render_widget call is Django’s parser. There is no view logic in it, no ORM, no partner name, no record id — nothing that tells you which of several hundred rows is responsible. That is most of why this took a week: the error names a fragment of text, and the text isn’t in the log.
(This post has a sibling. Gunicorn’s TimeoutError: [Errno 110] in sock.sendall() is the same lesson on a different stack — a traceback showing you where the failure surfaced, which is not where it came from.)
Here’s what was in the record:
<div class="nw-widget">
<ul id="nw-events"></ul>
<script id="eventRow" type="text/x-jquery-tmpl">
<li>${name} — ${price}
{{if soldout}}<span class="badge">Sold out</span>{{/if}}
</li>
</script>
</div>That is a jQuery Templates fragment. It is meant to sit inert in the page and be rendered in the browser, client-side, long after your server is done with it. It is perfectly valid. It is also, from Django’s point of view, a syntax error.
Why {{if}} is fatal and ${name} isn’t
jQuery-tmpl and Django overlap in exactly one place, and it’s the worst possible one.
${name} is jQuery-tmpl’s interpolation. Django doesn’t use $ for anything, so it passes through as literal text. No conflict.
{{if soldout}} is jQuery-tmpl’s block tag. Django uses {{ ... }} for variables, so it reads that as a variable expression named if soldout — and a variable expression may not contain a space. Hence Could not parse the remainder: ' soldout' from 'if soldout'.
The closing tag fails the same way for a different reason:
Could not parse the remainder: '/if' from '/if'
So the interpolation syntax that looks like it should collide doesn’t, and the block syntax that looks unrelated does. Anyone eyeballing that record for a Django problem skips straight past the {{if}}, because in a page full of ${...} it reads as more of the same client-side stuff.
Mustache, Handlebars, Vue and Angular all use {{ }} too. Any of them stored in a field you later hand to Template() produces some flavour of this.
It fails at compile time, not render time
Worth internalising, because it changes where you look. Template("...") parses in __init__. The exception is raised when you construct the object, before any context exists, before render() is called, and regardless of what data you were going to pass in.
Which means: you cannot test this away with representative data. The record is either parseable or it isn’t, and a record that isn’t will fail identically for every user, on every request, forever, until somebody edits the row.
One record, the whole endpoint
The widget list view rendered every active partner in a loop. One unparseable row therefore 500’d the page for all of them — the failure was not scoped to the partner who caused it.
That is the detail that made this look like an infrastructure problem for the first few days. The symptom was “the embed endpoint is down”, the deploy history was clean, and the one thing that had changed was a row somebody added through the admin. Nobody looks in the admin for a cause of an outage.
Finding it, once we knew what to look for, was one query:
from django.db.models import Q Widget.objects.filter(Q(body__contains="{{") | Q(body__contains="{%"))
The part that should worry you more than the 500
We got lucky. The record crashed.
Consider the version that doesn’t — a partner whose markup happens to parse. You are now executing a template written by somebody outside your organisation, against a context you control, and Django’s template language does two things that matter here.
It resolves anything in the context. Whatever you passed in is readable. If a request or a user object is in scope — and in a lot of codebases it is, via a context processor — then {{ request.user.email }} in partner markup silently renders your users’ email addresses into a public embed.
It calls callables with no arguments. This is documented Django behaviour: when variable resolution lands on something callable, the template system calls it. That is how {{ user.get_full_name }} works without parentheses. It is also how {{ some_object.deactivate }} would work, if such a method existed and the object were in the context.
Django is not Jinja2 here, and it’s worth being precise about the difference. Django’s variable resolution refuses names beginning with an underscore, so the usual __class__-walking escalation to arbitrary code execution doesn’t apply. This is not a straight RCE. But “attacker can read your entire template context and invoke any zero-arg method it can reach” is not a good place to be either. Django defends its own models with alters_data = True on save() and delete(), which the template system honours — it does not defend your methods, because it doesn’t know about them.
None of that is a Django flaw. It’s the template language working exactly as specified. The flaw is upstream: we treated a data field as source code.
The fix: stop mixing markup and data
Once you say it out loud — “we are compiling a data field as source code” — the fix stops being about escaping or validation and becomes about not doing that.
The partner markup never needed to be a template. It needed to be emitted verbatim, with our values handed to it as data:
# widgets/services.py from django.utils.html import json_script from django.utils.safestring import mark_safe def render_widget(widget, viewer): config = { "viewer": viewer.public_name, "price": str(widget.default_price), } # The partner's markup is emitted untouched. Our values ride alongside it # as JSON, for their client-side code to pick up. return mark_safe( json_script(config, element_id=f"widget-config-{widget.pk}") + widget.body )
json_script is Django’s own helper for exactly this: it serialises to JSON, escapes the characters that would let a </script> break out, and wraps the result in a <script type="application/json"> tag. The partner’s browser-side code reads it by id.
This is the whole fix. It is shorter than what it replaced, it cannot raise TemplateSyntaxError, and it cannot read your context — because there is no longer a context to read.
(Note the mark_safe and think about it for a second. You are still emitting partner HTML into your page, which means you are trusting them not to put a <script> in it. That’s a trust decision about who can write to that field, and it is a completely different and much smaller problem than handing their bytes to a template compiler. If you don’t trust them that far, the answer is an iframe or a sanitiser like nh3 — not a template engine.)
If you genuinely need substitution
Sometimes the partner content really does have server-side placeholders in it. Then substitute — but substitute, don’t compile:
from string import Template as StringTemplate ALLOWED = {"viewer", "price", "year"} def substitute(body, values): # This guards our own call sites, not the partner's markup — safe_substitute # already ignores any placeholder in `body` we didn't pass a value for. if not set(values) <= ALLOWED: raise ValueError("unexpected placeholder") # safe_substitute leaves unknown placeholders alone instead of raising, # which is what you want for markup you didn't write. return StringTemplate(body).safe_substitute(values)
string.Template understands $name, ${name}, and $$ as an escape — and nothing else. No conditionals, no loops, no attribute traversal, no method calls. It is a string operation with a placeholder syntax, which is what this job always was. And you’ll notice its syntax happens to be the same ${name} that jQuery-tmpl uses — so in this particular case the two never fight.
If you’re absolutely committed to running a template engine over foreign markup, the least-bad version is a separate Engine with builtins=[] and no libraries, fed a context containing nothing but the values you intend to expose. Understand that this is damage limitation and not a sandbox. Django’s docs don’t claim otherwise, and neither should you in your design doc.
If you can’t stop compiling yet
One thing to be clear about first: once the fix above is in, none of what follows is needed. The body is emitted verbatim, {{ }} inside it is inert, and the northwind-events record renders exactly as they intended it to. That is worth checking deliberately, because it’s the test of whether you actually fixed anything — if the offending record is still rejected afterwards, you haven’t stopped treating markup as source code. You’ve just moved the refusal earlier and made it the partner’s problem.
But refactors take a sprint and outages don’t wait. If that field is still being compiled in production tonight, at least move the failure off the render path and in front of somebody who can act on it:
# widgets/models.py from django.core.exceptions import ValidationError class Widget(models.Model): ... def clean(self): # Interim guard. This field is still compiled as a Django template, so # Django's delimiters can't appear in it. Delete this method once the # body is emitted verbatim. if "{{" in self.body or "{%" in self.body: raise ValidationError({ "body": "This field is currently compiled as a Django template, so " "it can't contain {{ }} or {% %} — including client-side " "tags like {{if}}. Tell us if you need them and we'll lift " "the restriction; don't rewrite your markup around it." })
A 500 in production becomes a red box in the admin, next to the field, in front of the person who can actually fix it. It is still the wrong answer — you are asking partners to design around your architecture — but it is the wrong answer that doesn’t take the endpoint down.
The rule
A template engine is an interpreter. Template(x) is eval(x) wearing a nicer hat, and the fact that it’s a restricted interpreter is a mitigation, not a defence.
So: if x came out of your database, off an API, from a file somebody uploaded, or out of a form — it is data. Substitute into it, escape it, sanitise it, or refuse it. Don’t compile it.
And if you’ve got a Template(), a render_template_string(), a Handlebars.compile() or a new Function() somewhere with a variable inside the parentheses, go and look at where that variable comes from. That’s the whole audit.