Security in 3.2.1
3.2.1 is a security release. It closes three issues in the secure-by-default IO layer introduced in 3.2.0, all reachable when loading a document with the default trusted=False, and sharpens the security model: a statechart is an executable document, not inert data. The secure-by-default mode guarantees confidentiality and integrity (loading runs no arbitrary code and reads no arbitrary files); it does not sandbox availability, and it is hardening for your own dynamic definitions, not a sandbox for genuinely adversarial documents. See the IO security guide.
Note
Am I affected?
- Yes, if you load documents you did not author (via
statemachine.io.load(...)/build_processor(...)/SCXMLProcessor) with the defaulttrusted=False. - No, if you define machines in Python, only load documents you wrote yourself, or already load with
trusted=Truefor fully controlled documents.
Affected versions: >= 3.2.0, < 3.2.1 (this attack surface shipped with the secure-by-default IO layer in 3.2.0). Fixed in 3.2.1.
Local file disclosure via external src (GHSA-fj3w-533r-fvf6)
Loading a document with <data src="file:…"> or <invoke src="…"> (or srcexpr) read the named local file during loading, regardless of trusted. A document from an untrusted source could read an arbitrary file on the host (e.g. file:///etc/passwd) and exfiltrate it through the datamodel. External src references (including relative paths, so a document that includes a child from disk) are now rejected with InvalidDefinition unless trusted=True, the same gate already used for <script>. Separately, the SCXML parser now refuses any <!DOCTYPE>/DTD, neutralizing XML entity-expansion denial-of-service bombs (billion laughs / quadratic blowup) at parse time, independent of trusted.
Reported independently by @Pig-Tail, @the-vibe-dev (GHSA-82pq-c599-953q) and @manus-use (GHSA-v2p7-x2f2-p4xg).
Write to protected/dunder attributes via <assign> and friends (GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc)
Actions that pick a write destination from the document (<assign location>, <foreach item>/index, <data id>, <send>/<invoke idlocation>) only validated the final attribute. A document could therefore traverse __class__ (e.g. location="__class__.__init__") and setattr on the shared model class, corrupting every state machine in the process, or write to other private/protected attributes. Write targets are now confined to public model attributes on every path segment (private, dunder and engine-protected names are rejected), surfacing as error.execution.
A related route reached the same shared state through the SCXML system-variable views: even though the raw engine names (machine, model, ...) are withheld, the _event and _ioprocessors facades still re-exposed the machine, the interpreter and core State objects through public attribute chains (_ioprocessors.interpreter, _event.trigger_data.machine). Two defenses close it. The facades are now sanitized: they retain no reachable reference to the machine, interpreter or event carriers via public (non-_) attributes, so a restricted expression can no longer walk _event/_ioprocessors back to the engine. And the write-target guard now additionally rejects a live engine-capability instance (the machine, interpreter, State/Transition/Event, and the system-variable facades) as a traversed hop or write target, so even a leaked alias cannot be pivoted onto shared state.
Reported by @manus-use (GHSA-v3qq-3xvg-m77g) and @the-vibe-dev (GHSA-4857-ggqc-p3jc).
Unbounded arithmetic denial of service (GHSA-r8gj-366q-cgvj)
The restricted evaluator allowed ** and * with no magnitude bound, so a tiny expression could exhaust CPU (9 ** 9 ** 9, a ~370-million-digit bignum) or memory ([0] * 20000000). Both operators are now magnitude-capped: the denial-of-service forms are rejected before they run, while ordinary scalar arithmetic (x * 2, x ** 2) is unaffected. (trusted=True uses full Python, without these caps.)
Reported by @manus-use.
Note
Availability is still not sandboxed. A running machine's logic (eventless loops, large <foreach>, recursive <invoke>, delayed events) can consume CPU, memory or threads without bound. Run documents from parties you do not trust under your own timeout and OS/process resource limits, or under OS-level isolation. See the IO security guide.
Bug fixes in 3.2.1
Symmetric state for on_exit_state across compound boundaries
When exiting a compound state directly (a transition like child -> outsider), the generic on_exit_state() callback reported the transition's source for every exited state. Exiting child and its parent parent both arrived with state and source bound to child, so the parent level was never observable and the two exit calls were indistinguishable.
This was asymmetric with on_enter_state(), which already binds state (and target) to each individual state being entered. The exit side now matches: state (and source) is bound to the individual state being exited.
>>> from statemachine import State, StateChart
>>> class FSM(StateChart):
... orphan = State(initial=True)
...
... class parent(State.Compound):
... child = State()
...
... switch = orphan.to(parent.child) | parent.child.to(orphan)
...
... def on_exit_state(self, source, state):
... print(f"exit {state.id} (source={source.id})")
>>> sm = FSM()
>>> sm.send("switch")
exit orphan (source=orphan)
>>> sm.send("switch")
exit child (source=child)
exit parent (source=parent)Before this fix, the last line read exit child (source=child), hiding the parent. State-specific callbacks (on_exit_<state>) were already correctly keyed per state and are unaffected. Flat (non-compound) machines are also unaffected, since there the exited state is always the transition's source.
#634.
Negative indices in OrderedSet.__getitem__
OrderedSet.__getitem__ raised ValueError (leaking from itertools.islice) when called with a negative index, instead of following the sequence protocol. Negative indices now count from the end like any Python sequence, and an index that is still out of range after normalisation raises IndexError:
>>> from statemachine.orderedset import OrderedSet
>>> s = OrderedSet([1, 2, 3])
>>> s[-1]
3
>>> s[-3]
1
>>> s[-4]
Traceback (most recent call last):
...
IndexError: index -4 out of range#633.
Lazy / translation proxy objects as name
A State (or Event) name can now be any object castable to str, including lazy translation proxies (e.g. django.utils.translation.gettext_lazy). Earlier 3.x releases stored the value as-is and later assumed it was a real str, so a proxy broke message formatting (notably the TransitionNotAllowed message) and str(state). The proxy is now kept untouched and only resolved via str() at the point of display, so the active locale is honored at render time instead of at class-definition time.
>>> from statemachine import State, StateMachine
>>> class Lazy: # stand-in for a translation proxy (resolved on str())
... def __init__(self, value):
... self.value = value
... def __str__(self):
... return self.value
>>> class SM(StateMachine):
... draft = State(Lazy("Rascunho"), initial=True)
... published = State(Lazy("Publicado"), final=True)
... publish = draft.to(published)
>>> str(SM.draft)
'Rascunho'#632.
Operators without surrounding whitespace in cond expressions
Boolean expressions used in cond / unless had a fast-path that returned the whole string as a single variable name when it looked operator-free. The check was too naive: it only looked for !, a literal space and In(, so any other operator written without surrounding whitespace was swallowed into a variable name. cond="is_paid^is_shipped" and cond="items>0" were resolved as variables literally named is_paid^is_shipped and items>0, failing with InvalidDefinition: Did not found name ... when the machine was built.
The fast-path now triggers only for a lone Python identifier, so every other expression goes through the parser and whitespace is never required:
>>> from statemachine import State, StateMachine
>>> class Order(StateMachine):
... waiting = State(initial=True)
... completed = State(final=True)
...
... complete = waiting.to(completed, cond="is_paid^items>0")
...
... is_paid: bool = False
... items: int = 2
>>> sm = Order()
>>> sm.send("complete")
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't complete when in Waiting.
>>> sm.is_paid = True
>>> sm.send("complete")
>>> sm.completed.is_active
TrueGuards that are a single name (including a bare v) keep taking the fast-path, and != keeps parsing as a comparison rather than a negation.
As a side effect, a cond with a structure that is not valid in a boolean expression, such as cond="user.age", now raises InvalidDefinition: Failed to parse boolean expression 'user.age' instead of reporting the whole string as a name that was not found.
#639.