Skip to content

release: v0.7.0 - #266

Merged
ZhuchkaTriplesix merged 62 commits into
mainfrom
dev
Oct 18, 2026
Merged

ZhuchkaTriplesix merged 62 commits into
mainfrom
dev

Conversation

@ZhuchkaTriplesix

Copy link
Copy Markdown
Member

OxyRoute v0.7.0

Release v0.7.0 brings major performance enhancements across the entire hot path, comprehensive security hardening, and reliability bug fixes.

Performance Highlights

Security & Hardening

Bug Fixes

ZhuchkaTriplesix and others added 30 commits September 29, 2026 12:29
…lock

ensure_compiled_snapshot, setup_database, and close_database took the
AppState write lock and then called rebuild_snapshot(), which acquires
the GIL to clone Py<PyAny> handles. When those ran on a tokio worker
thread without the GIL already held, this created an AB-BA lock-order
inversion against handle_rsgi (GIL held, then state.read()/write()):
one thread could hold the Rust lock waiting for the GIL while another
held the GIL waiting for the Rust lock, deadlocking the worker.

Acquire the GIL first in all three call sites, then take the write
lock inside that scope, matching the GIL-then-lock order used
everywhere else in the hot path.

Closes #205
fix: acquire GIL before AppState write lock to prevent deadlock
…g it

Granian's protocol() reads the entire request body into memory in one
shot; the OXYROUTE_MAX_BODY_BYTES check ran only after that read and
after a second copy into PooledBuffer, so a client could force a
multi-GB allocation per request regardless of the configured limit.

Check the declared Content-Length against the limit before awaiting
protocol() and reject with 413 up front. Bodies without a
Content-Length (chunked transfer-encoding) still fall through to the
existing post-read check.

Closes #206
fix: reject oversized body via Content-Length before reading it
The default "ip" rate-limit key strategy honored X-Forwarded-For and
X-Real-IP unconditionally, with no trusted-proxy check. Any client
could bypass its rate limit by sending a fresh value on each request,
or frame another client by spoofing its IP in the header.

The default "ip" strategy now keys on the actual TCP peer address
(scope.client) only, which the client cannot control. A new opt-in
strategy, "trusted-forwarded-ip" (aliased "forwarded-ip"), restores
the X-Forwarded-For / X-Real-IP behavior for deployments that are
actually behind a reverse proxy overwriting those headers.

Also fixes scope.client parsing: Granian's RSGI scope exposes it as a
"host:port" string, but the old code indexed it with get_item(0) as
if it were an ASGI-style (host, port) tuple, silently returning only
the first character of the string. scope_client_host() now handles
both the RSGI string form and the ASGI/test-scope tuple form.

Closes #207
fix(rate-limit): stop trusting X-Forwarded-For / X-Real-IP by default
…d growth + O(n) sweep

Each shard's HashMap could grow without limit under a flood of unique
keys, since the periodic retain() only evicted entries idle longer
than two windows. Once past MAX_SHARD_ENTRIES, every request scanned
the whole shard under its mutex — O(n) per request on both the memory
and CPU axes, a DoS vector especially combined with a client-supplied
key (e.g. the forwarded-IP strategy).

Replace the periodic full-shard sweep with strict per-insert bounded
eviction: when a shard is at MAX_SHARD_ENTRIES and a new key arrives,
evict one existing entry before inserting. This keeps check() O(1)
per request regardless of key cardinality, with no full-shard scan.

Also cap the key string itself at MAX_KEY_LEN bytes, since keys can
come from client-controlled input (e.g. a forwarded-IP header value)
and an oversized key would otherwise inflate a shard's memory
footprint per request.

Closes #208
…hards

fix(rate-limit): bound shard memory with eviction instead of unbounded growth + O(n) sweep
…the middleware stack

set_middleware() replaces the entire single-slot request-middleware
list, but apply_cors() and apply_csrf() both called it internally.
Calling add_middleware(auth) followed by apply_cors(...) silently
deleted the auth middleware; calling apply_csrf(...) followed by
apply_cors(...) silently deleted the CSRF guard. No error, no
warning — a composed security stack just lost a layer.

- Add App.add_middleware(handler, phase="request"|"response"|"both"),
  wiring the previously-unused native add_middleware pymethod.
- apply_cors() and apply_csrf() now use add_middleware() instead of
  set_middleware(), so they compose with anything already registered
  (including with each other) instead of replacing it.
- set_middleware() now warns when it would clobber existing
  middleware registered via add_middleware, for callers who still use
  the single-slot API directly.

Closes #209
…bber

