Ushka

API reference

ushka.guard.untrusted

The Untrusted string type and its propagation rules.

An Untrusted value is a str that remembers it came from outside the program: an HTTP request, a cookie, a header, or the output of a language model. It behaves exactly like str for reading, comparing, hashing and serialising, so existing code keeps working. What changes is that the dangerous sinks (raw HTML, redirects, file paths, raw SQL, ...) refuse it until it goes through an explicit sanitiser from ushka.guard.safe.

Propagation

Operations that build a new string from an Untrusted value return an Untrusted value too: + (on either side), *, % (with the untrusted value on either side), slicing, .format()/.join() called on it, and every case/whitespace/replace method. split and partition return lists/tuples of Untrusted.

Known gaps (documented, tested as such)

CPython builds some strings in C without asking the operands, so the mark is lost in:

  • f-strings with more than one part: f"<b>{name}</b>"
  • .format on a trusted template with more than one part: "Hi {}".format(name)
  • % with a tuple of arguments: "%s-%s" % (a, b)
  • .join called on a trusted separator: ", ".join([name])
  • str(value) (see the note on __str__ below)
  • .encode() (bytes are not tracked)

ushka.guard.rewrite closes the first five for modules it compiles (Ushka applies it to the files in routes/). Even so this is defence in depth, not a proof. On Python 3.14+ prefer t-strings (t"<b>{name}</b>") for HTML and SQL: they keep every interpolated value separate by construction.

This module has no dependency on the rest of Ushka on purpose.

class Untrusted(str)

A string whose content came from outside the program.

Create one with Untrusted(value, source): value is anything str() accepts; source says where it came from (e.g. "query:next" or "llm") and appears in error messages. If source is omitted and value is already Untrusted, its source is kept.

describe(self) -> str

A debugging representation showing the value and its source.

split(self, sep: str | None = None, maxsplit: int = -1) -> list

rsplit(self, sep: str | None = None, maxsplit: int = -1) -> list

splitlines(self, keepends: bool = False) -> list

partition(self, sep: str) -> tuple

rpartition(self, sep: str) -> tuple

join(self, iterable: Iterable[str]) -> Untrusted

def taint(value: Any, source: str = UNKNOWN) -> Any

Marks value as untrusted if it is a string; returns other types as-is.

def taint_deep(value: Any, source: str = UNKNOWN) -> Any

Marks every string inside dicts/lists/tuples as untrusted.

Dictionary keys are marked too: they come from the same payload.

def is_untrusted(value: Any) -> bool

True if value is an Untrusted string.

def source_of(value: Any) -> str | None

The recorded source of an Untrusted value, or None.