Skip to content

Escaping

cf-ui templates escape their own output. Every one of the shipped Jinja component templates wraps its body in {% autoescape true %}, so a hostile prop is escaped whatever the surrounding environment is configured to do.

{#def content="", header="", footer="", extra_class="" #}
{% autoescape true %}
...
{% endautoescape %}

This holds on a bare Catalog() — whose environment does not autoescape — and it holds for a consumer who calls catalog.add_folder(...) directly and never calls install_cf_ui at all.

What this means for you

You get escaping by default and cannot lose it by misconfiguring Jinja. Your own templates keep whatever escaping policy you set; cf-ui's are escaped regardless.

A prop carrying real markup must be Markup. This is the intended and only per-value override:

from markupsafe import Markup

catalog.render("Cf:Card", header="Report", _content=Markup("<b>bold</b>"))

Pass a plain string and the tags render as visible text — that is the safe default doing its job, not a bug.

In Django, the same rule applies through the template layer: values are escaped unless marked safe (mark_safe, or a SafeString from a form widget).

There is no opt-out flag. No setting, no installer keyword, no environment variable turns cf-ui's escaping off. An escape hatch was prototyped during #36 and removed — a global switch that silently disables escaping across every component is exactly the shape of thing that gets flipped once during debugging and never flipped back.

Overriding a whole template

If you genuinely need a component to emit unescaped output, replace the template rather than disabling the mechanism. JinjaX resolves folders in registration order, so a folder added before cf-ui's shadows it:

from jinjax import Catalog

from cf_ui.fastapi import install_cf_ui

catalog = Catalog()
catalog.add_folder("my_templates/cf", prefix="Cf")   # (1)!
install_cf_ui(catalog, theme="bulma")                # (2)!
  1. Registered first — your Card.jinja wins.
  2. cf-ui's copies fill in every component you did not override.

This is deliberate: the override is per-component, visible in your repository, and reviewable as a diff. A boolean in a settings file is none of those.

Why it works this way

The obvious implementation — have install_cf_ui set autoescape=True on the catalog's environment — was tried and failed three separate ways. The history is worth knowing because each failure looks reasonable in isolation:

Setting it unconditionally destroyed the caller's policy. An app that passed select_autoescape(...) had it silently overwritten by a plain True, changing escaping behavior for the app's own templates.

Guarding it ("only set it when escaping is off") reopened the hole. select_autoescape keys off the file extension, and cf-ui's templates are .jinja. The idiomatic select_autoescape(["html"]) therefore answers False for every cf-ui template while reading as truthy at the environment level — so the guard deferred to it and cf-ui rendered unescaped. This is still pinned as an executable note in the test suite:

from jinja2 import select_autoescape

assert select_autoescape(["html"])("Tabs.jinja") is False

Both versions were order-dependent. Jinja reads autoescape at compile time and JinjaX caches compiled components. A component rendered before install_cf_ui ran stayed compiled with the old setting, so whether escaping applied depended on call order.

Every one of those defects came from the same root choice: making escaping a property of a shared, mutable Environment that cf-ui does not own. Moving the invariant into the files cf-ui does own removes the entire class.

Consequently:

The installers never touch autoescape

install_cf_ui leaves the environment's escaping setting exactly as it found it, select_autoescape callables included. Your app's escaping policy is your app's.

Assets macros

cf_ui/assets.jinja carries the same guarantee, with one structural difference: the {% autoescape true %} blocks live inside each macro body rather than at file scope. A file-scope block stops the macros being exported and breaks every {% from "cf_ui/assets.jinja" import ... %} downstream.

The axis attributes emitted by cf_ui_root_attrs() are Markup by construction, so they survive; a hostile URL argument passed to cf_ui_head() or cf_ui_body() is escaped.

The prose contract

Cf:Prose / <c-cf.prose> is a typographic reset block, and its whole purpose is to hold caller-supplied markup — usually several elements of it, not one icon tag. Mechanically nothing above changes: prose takes no markup prop, content arrives through the slot exactly like every other primitive, and cf-ui's own template is still wrapped in {% autoescape true %} regardless of what the calling environment does. What has to be said plainly, because prose is the component someone reaches for specifically to render a block of rich content, is what that mechanism does and does not cover.

cf-ui does not sanitize caller-supplied HTML. If you wrap content in Markup (Jinja) or mark_safe (Django) to get real markup into a prose slot, you have told the template layer to trust that string completely — cf-ui renders it as-is, the same way it renders any other Markup value passed anywhere else in the package. prose does not inspect it, strip it, or narrow which tags are allowed.

That is fine, and is exactly what Markup/mark_safe are for, when the HTML originates from your own code or a trusted template. It stops being fine the moment any part of that HTML came from a user — a comment body, a bio field, a rendered Markdown document someone else authored. Marking that safe and handing it to prose is a stored XSS vector, not a cf-ui bug: you told the template layer not to escape it, and it didn't.

If the content is user-supplied, sanitize it before it reaches Markup or mark_safe — with an allowlist-based HTML sanitizer such as nh3 or bleach, not with escaping, since the whole point of prose is to render real markup rather than escaped text. Sanitizing is the caller's responsibility; cf-ui's escaping guarantee starts after that decision has already been made.

Verifying it yourself

The unfriendliest configuration a consumer can produce — no installer, escaping explicitly off:

from jinjax import Catalog

from cf_ui import JINJA_TEMPLATES_DIR

catalog = Catalog()
catalog.add_folder(JINJA_TEMPLATES_DIR / "bulma", prefix="Cf")
assert not catalog.jinja_env.autoescape

html = catalog.render("Cf:Card", header='" onmouseover="alert(1)" x="')
assert 'onmouseover="alert(1)"' not in html

The repository enforces this three ways: a source-level drift guard over all 70 templates (so a new theme cannot ship without the block), a per-theme behavioural render with escaping off, and installer tests asserting both False and a select_autoescape callable survive untouched.