fix(middleware): apply_cors/apply_csrf compose instead of clobbering the middleware stack
GET /test_db was a hardcoded benchmark prototype (issue #55) left in
the hot path. If a DB pool was configured it silently shadowed any
user-registered route at that exact path, and on query error it
returned the raw sqlx error string assembled via format! without
escaping - a backend detail leak and a JSON-injection risk if the
error text contained quotes.

Removed both the sync short-circuit bailout and the async handler.
FrozenState.db_pool was only read by this endpoint - dropped the now-
dead field along with it; dependency-injected DBQuery execution reads
AppState.db_pool directly and is unaffected.

Closes #210
fix(dispatch): remove /test_db prototype endpoint
…/wrong status

map_handler_return() tried extract::<Vec<u8>>() before checking for a
plain dict/list. pyo3 happily extracts any Python sequence of small
ints as Vec<u8> - including an empty list - so `return []` sent an
empty application/octet-stream body instead of JSON `[]`, and
`return [1, 2, 3]` sent raw bytes \x01\x02\x03 instead of JSON
`[1,2,3]`.

Separately, any dict containing both a "status" and "body" key was
unconditionally treated as a structured HTTP response, so ordinary
JSON data shaped that way (e.g. {"status": 1, "body": "x"}) had its
HTTP status silently set to 1 instead of being returned as data.

Fix:
- Check for PyDict/PyList before the String/Vec<u8> extraction
  attempts, so JSON payloads always fall through to JSON
  serialization instead of being coerced to bytes.
- Require "status" to be a plausible HTTP status code (100-599,
  RFC 9110 Sec 15) before treating a dict as a structured response,
  preserving the existing documented {"status": ..., "body": ...}
  shorthand (docs/usage.md, tests/test_exception_handlers.py) for
  real status codes while letting ambiguous data round-trip as JSON.

Closes #211
…r-return

fix(dispatch): stop misreading list/dict handler returns as raw bytes/wrong status
Every path param value, including a `*name` catch-all capturing an
arbitrary sub-path, went through the same int/float/bool schema-lite
coercion as a single-segment `:name` param. This broke StaticFiles
(mounted via app.mount(...) with a `*path` wildcard) on any file whose
name looked numeric or boolean: e.g. GET /static/42 arrived at the
handler as an int, and int has no .split("/"), so the route 500'd.

It also silently mistyped ownership/auth checks comparing a wildcard
path segment against a string ID.

Track which path param names come from a `*name` catch-all at route-
registration time (RouteExtra::wildcard_params) and skip coercion for
just those params at dispatch time. Single-segment `:name` coercion
(fully covered by the existing test_path_param_coercion.py suite) is
unchanged.

Closes #212
…string

fix(routing): wildcard *path params never get schema-lite type coercion
fix(db): decode UUID, timestamp, and numeric types in DBQuery without unwraps
fix(docs): pin CDN asset versions, add SRI integrity hashes and allow self-hosted assets
…idation

fix(websocket): validate Origin header on handshake to prevent CSWSH
fix(auth): match Bearer scheme name case-insensitively
fix(dispatch): build json and multipart error bodies using serde_json::json!
fix(auth): ignore malformed cookie segments instead of aborting search
ZhuchkaTriplesix and others added 29 commits October 18, 2026 14:53
fix(rsgi): forward __call__, async iteration, and websocket methods on _ProtocolWrapper
…-limiter

fix(resilience): fix AdaptiveConcurrencyLimiter self-throttling and units mismatch
docs(db): clarify DBQuery chunk_size batches rows into lists rather than bounded streaming
perf(dispatch): fast-path dict/list handler returns with native JSON serializer
perf(params): optimize header_get_lax with exact lookup and zero-allocation scan
perf(dispatch): zero-copy request body with PyBackedBytes and eliminate empty pool churn (#219)
…s-fast-path

perf(cors): compile CORS and security headers natively and enable sync short-circuit (#218)
perf(response): replace protocol.getattr with call_method1 to avoid PyMethodObject allocations (#251)
perf(rate_limit): eliminate String allocation for existing keys in RateLimiter::check (#252)
…orrow-str

perf(dispatch): borrow &str instead of allocating String in try_rsgi_sync_short_circuit (#253)
perf(dispatch): enable sync short-circuit for sync routes with path parameters (#254)
…ate-json

perf(dispatch): call model_validate_json directly and stream JSON into Python objects (#255)
perf(dispatch): avoid redundant RwLock reads in handle_rsgi (#256)
perf(websocket): streamline frame receive/send without Tokio future bridge (#257)
chore(release): prepare v0.7.0 release
@ZhuchkaTriplesix
ZhuchkaTriplesix merged commit d17ffe0 into main Oct 18, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant