//nefariousplan

CVE-2025-23211: bleach Is An HTML Sanitizer. Jinja2 Does Not Read HTML.

pattern

cve

proof of concept

Tandoor Recipes renders every recipe step through jinja2.Template(). The template engine is not sandboxed, has full access to Python builtins, and has been configured that way since January 5, 2021. Between the user-controlled instruction field and the template engine sits exactly one security call: bleach.clean(). bleach is an HTML sanitizer. The template engine it is placed in front of does not read HTML.

The pipeline is markdown, then bleach, then Jinja2

cookbook/helper/template_helper.py, at version 1.5.23, defines render_instructions(step). It is called from StepSerializer.get_instructions_markdown, which the recipe API invokes every time a recipe step is fetched. The function mutates a single instructions variable through three stages:

def render_instructions(step):  # TODO deduplicate markdown cleanup code
    instructions = step.instruction

    tags = {
        "h1", "h2", "h3", "h4", "h5", "h6",
        "b", "i", "strong", "em", "tt",
        "p", "br",
        "span", "div", "blockquote", "code", "pre", "hr",
        "ul", "ol", "li", "dd", "dt",
        "img",
        "a",
        "sub", "sup",
        'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
    }
    parsed_md = md.markdown(
        instructions,
        extensions=[
            'markdown.extensions.fenced_code', TableExtension(),
            UrlizeExtension(), MarkdownFormatExtension()
        ]
    )
    markdown_attrs = {
        "*": ["id", "class", 'width', 'height'],
        "img": ["src", "alt", "title"],
        "a": ["href", "alt", "title"],
    }

    instructions = bleach.clean(parsed_md, tags, markdown_attrs)

    ingredients = []
    for i in step.ingredients.all():
        ingredients.append(IngredientObject(i))

    def scale(number):
        return f"<scalable-number v-bind:number='{bleach.clean(str(number))}' v-bind:factor='ingredient_factor'></scalable-number>"

    try:
        template = Template(instructions)
        instructions = template.render(ingredients=ingredients, scale=scale)
    except TemplateSyntaxError:
        return _('Could not parse template code.') + ' Error: Template Syntax broken'
    except UndefinedError:
        return _('Could not parse template code.') + ' Error: Undefined Error'

    return instructions

The three stages, in order:

  1. md.markdown() converts step.instruction from markdown source to HTML.
  2. bleach.clean() strips HTML tags and attributes not in the allowlist.
  3. jinja2.Template(instructions).render() compiles the bleached HTML as a Jinja2 template and executes it. The render context supplies ingredients and scale.

bleach's job is to ensure HTML that reaches the browser is free of <script>, <iframe>, and unsafe attributes. It does that job. It does not know what Jinja2 is, and it does not inspect text for Jinja2 expressions. The string {{ something }} is indistinguishable from any other text to bleach, because {{ something }} is not an HTML tag, attribute, or entity. bleach returns it unchanged. Jinja2 then sees it and evaluates it.

jinja2.Template is the unsandboxed template class in Jinja2. Expressions in its templates can reach Python's class hierarchy through attribute navigation. An empty tuple's __class__ is tuple. Its __base__ is object. object.__subclasses__() is the list of every class loaded into the interpreter, and at a reproducible index sits subprocess.Popen. This is the standard Jinja2 SSTI primitive, and it is available by default in a Template that was constructed without an environment.

jinja2.SandboxedEnvironment exists for the specific purpose of refusing those navigations. It has existed in every version of Jinja2 Tandoor ever shipped. The developer reached for Template instead.

The developer's mental model is preserved in a 2021 commit

The markdown → bleach → Jinja2 order was not always the order. In the first commit that added render_instructions, on January 5, 2021, Template(step.instruction) ran first on the raw field. That ordering was also unsafe, for a simpler reason. It was replaced eight days later.

Commit 0eebd438 on January 13, 2021 restructured the function so that Jinja2 ran last, after bleach. Its commit title is the single word "commit."

A companion commit the same day, 1bb412e0, has a more revealing title:

sanitize inputs of jinja so that output does not need to be

That commit wraps every attribute of IngredientObject in bleach.clean():

-    self.amount = f"<scalable-number v-bind:number='{ingredient.amount}' ..."
+    self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' ..."
-    self.unit = ingredient.unit
+    self.unit = bleach.clean(str(ingredient.unit))
-    self.food = ingredient.food
-    self.note = ingredient.note
+    self.food = bleach.clean(str(ingredient.food))
+    self.note = bleach.clean(str(ingredient.note))

IngredientObject instances are what get passed into template.render(ingredients=...). When a recipe step contains {{ ingredients[0].food }}, the rendered output is whatever ingredient.food happens to be. The developer's mental model, stated plainly in the commit message: clean the inputs to render(), and you will not need to clean the output.

That model protects against a user who types <script> into an ingredient name and has that script survive into rendered HTML. It does not protect against anything that lives in the template source. The template source is step.instruction. The template source is not one of render()'s kwargs. It is code, compiled by Template() and executed by .render(). The security question for Template(step.instruction) is not "what do the variables look like," it is "what does the template itself do."

The commit message names the wrong input. ingredients and scale are not the dangerous input. step.instruction is the dangerous input, because step.instruction is source code.

The exploit's work is bypassing markdown, not Jinja2

