← Blog
How it works

An invoice template is an HTML document

“Our invoice PDFs are built from an HTML template with placeholders filled in from database fields, and I cannot tell whether that is a templating system or a string operation wearing one as a costume.”

Here is the whole fix, from a CRM's invoice module:

-        template_html = template_html.replace(placeholder, str(value))
+        template_html = template_html.replace(placeholder, escape(value))

One call added, plus the import it needs and four lines of docstring saying why. The commit that carries it bumps the patch version from 1.14.7 to 1.14.8 and adds a test file, and that is the entire change.

I want to spend a post on it, because the smallness is the interesting part. A one-line fix implies a one-line bug, and a one-line bug implies somebody forgot a function call. That is not what happened here. What happened is that a codebase ended up with two ways to put the same database field into the same PDF, one of which was a template engine and one of which was a string operation, and nothing in the shape of the code made the second one look like a decision.

Everything below was measured today. The code is read from the repository at its current tip, and what I could not check is listed at the end.

Two paths, same field

The function is render_invoice_template in a Django CRM's invoices/pdf.py. An org admin can save a custom HTML template for invoice PDFs; if they have, the generator takes that path, and if they have not, it renders a packaged Django template instead. The relevant lines:

    # Check for custom template HTML
    if template and template.template_html:
        html_content = template.template_html
        # Replace placeholders with actual values
        html_content = render_invoice_template(html_content, context)
    else:
        # Use default template
        html_content = render_to_string("invoices/pdf/invoice.html", context)

Both branches produce a string of HTML, and both strings go to the same renderer. The difference is that render_to_string runs the Django template engine, which autoescapes by default, and render_invoice_template does this:

    for placeholder, value in replacements.items():
        template_html = template_html.replace(placeholder, str(value))

replacements is a dict built a few lines up, mapping the literal string {{invoice.notes}} to the invoice's notes, {{org.name}} to the org name, and so on for twenty-seven fields in all: numbers, dates, client contact details, both address blocks, the currency-formatted totals.

So the placeholders look like Django template syntax. They are spelled like Django template syntax. They are not Django template syntax. They are dictionary keys in a str.replace loop, and the resemblance is the part that does the damage, because it is what lets a reader skim the function and conclude that the engine is handling this.

What that actually produces

I pulled that function out of the repository, all twenty-seven fields of it, and ran it both ways against the real django.utils.html.escape from Django 6.1.2, the version the project requires. The admin's template is one paragraph:

<p class="notes">{{invoice.notes}}</p>

The member fills in notes. Here is what comes out, with the field holding a positioned div, the same input the fix's own test uses:

== custom path, before the fix  (str(value)) ==
<p class="notes"><div style="position:absolute;top:0">PAY TO 9999</div></p>

== custom path, after the fix   (escape(value)) ==
<p class="notes">&lt;div style=&quot;position:absolute;top:0&quot;&gt;PAY TO 9999&lt;/div&gt;</p>

== default path, Django autoescape, same field ==
<p class="notes">&lt;div style=&quot;position:absolute;top:0&quot;&gt;PAY TO 9999&lt;/div&gt;</p>

The second and third are byte-identical. I checked that rather than eyeballing it: escape(value) and {{ v }} through the engine produce the same bytes for this input, and the default template's {{ invoice.notes|linebreaks }} escapes too, so the fix does not invent a policy. It makes the custom path agree with the path that was always there.

Which is the useful way to describe this class of bug. Nobody decided that custom templates should be unescaped. There was no threat model in which that was acceptable. There were two implementations of one idea, and only one of them had been thought about.

The renderer is not a sanitiser

The next question is whether any of this survives to the PDF, and that is worth measuring rather than assuming, because people do assume. PDF feels like a flattening step. Markup goes in, a page comes out, and the intuition is that whatever was dangerous about the HTML got left behind in the conversion.

