FastAPI · Server-side template injection (SSTI)

Server-side template injection (SSTI) in FastAPI

FastAPI's documented template path renders files by name, so template injection in a FastAPI service nearly always sits in a secondary feature (emails, PDFs, chat prompts) that compiles a string with Jinja directly.

Class
Server-side template injection (SSTI) in Python
CWE
CWE-1336, CWE-94
web framework
FastAPI documentation
On this page

How it arises in FastAPI

The documented pattern is templates = Jinja2Templates(directory="templates") and templates.TemplateResponse(request=request, name="item.html", context={"id": id}). The request supplies context values. The template is a file the team wrote. An API that only does this has no SSTI.

Most FastAPI services are JSON APIs, though, and their templates are not pages:

Notification bodies. A POST /notifications endpoint accepts {"template": "Hi {{ name }}", "vars": {...}} so that callers can format their own messages. The handler runs jinja2.Template(body.template).render(**body.vars).

Per-tenant templates. Tenants store invoice or email layouts; a background task loads the text and renders it with templates.env.from_string(...), reusing the web environment and all of its globals.

Prompt and document builders. Services that assemble a text prompt or a Markdown report from parts use Jinja for loops and conditionals, and one of the parts is a user-written instruction block concatenated into the source.

Templated SQL or shell. Jinja used to build a query or a command from a request-supplied snippet is two vulnerability classes at once.

Two properties make the direct route worse than it looks. A bare jinja2.Template(...) or Environment() is not sandboxed, and Jinja expressions can walk attributes from any value to Python's internals, which ends in code execution. And validation gives false comfort: template: str on a Pydantic model accepts any text of any shape.

Vulnerable and fixed

from fastapi import FastAPI
from jinja2 import Template
from pydantic import BaseModel

app = FastAPI()


class Notification(BaseModel):
    template: str
    variables: dict


@app.post("/notifications/preview")
def preview(note: Notification):
    return {"text": Template(note.template).render(**note.variables)}

Let callers pick a template you wrote and supply only the values. The identifier is an Enum, the sources are constants, and the variables are a typed model, so a caller cannot add names to the context:

from enum import Enum

from fastapi import FastAPI
from jinja2.sandbox import ImmutableSandboxedEnvironment
from pydantic import BaseModel

app = FastAPI()
env = ImmutableSandboxedEnvironment(autoescape=True)


class Kind(str, Enum):
    welcome = "welcome"
    receipt = "receipt"


SOURCES = {
    Kind.welcome: env.from_string("Hi {{ name }}, welcome to {{ product }}."),
    Kind.receipt: env.from_string("Hi {{ name }}, we received {{ amount }}."),
}


class Variables(BaseModel):
    name: str = ""
    product: str = ""
    amount: str = ""


class Notification(BaseModel):
    kind: Kind
    variables: Variables


@app.post("/notifications/preview")
def preview(note: Notification):
    return {"text": SOURCES[note.kind].render(**note.variables.model_dump())}

What FastAPI and Jinja already do for you

Jinja2Templates resolves names inside one directory and renders files, which keeps request data in the context. Pydantic lets you type the variables so the context is a closed set. Jinja provides SandboxedEnvironment and ImmutableSandboxedEnvironment for templates you do not fully control, and documents their limits honestly: the sandbox alone is not a solution for perfect security, and a small template can still render a very large output.

What es-python adds

In an API service the template call is rarely in the handler. It is in a mailer module, a task, or a utility that several endpoints share, and the string passes through a Pydantic model and often a queue on the way. es-python follows the request text through those hops and reports when it is compiled: ssti jinja2, recorded at Environment.from_string, which jinja2.Template uses as well. The event carries the ssti impact tag, the offending text and the trace of the function that compiled it.

For the per-tenant case, the text comes from your own database at render time. Turn on the db_rows source alongside socket and that flow is reported too.

The fixed endpoint compiles its templates once at import, from literals, so no request text reaches the compiler and no SSTI event is produced; the values rendered through {{ name }} are data. If you keep a sandboxed renderer for tenant-written templates, expect the event to remain, since an outside author still writes template code, and record that decision with a rule. Block network code execution raises a RuntimeError at the compile.

Review checklist

  1. Search for jinja2.Template(, Template( and .from_string( outside module scope.
  2. Check mailers, PDF builders and prompt builders, not only HTML responses.
  3. Replace free-form template fields with an Enum of templates plus typed variables.
  4. Use a separate sandboxed environment, with no globals, for tenant-authored templates.
  5. Cap output size and render time.

References

See it on your own FastAPI code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo