v0.8.0
Citry 0.8.0 makes Events refuse event data and State writes that are too large, too deeply nested, or too slow to check. Pydantic classes in Events now need Pydantic 2.13.0 or newer. Signed State tokens from earlier versions stop working. The output checks security_javascript and security_csp now find more scripts and event handlers that browsers run, so a page that passed them before can now be reported. Tags for files that Citry serves now carry integrity, and event calls send the CSRF token only to the page's own origin. The upgrade notes below list what can fail or change.
Citry 0.8.0 does not import on Pyodide (Python in the browser), so use Citry 0.9.0 or later there. For the same reason the docs site has no 0.8.0 entry in its version picker.
Upgrading from 0.7.x
- Signed State tokens from earlier versions stop working: State tokens in the default signed storage now use a key that Citry derives from your secret only for State tokens, so values that other code signs with the same secret (such as Django's
SECRET_KEY) cannot be used as State. After you upgrade, a page that was open before the upgrade asks the visitor to reload on its next event call. During a rolling deploy, old and new processes reject each other's signed tokens until every process runs the new version. - Pages that passed the output checks can now be reported: the
security_javascriptandsecurity_cspchecks now read a page in each way a browser may build it, and withsecurity_javascript="forbid"such a page raisesValueError. The checks also refuse a page that would take too long to read every way. Citry also writes the ESC character in an escaped value as, which<style>,<xmp>and<script type="text/plain">show as written. The Fixes below list each case. - Clear proxy, CDN, and whole-page caches when you upgrade: script and stylesheet tags that Citry writes for files it serves now carry
integrityandcrossorigin="anonymous"at everysecurity_script_integrityvalue, including/citry/citry.jsandlocal_files = "serve"files. The browser now refuses a file that a proxy or CDN changed, and acitry.jsfrom a different version than the page, so a page cached before the upgrade fails to load its files. A CDN that serves these files from another host must sendAccess-Control-Allow-Origin. During a rolling deploy, a page can fail until a reload reaches the same version. - An empty or non-string secret now raises an error:
Citry(secret=...)raisesValueErrorfor a secret, or an entry of a list, that is empty or only whitespace, andTypeErrorfor an entry that is not a plainstr. The error gives the entry's position. If your secret can come out empty, for example[os.environ["NEW"], os.environ.get("OLD", "")], leave out keys that are not set.citry.contrib.django.secret()skips such entries for you. The setting takes a string, a list, or a tuple; pass any other collection as a list. - Event calls send the CSRF token only to the page's origin and no longer follow a redirect: event calls to an events
urlon another origin now carry no token from the built-in transport, and a redirect answer makes the call fail. Returnactions.Redirectto send the visitor to another page.Citry.events.configure({csrf})now rejects a header name thatCitry.configurerejects. - A
<base>element no longer changes where Citry's requests and URL actions go: event calls,$i18nmessage requests, and theRedirect,PushUrl, andReplaceUrlactions now resolve a relative URL against the page's own address. If your page sets<base href>on purpose, write these URLs as paths that start with/. - Upgrade every process that shares a cache: a
css_data()stylesheet in a configured cache is now built again from its stored values each time it is served. One stored by an earlier version returns 404 until a page with the same values renders again, and a cached component withcss_data()values renders again once. A process on an earlier version keeps writing entries that the new version does not serve. A State that holds a filter tree needs a discriminator: if a field the browser may write holds a tree of nodes of different classes, such as
And | Or | CondwhereAndandOrhold more nodes, and the union has no discriminator (a field, or a function, that tells Pydantic which class a node is), defining the component raisesValueError. When you do not set_model, the browser may write every public field. This also applies to a plainclass State:thatCitry(model_base=BaseModel)turns into a model. Pick one fix (details):Give each node class a
Literaltag field, and declare the discriminator on theNodetype below. Every node the browser writes must then carry the tag, and a node without it is refused, so update browser code that builds nodes. A page opened before the deploy with an untagged tree in its State asks the visitor to reload once, on its next event call.from typing import Annotated, Literal from pydantic import BaseModel, Field class And(BaseModel): type: Literal["and"] = "and" items: list["Node"] # Or and Cond get a tag field of their own in the same way. Node = Annotated[ And | Or | Cond, Field(discriminator="type") ]Use a callable
Discriminatorwith aTagon each class. When each class has a field the others lack (below,itemsonAnd,anyonOr,fieldonCond), the browser writes the same data as before. This is the only discriminator fix when some types in the union are lists or tuples, as inNode = list[Node] | tuple[Node, ...] | int.from typing import Annotated from pydantic import Discriminator, Tag def pick(v): if isinstance(v, dict): if "any" in v: return "or" return "and" if "items" in v else "cond" return {And: "and", Or: "or", Cond: "cond"}.get(type(v)) Node = Annotated[ Annotated[And, Tag("and")] | Annotated[Or, Tag("or")] | Annotated[Cond, Tag("cond")], Discriminator(pick), ]Set
_modelto the fields the browser may change, without the tree. The browser can then no longer write that field.
Pydantic classes in Events need Pydantic 2.13.0 or newer: on an older Pydantic, defining a component raises
RuntimeErrorwhen its eventdataclass or State is a Pydantic v2 model or Pydantic dataclass, or when its plaindataclass holds one. This includes thedataand State classes thatCitry(model_base=BaseModel)turns into models. Upgrade Pydantic before upgrading Citry.Some other schemas are refused when the component class is defined: defining the component raises
ValueError, and the error names the field and the fix, when a field that the browser may write, or aJson[...]field (a Pydantic type that makes a field read its value from JSON text), holds one of these:- a tree like the one above, in a
Json[...]field or in a field of a Pydantic model State; - extra keys that the class allows and checks against a type that can fail with many errors, such as a model, a list or dict, or a union of several types;
- items that can fail with several errors, in a list or dict inside a
Json[...]field that has a wrap validator around it (a validator that receives the value and the next validator), such as a list of models; - a recursive model, dataclass, or TypedDict (one that can hold itself) with no wrap validator on the way back to itself, in a model State where Citry reads browser writes only as JSON text, as it does with
ConfigDict(strict=True).
To fix the last case, remove the setting that makes Citry read the State only as JSON text (the strict setting, for example), leave the field out of
_model, or add a wrap validator to the recursive class, which makes Citry apply the 32-level limit there.The details list every case, including the ones that are fine (details). Citry cannot run this check on a class it sees only when a call arrives, such as a model inside a plain
dataclass. A call that reaches it (for State, a call that writes it) fails with a 422 (invalid_args) that says "The value could not be checked.", and Citry logs the field and the fix once. - Apydantic.v1class can't be used in an Eventsdataclass or inside a Pydantic v2 State: defining a component now raisesValueError, naming the class, when itsdataclass is, or holds, apydantic.v1model or dataclass. This covers a field, a list, dict, optional or union, a nested class, and a Pydantic v2 class that holds one or checks a field with one of its methods, such asPlainValidator(Model.parse_obj). A Pydantic v2 State whose fields hold such a class, or are checked with such a method, is refused the same way. Some of these worked in 0.7.x and are now refused: apydantic.v1dataclass as thedataclass, a plain class that holds one, and a field checked with a v1 method. Before you upgrade, make the v1 classes Pydantic v2 models, and check fields with Pydantic v2 validators. A State that is itself apydantic.v1model still works (details). - Clear server-stored State: if a component setsState._storage = "server"and your cache keeps its data across restarts (Redis, for example), delete the keys that containcitry:state:once every process runs the new version, so that Citry restores only State that the new version saved. A cacheprefix, or theKEY_PREFIXof a Django cache, goes in front of that text, so the pattern below matches it anywhere in the key. With Redis, for example:redis-cli --scan --pattern '*citry:state:*' | xargs redis-cli unlinkAdd
-h,-pand-nto bothredis-clicalls if your Redis needs them, and check first that no other application on that Redis keeps keys that contain the same text. If you skip this, State that the older version saved stays in the cache, and Citry restores it as it was saved, until each entry expires. An entry expires afterState._max_age, or after 24 hours when you do not set it, so waiting that long works too. A page that is open when the keys go asks the visitor to reload on its next event call. An in-memory cache starts empty on every restart, so it needs nothing. - A 422 names only the first bad item of each list or dict: code or tests that expect a message for every bad row must change. Show the first bad row and let the user fix it and send again, or declare the field loosely (for examplelist[dict]), check each row in your handler, and raiseEventErrorwith a message for each bad one. A browser write to a Pydantic v1 State still lists up to 20 items, and so does a list or dict inside a wrap validator. A fixed-length tuple lists each bad position, and extra keys that a model allows and checks against a type list each bad key (details). - Data with too many errors gets one message: event data with more than about 1,000 errors gets a single message in place of the field messages. The message sits underfieldErrors._args, the key for a message about the whole call's data, and a State write gets it underfieldErrors._state. A form that shows only the message next to each field then shows nothing, so also show the message, as in$error('submit')?.fieldErrors?._argsfor a handler namedsubmit(how). - Checking one request's data stops after about 1 second of CPU time: once the request's checks have used that time, the call being checked fails with a 422 that says the data took too long to check, underfieldErrors._args(fieldErrors._statefor a State write). A browser write to a Pydantic v1 State stops the same way. Citry does not interrupt a slow validator of your own. Every check of apydantic.v1State, including restoring it at the start of a call, counts toward that second, so a request with several such calls can get the "took too long" 422. Run more than one worker process so one slow request does not delay others (details). - Deep trees in event data need a discriminator: event data that holds a tree like the one above, with no discriminator on the union of node classes, fails with a 422 underfieldErrors._argsthat says the data is nested too deeply to check, or too large or nested too deeply to check. A Pydantic model fits about 14 levels with one child per node. WhenAndandOrhold their children in fields of the same name, it fits about 8 levels with two. More children, or more data in each node, lower those numbers. Declare the discriminator as above and a tree can nest about 30 levels. Citry checks a plain class or dataclass itself, without reading discriminators, so make the node classes Pydantic models, or keep the nesting shallow (details). - Data nested more than 32 levels is refused: a Pydantic v2 class that checks event data or a browser State write now stops at 32 levels, and the call fails with a 422 underfieldErrors._args(fieldErrors._statefor a State write) that says the data is nested too deeply. Citry logs a warning naming the class. A model, dataclass, TypedDict, wrap validator,Iterable[...]orGenerator[...]field, or union of classes each counts as one level (a model State that Citry reads only as JSON text counts only wrap validators, as explained above). Send data with fewer levels, or give a recursive union a discriminator. The limit does not apply when Citry restores State that the server saved. If your server runs requests on threads with less than 1 MiB of stack (some Windows and Alpine setups), callthreading.stack_size(1024 * 1024)before the server starts them (details). - Events checks every item of anIterablefield before the handler runs: a bad item of anIterable[...]orGenerator[...]field in event data or a State write now fails the call with a 422. Before, it raised aValidationErrorwhen the handler read the item, so atryaround your loop no longer catches it. A union whoseIterablemember has a bad item now tries its next member. The handler gets an iterator over the checked items, and the items count toward the same limits as the rest of the data. - Pydantic dataclass State writes are checked when they arrive: withoutvalidate_assignment, on a browser write Citry runs the class's field validators, model validators,__post_init__, and anydefault_factorythat takes the other fields, as the class does when it is built. A value the class refuses now fails the call withinvalid_args(422) and a message for that field infieldErrors(fieldErrors._statefor an error from a model validator or__post_init__), and the State does not change. Before, the call succeeded and the next call failed withstale_state. The State still keeps each value as the browser sent it, as the class does: atuple[int, ...]field written as[1, 2]holds the list[1, 2]until the next call restores it as the tuple(1, 2). Withvalidate_assignment, Citry checks each assigned field, as the class does. - A Pydantic dataclass State refuses a write when a validator raises an unexpected error: when a validator of a Pydantic dataclass State raises an exception other thanValueErrororAssertionError, such asRuntimeError, while Citry checks a browser write, the call now fails at once with a 422 and "The State could not be checked." underfieldErrors._state, with or withoutvalidate_assignment. Before, a dataclass State withoutvalidate_assignmentaccepted the write, and the next call failed withstale_stateor a server error. A model State already answered this way. - Calls in one request that carry the same State can share one restore of it: a request can carry several calls, and when they carry the same State values, Citry can restore the State once and give each call its own copy. A validator with a side effect, or a default that changes each time, such asdefault_factory=uuid4, can then run fewer times than there are calls. - Citry no longer calls yourdataclass'smodel_validate, or an override of it, for event data. Move that logic into amodel_validator. - Pydantic plugins no longer record event checks: plugins such as Logfire do not see the checks of event data and State writes, because Citry runs them with its own copy of the class's validator. - Validators that readinfo.modecan fail State writes: Citry can check a browser write to a Pydantic model State both as Python values and as JSON, so a validator that answers differently in the two modes can refuse a valid write. Keep such validators out of a model State. - Long field paths can refuse a valid call: with a Pydantic v1 State, a Pydantic model State whose browser writes Citry can check only as JSON text (a strict one, for example), or, rarely, a Pydantic v2 class whose checks Citry could not set up (it logs an error), Citry adds up the length of the field paths in the data (such asscores.alice.3) and answers with a 422 before Pydantic runs when they add up to too much text. This most often refuses a large write to such a State when it has a union without a discriminator or many required fields. Send shorter keys or fewer items, or give the union a discriminator (details). - A signed State write pastState._max_bytesis refused before the handler: a browser write that makes a State in the default signed storage larger thanState._max_bytesnow gets a 422 before the handler runs, when the response would carry a new State. Such a call used to fail with a server error after the handler, or succeed when the handler made the State small again. Raise_max_bytes, or have the browser write less. - Text with an unpaired surrogate is refused in State writes: a browser write of text with an unpaired surrogate character, which JSON allows as an escape such as"\ud800", now gets a 422 before the handler runs. It used to fail with a server error after the handler (a server-storedpydantic.v1State saved it). - Deeply nestedpydantic.v1State writes are refused sooner: a browser write to apydantic.v1State now fits about 120 levels of nesting, down from about 135. A deeper write gets a 422 that says it is nested too deeply.- a tree like the one above, in a
Added
- You can now set the page's CSRF token once with
Citry.configure({csrf: {...}}). Event calls and$i18nmessage requests both read it.
Changed
- The security guide now recommends
base-uri 'self'in your Content Security Policy.
Fixes
$i18n.switchLocale()no longer fails with status 403 on Django: the request that loads the new locale's messages now sends the page's CSRF token, the same way event calls do.- A few kinds of long input no longer take seconds or more to handle: a
stylevalue with many unclosed/*comments, a request path for a route with several parameters in one path segment (such as{name}-{version}, which an extension can add), and, in the browser, aRedirectURL with a long run of spaces. Each now takes time in step with its length. The style and route results are the same as before, and aRedirectURL is refused for its scheme exactly when it was before. - Parsing a long
stylevalue, or an event request with long strings, now uses less memory. - When an event call's arguments or State updates hold a very long dict key, or a dict key with many dots, Citry no longer copies that key into the field path of every item below it. The field messages are the same as before.
- Citry now stops checking event data and State writes early once they fail (see Upgrading from 0.7.x). A 422 also stops listing field messages once their text would pass 65,536 characters, and its message says how many it left out. It cuts a field message longer than 1,000 characters to that length (details).
- A call with bad input to a Pydantic dataclass
dataclass, or to a Pydantic dataclass State withvalidate_assignment, now gets a 422 with field messages, not a server error. - A call that writes to a
pydantic.v1State is faster: Citry checks each written State once, before the handler, and reuses that result for the rest of the request. - A
pydantic.v1State write that Citry cannot read back from the State it stores now gets a 422 with field messages before the handler, not a server error after it. - A browser write to a
pydantic.v1State that holds a model with a forward reference Pydantic never resolved now gets a 422, not a server error after the handler, and Citry logs Pydantic's message, which names the model. - A
dataclass whose own code raises, such as a__post_init__, no longer ends the whole request: only its call fails. AnEventErrorkeeps its own status, any other error becomes ahandler_error(500), and the request's other calls still run. security_javascriptnow counts a<script>as running when itstypeholds a character such as U+3000, and finds adata:URL in a<meta http-equiv="refresh">padded the same way. Chromium skips these characters, so it ran the script or opened the URL.security_javascriptnow counts an HTMLdata:document as running code when it holds anything outside plain ASCII, because a browser can read such text in another encoding. Write such text as a character reference with&and#percent-encoded, such as%26%23233;foré.security_javascriptnow reports text that changes a page's tags when a browser reads the page in another encoding: the escape bytesESC $andESC (, non-ASCII text in a<![CDATA[section, and a<followed by a NUL byte, which spells a tag when a response names UTF-16. A page could use<meta charset>to hide a script from the check. Only text Citry does not escape, such asMarkup, can trigger this. Send pages withcharset=utf-8in theContent-Typeheader, so the browser reads them the way Citry does.security_cspandsecurity_javascriptnow find more scripts and event handlers in pages that browsers read differently from the way Citry's check read them. A page inMarkupcould hide a script from the check, and the browser would run it with no click:- Inside
<select>, current browsers keep tags such as<style>and<svg>, and older ones drop them. Citry's check now reads such a page both ways. - Around SVG and MathML elements that hold HTML, such as an SVG
<title>, Citry's check closed elements that browsers keep open. - Firefox and Chromium each differ from the HTML standard on a few misplaced end tags, such as
</p>in an SVG<title>. Citry's check now also reads such a page the way each of them does. - An
iframe srcdocdocument is now read in both quirks and standards mode, because Chromium and Firefox pick different modes for it. - Your app's own script can put HTML into the body of a page that is already open, with
innerHTMLon an ordinary element such as a<div>. Browsers build some tags differently there: a<frameset>builds nothing, and the open page's doctype picks the mode. Citry's check now also reads such HTML that way, in both quirks and standards mode. - In an interactive component, Vue inserts HTML such as
Markupas one block, which Chromium builds with the markup inside a<noscript>as real elements. Citry's check now reports an event handler or script inside a<noscript>too.
The check does not cover HTML that your own script inserts with
innerHTMLinto an element with special parsing rules, such as<svg>,<math>,<table>and its parts,<select>or<form>. Build such HTML in a<template>element and move its contents into place instead (see the security page).- Inside
security_javascript="forbid"now raisesValueErrorfor a page with markup that browsers may build into a page that Citry's check does not build, and"warn"and"omit"warn about it. A page that passed before can now raise. Such markup is rare, and usually a mistake, for example an<svg>or<math>right after a formatting element that an outer end tag closed, as in<p><b>Bold</p><svg>(close the<b>first). A valid MathML<annotation-xml encoding="text/html">and a<table>inside an unclosed<p>under a few old doctypes (see the security page) are refused too. The error names what to change, and the security page lists every case.- The output checks now refuse a page that keeps hundreds of elements open across much of it while it also holds markup that Citry reads in several ways, so that checking any page stays fast. No ordinary page comes close.
- Citry now writes the ESC character in an escaped value as
, in text and attributes, on normal and Vue pages. On a page read as ISO-2022-JP, a raw ESC could change how the browser read the HTML after it, so that ordinary escaped values could run code. Visitors see the same text, except inside<style>,<xmp>and<script type="text/plain">, which showas written, as they already do for<.Markup.format(),%,+andjoinuse MarkupSafe's own escaping, which keeps ESC. security_cspandsecurity_javascriptnow read a numeric reference to a control character in an attribute value as the browser does, sojavascript:is a relative link, not ajavascript:URL, andMarkupin an attribute keeps such a character.citry previewnow sendscharset=utf-8with text responses, as the Django, ASGI and WSGI adapters do.- With the
Debugextension on, a render whose HTML starts with many comments, such as a comment inside a<c-for>over a long list, could take minutes. It now takes time in step with the length of the HTML. The same fix applies to third-party extensions that, likeDebug, skip their wrapper around a whole HTML document. - After you change a component's
State._storage, a page rendered before the change gets astale_stateerror on its next event call, and the visitor is asked to reload. css_data()now raisesValueErrorfor a value with a control character outside a quoted string, so the browser reads the value the way Citry checked it. Tab, newline, carriage return and form feed are still allowed. Remove the character, or keep it inside a quoted string.