Security of the generated server¶
This page describes what the binary py2axum generates guarantees when it faces untrusted clients. It covers
the threat model, the protections the runtime (py2axum/runtime/dynrt/, copied into every generated crate)
puts in place, how they compare with uvicorn + Starlette + FastAPI, and the knobs you can turn. It is the
result of two security reviews done before 1.0 (0.5 and 0.8). Each guarantee is pinned by a test, named in Tests at the
end.
To report a vulnerability, see SECURITY.md.
Contents: Threat model · SQL · Request input · Files and paths
· Slow clients · Errors and logs · Cryptography · Hybrid deployments
· Per-request state · Rust dependencies · unsafe code
· Settings · Tests
Threat model¶
- Attacker: any HTTP or WebSocket client of the binary, sending arbitrary bytes: request line, headers, cookies, query string, body (JSON, forms, multipart), WebSocket frames.
- Trusted: the application's source (it is compiled), its configuration and environment, the database,
the Python sidecar of a hybrid deployment, and the machine itself (files the binary reads,
PROMETHEUS_MULTIPROC_DIR). - Goal: the binary must not be less safe than the Python application it replaces. A behaviour that is
unsafe in the Python application (an endpoint that splices a request value into
text(), a guard written with==on a secret) stays the same in the binary: py2axum reproduces your application, it does not audit it. - Availability: a single request must never take the whole process down. CPython turns most resource exhaustion into an exception (RecursionError, MemoryError) that costs one 500; a Rust stack overflow or a failed allocation aborts the process, and every in-flight request with it. The runtime therefore converts those cases into the same exceptions (see Request input).
SQL¶
- Values are always bound. Every value that reaches a statement (comparisons,
in_(),like/ilike/contains/startswith/endswith,regexp_matchand itsflags=, JSONB and ARRAY operators,limit/offset,insert().values(),update().values(), the unit of work,text()with parameters or.bindparams()) is sent as a$nparameter. A value is never re-parsed for placeholders. - Names given as strings are quoted like SQLAlchemy quotes them. A label (
.label(name)), a subquery or alias name (.subquery(name),.alias(name)), anindex_elementsstring ofon_conflict_do_*, and the label or column named byorder_by("…")are written as is only when they are plain lowercase identifiers that are not reserved words; anything else is put between double quotes with"doubled (SQLAlchemy'sIdentifierPreparer). - Equivalent by design:
text("…")built from a request string,.op(string)andliteral_column-style constructs are raw SQL in SQLAlchemy too; the binary sends exactly what the application wrote. Each distinct.op()operator string is kept for the life of the process: taking operators from the request (beyond a fixed set) grows memory.order_by("string")only accepts a label or column of the statement, else it raises SQLAlchemy's CompileError.cast()accepts a closed list of types,extract()a field made of letters. - Connection settings (
connect_argsoptions, the session time zone) are applied withset_config($1, $2), never spliced intoSET. Savepoint names are generated integers. - DDL (
create_all) is produced by SQLAlchemy itself at compile time from your models; the existence checks run at start-up are parameterised. - Table and column names come from the models at compile time and are not quoted (a reserved word or a mixed-case column name is a correctness gap, not an injection: no request value reaches them).
Request input¶
Limits that FastAPI and uvicorn do not have stay off by default, so a translated application answers the same requests. The ones Starlette applies are applied the same way.
| Input | uvicorn / Starlette / FastAPI | The binary |
|---|---|---|
| Body size | no limit | no limit by default; PY2AXUM_MAX_BODY (bytes) answers 413 past it, checked on Content-Length and while reading a chunked body |
| JSON nesting | CPython's decoder (~10 000 levels on 3.12/3.13, ~50 000 on 3.14), then RecursionError → 400 | iterative decoder, 400 past 10 000 levels; see below |
| Numbers | int → str limited to 4 300 digits | integers beyond 64 bits are an OverflowError (documented in Python semantics), past 4 300 digits a 400 as in FastAPI; decoding is linear |
Decimal |
libmpdec: exponents up to ±10¹⁸, default context (28 digits, Emax 999 999) | same limits and context: comparisons and bounds decided on the exponents, Overflow, DivisionImpossible and subnormal rounding as CPython; long coefficients parsed and counted in quasi-linear time |
Fraction |
int()'s 4 300-digit limit |
same (fraction_parsing in a model); exponents past 100 000 are refused (a RuntimeError, 500) |
| Invalid UTF-8, lone surrogates | 400 / kept | 400 / U+FFFD (documented) |
| Multipart | max_files 1 000, max_fields 1 000, 1 MiB per non-file part |
same limits, same 400 messages |
| URL-encoded forms | 1 000 fields, 1 MiB per field (Starlette ≥ 1.4) | same |
| Upload file names | as sent, an IE-style C:\… path cut to its last part (python-multipart) |
same; files are held in memory, never written under the client's name |
| Headers | h11/httptools limits | hyper: at most 100 headers (431 past it), ~400 KB in total |
| Cookies | Starlette's parser, a dict | same parser, linear in the header size |
| WebSocket messages | 16 MiB (ws_max_size) |
16 MiB per frame and per message |
| Regular expressions | Field(pattern=) with pydantic-core's linear engine; re backtracks |
Field(pattern=) with Rust's linear regex; re uses fancy-regex (backtracking, as CPython) |
Deeply nested values. The decoder accepts up to 10 000 levels, as CPython does, and the runtime then walks
the value recursively (validation, the 422 body that echoes the input, serialisation, repr, copies). To keep
a small request from overflowing the stack, which would abort the process:
- the runtime threads get a 256 MiB stack (
PY2AXUM_STACK_SIZE). It is reserved address space: memory is only touched as deep as a request actually recurses, and the resident size of an idle server does not change; - every project function and the runtime's recursive walks check the remaining stack and raise
RecursionError("maximum recursion depth exceeded")instead of overflowing, so even with a small stack the outcome is a 500, never an abort. Unbounded recursion in your own code also ends in RecursionError, as in Python (the depth at which it happens differs: CPython counts frames, the binary measures stack).
Difference: between ~1 000 and 10 000 levels, FastAPI often answers 500 (its recursive jsonable_encoder or
Pydantic hits Python's recursion limit while echoing the input or the result); the binary usually answers
normally. Neither process goes down.
Sizes chosen by the request. str * n, bytes * n, list * n, secrets.token_*(n), str.zfill/ljust/
rjust/center, the width and precision of format specs and of %-formatting raise OverflowError, MemoryError
or ValueError ("Too many decimal digits in format string", "width too big", "precision too big") like CPython,
instead of a failed allocation that would abort. range() is a list in the binary (CPython's is lazy): its length
is computed first, and a range too large to hold is a MemoryError even where Python would only have iterated a
few items of it (for i in range(n): ... break with n from the request).
Request bodies. A request body is buffered whole before routing, also for a 404 or a route that takes no body
(uvicorn reads it lazily, when the endpoint asks, and pauses the client past 64 KiB). Behind a reverse proxy, cap
bodies there (ingress-nginx proxy-body-size) or set PY2AXUM_MAX_BODY.
Proxy headers. As uvicorn (--proxy-headers, on by default): X-Forwarded-For and X-Forwarded-Proto are
read only from a peer in FORWARDED_ALLOW_IPS (default 127.0.0.1,::1), the client being the right-most
untrusted hop and the scheme the last X-Forwarded-Proto. * trusts every peer only when it is the whole list;
an entry that is not a valid address or network (10.0.0.1/8, host bits set) is a literal, as uvicorn's strict
parsing makes it.
Response headers. A header value the application builds from request data with CR, LF or NUL in it never reaches the client: the response becomes a plain 500. Header injection (response splitting) is not possible. Difference: uvicorn refuses such a header at send time and closes the connection without an answer.
Panics. The generated crates do not set panic = "abort": a bug that panics loses its own connection only,
and the runtime's locks do not poison.
Slow clients and streams¶
- Streamed responses (
StreamingResponse, lists streamed from the session, SSE) are produced as the client reads: a client that stops reading holds its own request, not the server's memory. - Raw ASGI apps and middlewares:
await send(...)waits once 16 messages are queued ahead of the client, as uvicorn'ssendwaits for the transport to drain. A stream the application leaves unfinished (it returns or raises withoutmore_body=False) closes the connection, as uvicorn does (without uvicorn's "ASGI callable returned without completing response" line). - WebSocket:
websocket.sendwaits once 16 messages are queued ahead of the client (websockets' drain). Incoming frames are read only when the application has taken the previous message (websockets queues up to 32). - MCP (
mcpSDK, stateless): theGETevent stream ends when the client leaves, asEventSourceResponsedoes, so the endpoint's dependencies (a database session) are released. - Raw ASGI middlewares do not run for WebSocket connections. A raw middleware next to WebSocket routes is
therefore accepted only when its
__call__starts by handing non-HTTP scopes straight on (if scope["type"] != "http": return await self.app(scope, receive, send)); one that checks websockets (authentication, IP filter, rate limit) is refused at translation rather than skipped.TrustedHostMiddlewareandHTTPSRedirectMiddlewarewith WebSocket routes are refused for the same reason.
Files and paths¶
FileResponse(path)checks nothing aboutpath, exactly like Starlette: confining it is the application's job. A missing path or a directory raise the RuntimeError Starlette raises (500). The file is read off the event-loop threads.Content-Dispositionfile names are percent-encoded (filename*=), so they cannot inject a header.StaticFilesis not translated: a mount is refused at compile time or left to the Python side (--python-side mount), where Starlette's own checks (realpath,.., symlinks) apply unchanged.Path.resolve()andos.path.realpath()resolve symlinks component by component like CPython's_joinrealpath, including below a final part that does not exist yet. The common upload guard(BASE / name).resolve().relative_to(BASE.resolve())therefore refusesname = "link/new"whenBASE/linkpoints outsideBASE, as in Python.- No temporary files:
UploadFileis in memory,tempfileis not translated.
Errors and logs¶
- An unhandled exception gives Starlette's response: status 500,
text/plain; charset=utf-8, bodyInternal Server Error. No message, traceback, SQL text, constraint name or driver error reaches the client.FastAPI(debug=True)(and any non-literaldebug=) is refused at compile time, so a debug page is never served. - On stderr, an unhandled exception is uvicorn's
ERROR: Exception in ASGI applicationwith the traceback of the project's frames (Logging). Request headers, cookies and bodies are not logged (the access line holds the client address, the method, the path and query string, and the status, as uvicorn's). The SQL text of a failing statement is logged only withPY2AXUM_SQL_DEBUGset (bound values are never logged). Nothing from the environment or the configuration is logged at start-up; a malformedDATABASE_URLstops the binary without printing it. - The Python-side relay logs a failing upstream with the request path only (a query string can carry a token)
and answers the client a bare
502 Bad Gateway. - Sentry (
sentry-sdktranslated natively) follows the Python SDK:send_default_piiis false by default; without it,Authorization,Cookie,Set-Cookie,X-Forwarded-For,X-Real-Ipand API-key headers are[Filtered], cookies and the client IP are not sent, and the SDK's default denylists (passwords, tokens, secrets, session ids…) scrub headers, cookies, form data,extra,userand breadcrumbs. As in Python, bodies followmax_request_body_size, nested request data is scrubbed at the first level only and the query string is sent as is. No local variables,sys.argvor environment are sent.
Cryptography¶
- Constant-time comparisons (the
subtlecrate) inhmac.compare_digest,secrets.compare_digest, itsdangerous signatures, python-jose and PyJWT HMAC verification, Fernet tokens,bcrypt.checkpwand pyotp'sverify. A comparison your code writes with==stays a plain comparison, as in Python. - Randomness comes from the operating system:
secrets.*,uuid.uuid4, Fernet IVs and keys, bcrypt salts, pyotp secrets, Web Push keys and salts and RSA key generation useOsRngorrand'sthread_rng(ChaCha12 seeded and reseeded from the OS). Python'srandommodule maps to the same generator (stronger than CPython's Mersenne Twister, which only makes it unpredictable). - JWT. PyJWT:
decodewithoutalgorithms=(or with an empty list) raises PyJWT's DecodeError; a headeralgoutside the list is InvalidAlgorithmError;alg: noneverifies only if"none"is in the list and the key isNone, and even then the signature check fails as in PyJWT; HMAC keys that look like a PEM, SSH or JWK public key are refused (algorithm confusion);exp/nbf/iat/aud/iss/leeway/requirefollowapi_jwt.py. Asymmetric algorithms are refused at run time with a clear error. python-jose:noneand asymmetric algorithms raise JWKError;algorithms=Noneaccepts any HS* algorithm, as python-jose does. - itsdangerous: same key derivation and salts, newest-to-oldest key rotation, signature checked before the
payload is decoded or decompressed,
max_ageenforced (also for timestamps in the future). - Fernet: version byte, HMAC checked before decryption, padding errors are InvalidToken (no padding oracle).
ttl=is refused rather than ignored. - bcrypt: cost 4–31,
2a/2bprefixes, passwords over 72 bytes raise ValueError (bcrypt 5 behaviour, no silent truncation). Hashing runs off the event loop (pyca/bcrypt releases the GIL), so concurrent logins do not stall other requests. - Key material is never printed: the runtime's key types have no debug representation and their errors carry no key bytes.
Hybrid deployments: the Python-side relay¶
Routes left to Python (--python-side) are relayed by the binary to PY2AXUM_PYTHON_URL.
- No SSRF: the upstream URL is
PY2AXUM_PYTHON_URLfollowed by the request's path and query, taken from the origin-form target; an absolute-form request line (GET http://other/ HTTP/1.1),//hostor@in the path cannot change the host. Redirects are not followed. The relay ignoresHTTP_PROXY/HTTPS_PROXY/ALL_PROXY, so requests with their cookies and tokens never leave through an outbound proxy. - Headers: hop-by-hop headers (
Connection,Keep-Alive,Transfer-Encoding,TE,Trailer,Upgrade,Proxy-*) and every header named inConnectionare dropped in both directions;Content-Lengthis recomputed from the body the binary read, so a request carrying bothContent-LengthandTransfer-Encodingcannot desynchronise the sidecar.Hostis passed as received, as areX-Forwarded-*andForwarded: the translated routes see them unchanged too, and applications that read them (X-Real-Ip, the lastX-Forwarded-Forhop set by the ingress) behave the same on both sides. - Run the sidecar with the trust you intend. uvicorn trusts
X-Forwarded-For/X-Forwarded-Protofrom127.0.0.1by default (--forwarded-allow-ips), and the relay connects from127.0.0.1. Behind an ingress that overwrites these headers (ingress-nginx does) nothing changes. If clients can reach the binary directly, start the sidecar with--forwarded-allow-ips ''(orFORWARDED_ALLOW_IPS=''), otherwise a client chooses therequest.client.hostand scheme the Python routes see. The translated routes always use the TCP peer. - The connection to the sidecar times out after 10 s if it cannot be established; a slow response is waited for, as uvicorn waits for the application.
- WebSocket upgrades are never relayed.
Per-request state¶
ContextVars, request.state, the ASGI scope, the derived contexts of raw ASGI middlewares and
starlette-context's per-request dictionary live in the request's own context: concurrent requests never see
each other's values. Process-wide state is what the application declares at module level, shared exactly as
in a uvicorn worker. Thread-locals used during validation are set and consumed without an await in between.
Known difference: a task created by a request (asyncio.create_task) shares the request's ContextVar map
instead of a copy, so a set() in the child is visible to its parent within the same request. A rare race on
@property values of one module-level object validated by two requests at once can give one of them the other's
value; it never crosses request data from distinct objects.
Rust dependencies¶
Every generated crate ships py2axum/runtime/Cargo.lock and is built with --locked. CI runs cargo audit
on it (tools/cargo_audit.sh, job audit) and fails on any RustSec advisory not listed here:
| Advisory | Crate | Why it is accepted |
|---|---|---|
| RUSTSEC-2023-0071 (Marvin) | rsa 0.9 |
timing side channel on RSA decryption with a private key. The runtime only generates keys (cryptography's rsa.generate_private_key, serialised for DKIM) and verifies PKCS#1 v1.5 signatures with public keys (Google ID tokens); it never decrypts or signs with RSA. No fixed version exists yet. |
unsafe code¶
Every unsafe block of the runtime, and why it is sound:
| Where | What | Invariant |
|---|---|---|
prom.rs MFile |
mmap of prometheus_client's multiprocess files (same format as CPython) |
the header (used) and every offset read from a file another process writes are checked against the mapping size before any access; a corrupted file is an error, never an out-of-bounds read; the pointer is only used under the files mutex (Send, not Sync) |
net.rs getaddrinfo |
libc resolver for socket.getaddrinfo |
host and service go through CString (an interior NUL is ValueError); each result's address is checked non-null and long enough for its family before it is read; the list is freed once |
net.rs strerror_r |
error text of socket errors |
thread-safe variant, buffer and length passed together |
sysmon.rs |
statvfs, sysctlbyname (psutil) |
NUL-checked path, zeroed output structures |
sentry.rs |
gethostname |
fixed 256-byte buffer, a missing terminator handled |
web.rs |
restores the default SIGTERM/SIGINT action and re-raises at exit (exit status like uvicorn) | called once from the main task, outside any signal handler |
The generated application code contains no unsafe.
Settings¶
| Variable | Default | Effect |
|---|---|---|
PY2AXUM_MAX_BODY |
unset (no limit, like uvicorn) | largest request body in bytes; past it, 413 |
PY2AXUM_STACK_SIZE |
268435456 (256 MiB) | stack of the runtime threads (address space reserved, not memory used) |
PY2AXUM_SQL_DEBUG |
unset | logs the SQL text of a failing statement (not its bound values) |
PY2AXUM_SHUTDOWN_TIMEOUT |
see Graceful shutdown | grace period of open connections at shutdown |
Tests¶
fixtures/dynapp/sec.pywith its cases intests/scenarios/dynapp.py(conformance against FastAPI, normal and forced-streaming passes): request values used as labels, subquery names,index_elementsandorder_bystrings, values in every comparison and intext(), with the table checked intact after each; symlinks underresolve(); CR/LF in a response header; the body of a 500 (an exception message with a password, an IntegrityError);token_urlsafe(-1);str * npast memory; Starlette's multipart limits and Windows file names.- The second review's cases in the same scenario:
Decimalbounds, arithmetic,quantize,//and%at the limits of the context (exponents near ±10⁹ and ±10¹⁸),max_digitson a 200 000-digit value,Fractionpast 4 300 digits, widths and precisions ofzfill/ljust/center/format()/%,range()at the 64-bit edge, hostileregexp_matchflags with the table checked intact.tests/sse_check.pychecks that MCPGETstreams the client leaves keep no database connection. tests/security_check.py(against the binary alone, in CI after the conformance): JSON nested up to 50 000 levels on model,dictandAnybodies with the process still serving; 30 000 cookies in one header; 5 000 multipart files; eight concurrent bcrypt hashes at cost 12 while other requests stay under 0.5 s; the relay droppingConnection-named headers;PY2AXUM_MAX_BODYwithContent-Lengthand chunked bodies; million-digitDecimal,Fractionand JSON integers answered within seconds;range(10**15); an unfinished raw ASGI stream closed; a client that does not read a 256 MiB raw ASGI stream without the server's memory following it.tests/test_rejects_dyn.py: a raw ASGI middleware that looks at websockets, next to a WebSocket route.- The existing PyJWT, python-jose, itsdangerous and Fernet cases of the dynapp scenario (wrong algorithms,
none, PEM keys used as HMAC secrets, tampered and expired tokens). - The
auditCI job (cargo audit).