It does not. HTML to PDF is a rendering operation, and a renderer's entire job is to honour layout instructions. I built a small invoice: a heading, a client line, a payment line with the real account number and total, and the notes paragraph. Then I rendered it with WeasyPrint 70.0, the renderer this CRM uses and the oldest version it accepts.

The injected div is live layout. Placed over the payment line with a white background, it leaves a page that reads:

Invoice INV-1042
Billed to Acme Ltd
Pay to account 9999 0000. Total due USD 9,500.00
Notes:

The real line, Pay to account 1234 5678. Total due USD 4,500.00, is not visible anywhere on the page. It is underneath an opaque block.

With escape(value), the same field in the same template renders as what it is: the whole div, tags and style attribute included, as visible text in the notes paragraph, ugly and obviously wrong, which is the correct outcome for a value that contained markup.

The text layer keeps what the page hides

This is the part I did not expect to be as clean as it was.

Covering text in a PDF does not remove it. The painting order decides what a human sees; the content stream still holds both strings, at the same coordinates. So I ran two independent extractors over the covered file.

pdftotext -layout from poppler 24.02.0:

Invoice INV-1042
Billed to Acme Ltd
Pay to account 9999
               1234 0000.
                    5678. Total due USD 9,500.00
                                        4,500.00
Notes:

pypdf 6.21.2, on the same file (the space in T otal is pypdf's, not the document's):

Invoice INV-1042
Billed to Acme Ltd
Notes:
Pay to account 1234 5678. T otal due USD 4,500.00Pay to account 9999 0000. T otal due USD 9,500.00

Both extractors recover both account numbers and both totals. A person reading the PDF sees one payment line. Anything reading the PDF mechanically sees two, either interleaved into nonsense or concatenated into a string containing two different amounts.

That asymmetry is the thing worth carrying away. A forged overlay is not a clean forgery; it is a document that disagrees with itself depending on who is reading. A client pays the visible number. An archive, a search index, or an accounts system that extracts text gets both, and nothing in the file marks which one the page actually showed.

What escaping does not close

Now the part that the one-line fix does not address, and was never going to.

escape escapes &, <, >, " and '. It does not escape braces. And the substitution loop walks a dict of placeholders in insertion order, rewriting the whole document on each pass, with no distinction between the admin's original text and text that an earlier pass just inserted.

Put a placeholder in a field value and watch the passes, on the post-fix code:

template: <p>{{invoice.invoice_title}}</p>
invoice_title field holds: 'Work for {{org.name}}'

   after {{invoice.invoice_title}}        <p>Work for {{org.name}}</p>
   after {{org.name}}                     <p>Work for Acme Holdings</p>

result:   <p>Work for Acme Holdings</p>

The same field through Django's engine:

result:   <p>Work for {{org.name}}</p>

The engine prints the braces, because it parsed the template once, before any value existed, and text that arrives later is text. The str.replace loop cannot make that distinction, because by the time it looks for {{org.name}} there is only one string and no record of where any part of it came from.

It is order-dependent, too. Run it the other way, with a field that names a placeholder already consumed:

notes field holds: 'Thanks. {{invoice.invoice_number}}'
result:   <p>Thanks. {{invoice.invoice_number}}</p>

Inert, because {{invoice.invoice_number}} was substituted first and the loop never goes back. So whether a member's text is live or literal depends on where its field sits in a dict literal relative to the field it names. That is not a rule anybody wrote down, and it would change silently if someone reordered the dict for readability.

I want to be precise about how much this matters, because it is easy to oversell. Every value reachable this way belongs to the same invoice, plus the org name, and the member doing it can already see all of them. Nothing crosses a tenant boundary and nothing is disclosed that was not already on the screen. The consequence is narrower and stranger than a leak: the document can end up saying something nobody typed into it. A title field reading Work for {{org.name}} is a title field whose rendered value depends on config: rename the org, and every PDF generated from that invoice afterwards says something different.

The reason to mention it at all is that it is the same defect as the escaping one, one layer down. Escaping fixed the symptom that had a name. The underlying property, that this function rewrites a document it cannot parse, is still there, and it is what would have to change to make the two paths actually equivalent rather than equivalent-for-the-inputs-tested.

The boundary they had already drawn

What makes this a good example rather than a careless one is that the same file shows its authors thinking carefully about the PDF step as a security boundary, in a different dimension. A few hundred lines up:

    class SafePdfURLFetcher(URLFetcher):
        """WeasyPrint fetcher that only fetches what ``_allowed_pdf_url`` permits.

        Must be passed to BOTH ``HTML(...)`` and ``CSS(...)``, WeasyPrint resolves
        ``@import`` / ``@font-face`` / ``@color-profile`` at CSS parse time through
        the CSS object's own fetcher, so a CSS() left on the default fetcher would
        reopen the whole SSRF/LFI through ``template_css``.

        Redirects are checked too: urllib's redirect handler re-enters through
        ``open()``, which calls ``fetch()`` for every hop. The fetcher holds
        per-request state, so build a new one for each render.
        """

That is someone who knows exactly what an HTML renderer is: a program that will fetch what you tell it to fetch, follow redirects while doing it, and resolve imports at parse time in a place you were not looking. They closed that. They closed it on both the HTML and the CSS object, which is the detail people miss.

And the same file pasted unescaped member text into the document that renderer reads. Not because the risk was unknown, but because the two things did not look like the same question. One was clearly about a renderer fetching resources. The other looked like filling in a form.

If you are substituting into HTML

The things I would take from this, in the order I would check them:

Count the paths. The bug was not in the escaping, it was in there being two renderers for one output. Any field that reaches a document through more than one code path should be checked on every path, and the test that proves it should name the field, not the path. The test file added with this fix does that: it parametrises over seventeen member-writable field names and asserts each one comes out escaped.

A placeholder that looks like a template is a claim. If your substitution uses {{ }} and your framework also uses {{ }}, every reader after you will assume the framework is involved. Either use the engine, or spell the placeholders so that nobody can mistake them for it.

Escaping is per-syntax, not general. escape makes text safe for HTML. It does nothing about your own substitution syntax, and if your substituted values can contain that syntax, you have a second injection channel that no HTML escaper will ever touch. A parse-then-fill engine does not have this problem, which is most of the reason to use one.

Rendering is not sanitising. Nothing is neutralised by becoming a PDF. If untrusted text reaches a renderer as markup, the renderer will lay it out, and the result is a document that carries the forgery in its text layer underneath whatever the page appears to say.

What was verified, and what was not

Measured on 2026-10-24: WeasyPrint 70.0; poppler pdftotext 24.02.0; pypdf 6.21.2; Django 6.1.2 on Python 3.12.3.

The source is read from the repository's default branch at commit ec8fd70. The fix is commit 237b617, dated 2026-10-15 and merged through pull request #834. The code blocks quoting the branch that chooses between the two templates and SafePdfURLFetcher are verbatim from ec8fd70; the substitution loop is quoted as it stood before the fix.

The change answers issue #825, a security report filed against release 1.14.5. It describes the mechanism the commit fixes: a member allowed to create invoices puts markup in a field such as the notes, a custom template is selected, and anyone opening the invoice's public link gets a PDF showing the injected content. It recommended the same one-call change that was merged. It does not mention placeholders inside field values.

Named limits:

  • I did not run the CRM. The substitution function was run on its own, taken from pdf.py and called with a stub invoice, against Django 6.1.2's real escape and template engine. The project's own test suite was not run.
  • The invoice in the PDF section is mine. Its layout, and the coordinates that put the forged line over the real one, are not the CRM's; I have not seen any organisation's custom template.
  • Current state. The escaping fix is in the default branch as of today. The substitution-into-substituted-text behaviour described above is also in the default branch as of today; it is a consequence of the loop, which the fix did not change.

No counts appear anywhere in this post: not of reports, not of affected projects, not of anything. I had no way to check any, so there are none to check.