Repository navigation
release: v0.7.0 - #266
Merged
Merged
release: v0.7.0#266
Conversation
…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
… self-hosted assets
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
fix(rsgi): forward __call__, async iteration, and websocket methods on _ProtocolWrapper
…han bounded streaming
…-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
…te empty pool churn
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)
…yMethodObject allocations
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)
…sync_short_circuit
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
receive/send/accepteliminating Tokio task hops and future bridge overhead per frame (perf: WebSocket frame receive/send goes through double future bridge (into_future -> future_into_py) #257).in_flight/max_concurrencycounters and per-requestAppStatesnapshots eliminating read lock contention (perf: handle_rsgi acquires RwLock<AppState> read lock redundantly #256).model_validate_jsonand streaming JSON bytes to dicts without intermediate AST copies (perf: avoid double-AST allocation for JSON bodies and leverage Pydantic model_validate_json #255).getattron RSGI protocol objects (perf: protocol.getattr allocates PyMethodObject on every response in response.rs #251).PyBackedBytesand fast single-pass ASCII header parsing (perf: request body copied twice (PyBytes -> PooledBuffer), pool churns even when body is empty #219, perf: header_get_lax does up to 3 Python attribute/dict lookups per header read #220).Security & Hardening
Bug Fixes
AppStatewrite lock (deadlock: lock-order inversion between handle_rsgi GIL and AppState write lock #205).