Security
Citry protects two different boundaries. Template expressions run in a Python sandbox. Server events receive values that have traveled through the browser, so their handlers need the same validation, CSRF protection, and authorization as any other HTTP endpoint.
The sandbox is on by default. Event routes also apply a same-origin floor by default, but only your application can decide whether the current user may act on a particular record.
Choose a CSP compatibility mode
Citry can validate its rendered component subtree against the expression language in its pinned Alpine CSP runtime:
from secrets import token_urlsafe
app = Citry(security_csp="strict")
nonce = token_urlsafe(16)
html = Page().render().serialize(csp_nonce=nonce)
The modes have distinct rollout purposes:
"off"keeps the standard Alpine runtime and existing output."warn"keeps that same runtime and HTML, but emits oneRuntimeWarningcontaining incompatible reached expressions or rendered markup. Findings stay separate per rendered instance when a late string hook prevents Citry from proving that two occurrences came from one authored source site."strict"selects Citry's version-matched Alpine CSP runtime and rejects incompatible output before returning HTML.
Strict validation covers component-boundary expressions that may disappear during rendering and the final HTML after extension hooks. It rejects Alpine syntax the pinned CSP evaluator cannot interpret, raw <script> and <style> elements, any ASCII-case-insensitive on* attribute, and javascript: URLs. Put complex browser logic in Component.js and call a scope method from the template. Put trusted scripts and styles in Component.js, Component.css, or structured [Dependencies][citry.ext.dependencies.Dependencies].
Citry UI's production component definitions are checked in CI against the pinned Alpine CSP expression subset. That guarantee covers the public library components and its registered internal renderers. Documentation snippets are teaching material and are not a compatibility allowlist; run citry check before copying an example's browser expressions into a strict application.
Run citry check or use the Citry editor extension to get the same pinned expression findings at source locations. Per-render mode overrides are enforced during serialization; project tooling reports the configured engine default.
Citry owns runtime selection and its rendered subtree. Your application still owns the response header, nonce generation, layouts, third-party resources, and directives other than the documented Citry boundary.
Choose how much JavaScript Citry may deliver
JavaScript delivery is separate from CSP. Set security_javascript on the engine, or override it for one serialization:
app = Citry(security_javascript="forbid")
email_html = Page().render().serialize(
security_javascript="omit",
)
The four modes answer different questions:
"allow"preserves normal interactive output."warn"preserves those exact bytes and emits oneRuntimeWarningthat inventories reached browser behavior."omit"removes Citry-managed executable scripts, Alpine and Events runtimes, preloaders, and browser manifests. Server-rendered HTML and CSS remain. Authored Alpine attributes remain inert."forbid"rejects a rendered subtree that needs executable client behavior, even whendeps_strategy="simple"or"ignore"would otherwise hide the corresponding runtime or dependency tag.
The inventory covers active component-boundary bindings, final structured dependencies after hooks, and settled HTML after string-level extensions. It recognizes Alpine and Events attributes, executable script types, native on* handlers, javascript: URLs, and executable HTML embedded through iframe srcdoc or HTML data documents. A declared but unused Events method is not by itself an active requirement.
"omit" is a static-export tool, not an HTML sanitizer. Raw executable scripts, native handlers, and JavaScript URLs are left unchanged and reported; use "forbid" when they must make serialization fail. Omit also warns about high-confidence fallback hazards such as x-cloak, structural Alpine templates, and handler-only controls. Check the resulting page without JavaScript and provide native links or forms for essential actions.
CSS remains allowed in every mode. An omit fragment emits its CSS directly, without a preloader, manifest, mounted route, or existing browser manager. deps_strategy="ignore" keeps its existing meaning and suppresses collected CSS too. When an exact structured stylesheet or inert data script carries an executable attribute, omit removes that attribute while retaining the CSS or data. Opaque dependency renderers are removed because Citry cannot prove what tag they create.
With security_csp="strict", omit and forbid do not validate inert Alpine expressions because no Alpine runtime is emitted. Strict CSP still validates raw executable markup and applies the response nonce to retained structured inline styles.
Pin Citry-managed scripts with SRI
Set security_script_integrity="citry" when you want Citry to bind its structured script output to exact bytes:
app = Citry(security_script_integrity="citry")
serialized = Page().render().serialize_result()
html = serialized.html
script_sources = " ".join(serialized.security.csp_script_hashes)
Citry computes SHA-384 after inline script wrapping, adds integrity to external scripts whose response bytes it owns, and carries the attribute into fragment dependency descriptors. The result includes immutable per-script records and quoted hash sources suitable for adding to the host's script-src. Citry does not construct the complete CSP header because the host also owns layouts, analytics, and every resource outside the component render.
For a third-party URL, provide its published integrity value on a Script. Citry validates and preserves the value but reports it as unverified; it never downloads third-party code during serialization. Configure CORS and crossorigin as required by that resource.
This option provides byte identity and hash metadata. It composes with security_csp="strict", but does not enable that expression policy by itself.
Apply a request CSP nonce centrally
Generate a fresh unpredictable nonce for each response, place its matching source in the host-owned CSP header, and pass the raw value at final serialization:
from secrets import token_urlsafe
nonce = token_urlsafe(16) # 128 random bits before URL-safe base64 encoding
serialized = Page().render().serialize_result(csp_nonce=nonce)
policy = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}'; "
f"style-src 'self' 'nonce-{nonce}'"
)
Your web framework still sends serialized.html with policy as the Content-Security-Policy response header. Citry validates the nonce's CSP base64 syntax, but the host owns its entropy, freshness, response header, and every resource outside the Citry render. The CSP specification recommends at least 128 random bits before encoding.
Citry adds the value after dependency hooks have run. Every structured Script, including external scripts and inert JSON manifests, receives it. Every structured Style, including external stylesheet links, receives it. A matching explicit nonce is accepted, while a different or malformed one is an error. The original dependency objects are not mutated, so one render can be serialized for separate responses with separate nonces.
Raw <script> and <style> elements written directly in template HTML are not automatically trusted or nonced. Move trusted code to Component.js, Component.css, or a structured dependency. Strict mode rejects those raw elements after all render hooks have run.
The browser manager records the nonce that authorized its own script tag. When a later fragment creates a structured script, inline style, or stylesheet link, the manager adds that document nonce if the descriptor omits it and rejects a different value before inserting the dependency batch. Off and warning fragments may load the standard manager through their preloader. Strict fragments contain only inert markup and manifests and require a strict Citry base document with an existing CSP manager. A present manager rejects nonce or runtime-variant mismatches before adoption; without one, the fragment remains inert.
Do not cache nonce-bearing HTML separately from its response header. If a full response is cached, its HTML and CSP header must remain one artifact.
Treat State as client input
With the default signed storage, every State value is visible in the page source. The signature stops a user from silently changing the server-minted token, but it does not encrypt the token. Public fields may also be changed deliberately through $state and two-way :c-* bindings.
State._public controls which values $state and bindings can read in plain form. It does not make the other State fields secret because those fields still travel inside the signed token. State._model narrows which public fields the browser may write. Neither list replaces authorization.
Keep secrets out of State. Prefer a small record id, then reload the record and check the current user's permission in every handler:
class ProjectPanel(Component):
class State:
project_id: int
page: int = 1
class Events:
def refresh(self, state, request):
project = load_project_for_user(
project_id=state.project_id,
user=current_user(request),
)
return ProjectPanel(
project=project,
page=state.page,
)
If State cannot be readable in the page at all, State._storage = "server" stores its values in the configured Citry cache and sends an opaque lookup key. That adds a shared-cache requirement in multi-worker deployments. It does not change the client-input rule: authorize every use of the restored values.
Protect event posts from CSRF
Every non-GET Events request passes Citry's always-on cross-site request floor. JSON calls must carry the X-Citry-Events header. When the browser supplies Origin, its authority must match the request's Host; when it supplies Sec-Fetch-Site, the value must be same-origin or none. These checks remain in place even when a handler sets csrf=False.
Django's CsrfViewMiddleware applies to Citry routes normally. Citry does not exempt them. The client runtime reads Django's csrftoken cookie and sends it as X-CSRFToken by default, so keep the middleware enabled. Django still owns token creation, rotation, cookie or session storage, and validation. Citry only carries the token on requests made by its browser runtime.
If Django stores the token in the session or makes the CSRF cookie HttpOnly, JavaScript cannot read that cookie. Render Django's masked token into the DOM, then configure a token function instead:
<input
type="hidden"
name="csrfmiddlewaretoken"
c-value="csrf_token"
>
Citry.events.configure({
csrf: {
token: () => document.querySelector(
'[name="csrfmiddlewaretoken"]',
).value,
},
});
Citry templates do not interpret Django's {% csrf_token %} tag. Pass the masked token returned by Django's get_token(request) as the component's csrf_token input. The same hidden input is what a native form post needs, so native forms continue to follow the host's normal token rules.
FastAPI, Starlette, Flask, and bare ASGI or WSGI do not provide one standard host token scheme. If your application requires an additional token, configure a callable on the component or one handler:
from citry.ext.events import EventError, event
def check_csrf(request):
expected = current_csrf_token(request)
if request.headers.get("x-csrf-token") != expected:
raise EventError(
"The call failed the CSRF check; reload and try again.",
status=403,
)
class Profile(Component):
class Events:
_csrf = check_csrf
def save(self, data: ProfileIn):
update_profile(data)
@event(csrf=False)
def token_authenticated_callback(self, request):
verify_bearer_token(request)
Use Citry.events.configure to tell the browser where to find and send a custom token before the runtime starts making calls:
Citry.events.configure({
csrf: {
cookie: "app_csrf",
header: "X-CSRF-Token",
},
});
The csrf=False override disables only Citry's configurable callable token check. The always-on cross-site request floor and independently configured host middleware still apply. It does not exempt a Django route from CsrfViewMiddleware.
GET event handlers are exempt from CSRF protection because GET must be safe and read-only. Citry enforces the declared HTTP method, but it cannot prove that the Python body has no side effects. Expose only idempotent reads as GET handlers.
Authorize every event
Placement inside class Events makes a public method remotely callable. Use a component-wide _guard, a per-handler @event(guard=...), or an explicit check inside the handler. A guard runs for every matching call and may reject it with EventError:
from citry.ext.events import EventError
class DocumentEditor(Component):
class State:
document_id: int
class Events:
def _context(self):
return build_event_context(self.request)
def _guard(self):
document = load_document(self.state.document_id)
if not can_edit(self.context.user, document):
raise EventError(
"You cannot edit this document.",
status=403,
)
def save(self, data: SaveIn, state):
save_document(state.document_id, data.body)
Guards are useful for rules shared by all handlers. Keep payload-dependent authorization in the typed handler body, after the input has been validated. Authentication still belongs to the host application and is available through the injected neutral request or request.native.
For the handler and State workflow, see Server events. The direct event routes page covers the HTTP-facing cases.
Sandbox Python template expressions
Anything inside {{ }} or a c-* attribute is Python code. Citry evaluates it through a sandbox that blocks the ways an expression could reach dangerous parts of the runtime.
How the sandbox works
An expression passes through two layers before it produces a value.
- A Rust layer parses the expression and allows only a whitelist of expression shapes. Statements (assignments,
del,import,raise,assert,async/await,yield) are not expressions, so they are rejected when the expression is compiled. This raises aSyntaxError. - A Python layer runs at evaluation time. It rewrites every variable read, attribute access, subscript, and call into a checked version, and those checks enforce the actual access rules against your render context. A blocked access raises
SecurityError.
The two layers fail at different times. Forbidden syntax fails when the expression is compiled; a blocked access fails only when the expression is evaluated with a context.
What the sandbox blocks
The sandbox is modeled on Jinja's sandbox. It blocks the known escape routes:
- Private and dunder attributes. Any attribute whose name starts with an underscore is blocked, including dunders like
__class__. This closes the usual traversal from an object to__globals__and__builtins__. - Underscore names and dict keys. A variable name starting with
_, a walrus target starting with_, and a string dict key starting with_(for exampledata['_key']) are all blocked. - Dangerous callables. A denylist covers
eval,exec,__import__,getattr,setattr,open,str.format, and others. The check is by identity, so passing one into the context under a harmless name does not get around it.
Here is the private-attribute rule in action:
from citry import SecurityError
from citry_core.safe_eval import safe_eval
# Dunder / private attribute access is blocked at eval time
compiled = safe_eval("obj.__class__")
try:
compiled({"obj": object()})
except SecurityError as e:
print(e) # attribute '__class__' on object '<class 'object'>' is unsafe
And the identity-based callable check, which catches a renamed builtin:
from citry import SecurityError
from citry_core.safe_eval import safe_eval
# eval() is blocked even when smuggled in under a harmless-looking name
compiled = safe_eval("totally_no_e_val('1+1')")
try:
compiled({"totally_no_e_val": eval})
except SecurityError as e:
print(e) # function '<built-in function eval>' is unsafe
str.format and str.format_map are blocked because their format syntax can reach __builtins__. Use f-strings, which the parser rewrites into a safe call.
Why builtins are not available
No Python builtins are exposed inside expressions. len, str, range, and the rest are not there. This is a direct consequence of the sandbox: builtins are looked up in your render context, and the context does not contain them unless you put them there. So {{ len(items) }} fails with KeyError: 'len'.
The recommended fix is to compute derived values in a component's template_data method, which is plain Python with every builtin available, and pass the result to the template. See Expressions for the full pattern.
class Cart(Component):
template = """
<p>{{ count }} items</p>
"""
def template_data(self, kwargs, slots):
return {"count": len(kwargs["items"])}
Marking your own functions unsafe
The denylist covers known-dangerous builtins, but a function you write is allowed to be called from an expression by default. To forbid a specific function, decorate it with unsafe. Django-style methods with alters_data=True are blocked the same way.
from citry import SecurityError
from citry_core.safe_eval import safe_eval, unsafe
@unsafe
def dangerous_function():
return "dangerous"
compiled = safe_eval("dangerous_function()")
try:
compiled({"dangerous_function": dangerous_function})
except SecurityError:
print("blocked")
What the sandbox does not protect
Be honest about the boundary. The sandbox is a whitelist of allowed syntax plus a denylist and attribute filter at runtime. It blocks the documented escape vectors, but it is not a formally proven-complete jail.
- Custom objects expose their whole public API. Any object you place in the context is reachable through every attribute and method that does not start with an underscore. If one of those methods can do something dangerous, an expression can call it. The sandbox filters attribute names; it does not reason about what your methods do.
- Your own callables are allowed unless you opt out. A function you write is callable from an expression until you mark it
unsafeor setalters_data=True. - The denylist is a denylist. It covers the known-dangerous builtins. Treat it as blocking specific vectors, not as an absolute guarantee.
The rule of thumb: only put objects and functions into your render context that you are comfortable exposing to template authors.
Browser CSP and Alpine expressions
The Python sandbox described above does not govern browser expressions. Citry ships both Alpine's standard evaluator and a version-matched CSP evaluator. The standard security_csp="off" and "warn" modes require unsafe-eval when they evaluate Alpine attributes. security_csp="strict" selects the CSP runtime and enforces its smaller expression language before serialization.
See Alpine runtime for the client-side loading and fragment contract.
Turning the sandbox off
If every template on a citry instance comes from a trusted source, you can turn the sandbox off with Citry and sandbox_expressions=False. This removes the access checks for that instance. Do this only for trusted input.
from citry import Citry
app = Citry(sandbox_expressions=False)
Two things stay the same even with the sandbox off, so a successful render produces byte-identical output either way: builtins remain unavailable, and a walrus assignment still writes back into the variables mapping. The difference shows only on failures.