The researcher's PoC in GHSA-r6rj-h75w-vj8v:

{{()|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(418)('whoami',shell=True,stdout=-1)|attr('communicate')()|attr('\x5f\x5fgetitem\x5f\x5f')(0)|attr('decode')('utf-8')}}

The Jinja2 chain here is a cheatsheet payload: navigate from an empty tuple to object, enumerate subclasses, pick the index where subprocess.Popen lives, invoke it with shell=True. It appears in every SSTI guide. The payload's novelty is not the Jinja2 part. It is \x5f\x5fclass\x5f\x5f instead of __class__.

Markdown treats consecutive underscores as emphasis. _italic_ becomes <em>italic</em>. __class__ becomes either wrapped in emphasis tags or has its underscores consumed, depending on the parser's state. A direct {{().__class__...}} payload does not arrive at Jinja2 in the form the attacker wrote it.

\x5f\x5fclass\x5f\x5f does. \x5f is five ASCII characters: backslash, x, five, f. md.markdown() has no interpretation for them and emits them unchanged into its HTML output. bleach.clean() has no interpretation for them and passes them through, because they are neither HTML syntax nor anything on its allowlist. jinja2.Template compiles the template, and when it evaluates attr('\x5f\x5fclass\x5f\x5f'), Jinja2 hands the argument to Python's string decoding. Python reads \xNN as a hex byte. The argument to attr() becomes __class__.

The researcher did the hex-escape work because markdown exists in the pipeline. The Jinja2 chain itself required no novelty, because Template imposes no sandbox. bleach contributed nothing to the difficulty of the attack. It was not a factor.

Authentication is required, nominally. Tandoor's recipe write permission belongs to the user group; the GHSA publisher, the project maintainer, puts it this way: "any user." Tandoor deployments include self-registration, shared spaces, and per-space invite flows. The Docker Compose shipped in-repository runs the Django process as root. CVSS assigned PR:L, S:C, and scored it 9.9.

The patch is two imports and one constructor

Commit e6087d51, November 26, 2024:

 import bleach
 import markdown as md
 from jinja2 import Template, TemplateSyntaxError, UndefinedError
+from jinja2.exceptions import SecurityError
+from jinja2.sandbox import SandboxedEnvironment
 from markdown.extensions.tables import TableExtension
...
     try:
-        template = Template(instructions)
-        instructions = template.render(ingredients=ingredients, scale=scale)
+        env = SandboxedEnvironment()
+        instructions = env.from_string(instructions).render(ingredients=ingredients, scale=scale)
     except TemplateSyntaxError:
         return _('Could not parse template code.') + ' Error: Template Syntax broken'
     except UndefinedError:
         return _('Could not parse template code.') + ' Error: Undefined Error'
+    except SecurityError:
+        return _('Could not parse template code.') + ' Error: Security Error'

Template and SandboxedEnvironment live in the same library. The Jinja2 documentation has said for more than a decade that Template is not safe with untrusted template sources, and that SandboxedEnvironment is the class to use when the source is untrusted. The fix is the swap. The function's shape, the markdown stage, the bleach stage, the render kwargs: all unchanged.

The vulnerable code shipped in Tandoor 0.13.0 on January 5, 2021. The markdown-to-bleach-to-Jinja2 order that the published PoC targets shipped in 0.14.0 on February 15, 2021. The fix shipped in 1.5.24 on November 26, 2024. The commit comment that became the function's header on the day the file was introduced, and is still there: # TODO deduplicate markdown cleanup code. The developer noticed the duplication. Nothing in any commit noted the template sandbox.

This is content-is-command at the template boundary

Every time a Tandoor recipe is read, step.instruction is compiled into a Jinja2 template and executed. The instruction field is a textarea in a form and a column in a database. Users type recipes into it. Its contents are authoritative for what the template engine compiles. An interpreter that reads user-supplied text and evaluates the expressions inside it is the content-is-command pattern reduced to its minimum form. The channel that was supposed to hold passive data (a cooking instruction) is the same channel that holds executable code (a Jinja2 template). Tandoor is the small-surface cousin of the Composer Perforce injection: there, package-registry metadata became a shell command on a developer's machine; here, markdown text from a recipe form becomes Python on the server.

The defense is not a better sanitizer. The defense is constraining the interpreter. SandboxedEnvironment refuses attribute access to Python's class hierarchy at evaluation time; ()|attr('__class__') raises SecurityError and never resolves. The patch adopts that class and adds the corresponding exception handler. The markdown and bleach stages stay, because they still have jobs to do at the HTML layer. They are not, and were never, doing a job at the Python layer.

The pipeline shape here recurs. Django templates run mark_safe() on escaped strings and then render them. A YAML loader runs downstream of an HTML scrubber. An LLM prompt assembled from bleach.clean()-sanitized text is fed to a model that evaluates natural-language instructions. Each stage has a threat model; each sanitizer defends against the threats its designer had in mind. Chaining a sanitizer for layer L with an interpreter for layer L' does not make the sanitizer cover the interpreter. The sanitizer is not looking at what the interpreter will read.

PoC: projectdiscovery/nuclei-templates/http/cves/2025/CVE-2025-23211.yaml

The instruction field was not data. It was template source. The sanitizer in front of it was for a different language.