diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ea5ec..9b54072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.0] - 2026-10-18 + +### Performance + +- **Zero-Tokio WebSocket Transport**: Direct Python coroutine execution for WebSocket `receive`/`send`/`accept` eliminating Tokio task hops and future bridge overhead per frame ([#257](https://github.com/QueryaHub/OxyRoute/issues/257), [#264](https://github.com/QueryaHub/OxyRoute/pull/264)). +- **Lock-Free Concurrency Checks**: Move `in_flight` and `max_concurrency` atomics directly to `App` and snapshot `AppState` once per request, eliminating reader lock contention ([#256](https://github.com/QueryaHub/OxyRoute/issues/256), [#263](https://github.com/QueryaHub/OxyRoute/pull/263)). +- **Direct Pydantic Validation & Zero-AST JSON**: Direct `model_validate_json` execution and direct streaming of JSON bytes into Python dicts bypassing intermediate AST copies ([#255](https://github.com/QueryaHub/OxyRoute/issues/255), [#262](https://github.com/QueryaHub/OxyRoute/pull/262)). +- **Sync Short-Circuit for Path Parameters**: Extended zero-Tokio sync short-circuit path to handle sync routes with path parameters ([#254](https://github.com/QueryaHub/OxyRoute/issues/254), [#261](https://github.com/QueryaHub/OxyRoute/pull/261)). +- **Zero-Allocation Routing Attributes**: Extract string slices `&str` instead of heap-allocated `String` during preliminary dispatch ([#253](https://github.com/QueryaHub/OxyRoute/issues/253), [#260](https://github.com/QueryaHub/OxyRoute/pull/260)). +- **Zero-Allocation Rate Limiting**: Zero-alloc key lookup in `RateLimiter::check` reusing byte buffers ([#252](https://github.com/QueryaHub/OxyRoute/issues/252), [#259](https://github.com/QueryaHub/OxyRoute/pull/259)). +- **Cached Protocol Calls**: Replaced runtime `getattr` calls on protocol objects with direct method calls ([#251](https://github.com/QueryaHub/OxyRoute/issues/251), [#258](https://github.com/QueryaHub/OxyRoute/pull/258)). +- **Native Compiled CORS & Security Headers**: Compiled CORS and security header policies executed directly in Rust without Python interop on short-circuited paths ([#218](https://github.com/QueryaHub/OxyRoute/issues/218), [#250](https://github.com/QueryaHub/OxyRoute/pull/250)). +- **Zero-Copy Request Body**: Zero-copy request body handling via `PyBackedBytes` avoiding double memory copying ([#219](https://github.com/QueryaHub/OxyRoute/issues/219), [#249](https://github.com/QueryaHub/OxyRoute/pull/249)). +- **Optimized Header Scanning**: Single-pass case-insensitive ASCII scan in `header_get_lax` with short-circuit lookup ([#220](https://github.com/QueryaHub/OxyRoute/issues/220), [#248](https://github.com/QueryaHub/OxyRoute/pull/248)). +- **Native Fast JSON Serialization**: Fast-path native JSON serializer for dict and list responses ([#217](https://github.com/QueryaHub/OxyRoute/issues/217), [#247](https://github.com/QueryaHub/OxyRoute/pull/247)). + +### Security & Hardening + +- **CSWSH Protection**: Validate `Origin` header during WebSocket handshake with `allowed_origins` parameter ([#225](https://github.com/QueryaHub/OxyRoute/issues/225), [#239](https://github.com/QueryaHub/OxyRoute/pull/239)). +- **Content-Length Guard**: Reject oversized request bodies using `Content-Length` before allocating buffer memory ([#206](https://github.com/QueryaHub/OxyRoute/issues/206), [#228](https://github.com/QueryaHub/OxyRoute/pull/228)). +- **Rate Limiting Security**: Disallow trusting `X-Forwarded-For` / `X-Real-IP` by default unless explicitly configured ([#207](https://github.com/QueryaHub/OxyRoute/issues/207), [#229](https://github.com/QueryaHub/OxyRoute/pull/229)). +- **Bounded Rate Limiter Memory**: Bound shard memory with LRU-style eviction preventing memory exhaustion from arbitrary IP/key floods ([#208](https://github.com/QueryaHub/OxyRoute/issues/208), [#230](https://github.com/QueryaHub/OxyRoute/pull/230)). +- **SSE Field Sanitization**: Prevent CRLF injection by rejecting newlines in SSE `event` and `id` fields ([#221](https://github.com/QueryaHub/OxyRoute/issues/221), [#243](https://github.com/QueryaHub/OxyRoute/pull/243)). +- **JSON & Multipart Error Safety**: Build error bodies safely with `serde_json::json!` avoiding format string injection ([#222](https://github.com/QueryaHub/OxyRoute/issues/222), [#242](https://github.com/QueryaHub/OxyRoute/pull/242)). +- **Case-Insensitive Bearer Auth**: Match `Bearer` scheme case-insensitively per RFC 6750 ([#224](https://github.com/QueryaHub/OxyRoute/issues/224), [#240](https://github.com/QueryaHub/OxyRoute/pull/240)). +- **Robust Cookie Parsing**: Ignore malformed cookie segments instead of aborting search ([#223](https://github.com/QueryaHub/OxyRoute/issues/223), [#241](https://github.com/QueryaHub/OxyRoute/pull/241)). +- **Docs UI Security**: Pin CDN asset versions and add SRI integrity hashes for Scalar / Swagger UI ([#226](https://github.com/QueryaHub/OxyRoute/issues/226), [#238](https://github.com/QueryaHub/OxyRoute/pull/238)). + +### Bug Fixes + +- **Deadlock Prevention**: Acquire Python GIL before taking `AppState` write lock to eliminate AB-BA deadlocks with background worker threads ([#205](https://github.com/QueryaHub/OxyRoute/issues/205), [#227](https://github.com/QueryaHub/OxyRoute/pull/227)). +- **Middleware Stacking**: `apply_cors()` and `apply_csrf()` now compose properly onto existing middleware chains rather than overwriting them ([#209](https://github.com/QueryaHub/OxyRoute/issues/209), [#231](https://github.com/QueryaHub/OxyRoute/pull/231)). +- **Wildcard Path Parameters**: Wildcard `*path` parameters preserve string type and bypass numeric/float schema-lite coercion ([#212](https://github.com/QueryaHub/OxyRoute/issues/212), [#234](https://github.com/QueryaHub/OxyRoute/pull/234)). +- **Handler Return Types**: Correctly handle dict/list returns and prevent status code misinterpretation ([#211](https://github.com/QueryaHub/OxyRoute/issues/211), [#233](https://github.com/QueryaHub/OxyRoute/pull/233)). +- **Database Query Decoding**: Robust decoding for Postgres UUID, Timestamp, and Decimal types without panicking ([#213](https://github.com/QueryaHub/OxyRoute/issues/213), [#237](https://github.com/QueryaHub/OxyRoute/pull/237)). +- **Protocol Wrapper Proxy**: Forward all RSGI protocol methods (`__call__`, async iteration, WebSockets) through `_ProtocolWrapper` ([#216](https://github.com/QueryaHub/OxyRoute/issues/216), [#244](https://github.com/QueryaHub/OxyRoute/pull/244)). +- **Adaptive Concurrency Limiter**: Fix unit mismatches and prevent self-throttling under normal operation ([#215](https://github.com/QueryaHub/OxyRoute/issues/215), [#245](https://github.com/QueryaHub/OxyRoute/pull/245)). +- **Cleanup**: Remove deprecated `/test_db` debug prototype endpoint ([#210](https://github.com/QueryaHub/OxyRoute/issues/210), [#232](https://github.com/QueryaHub/OxyRoute/pull/232)). + ## [0.6.0] - 2026-09-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index cf2efa4..4c9da1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -29,6 +38,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atoi" version = "2.0.0" @@ -77,6 +92,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -117,6 +156,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.0" @@ -125,7 +170,18 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", ] [[package]] @@ -195,6 +251,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -370,7 +432,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -523,7 +585,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -578,6 +640,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -586,8 +660,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -688,6 +762,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1012,10 +1110,11 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "oxyroute" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64", "bytes", + "chrono", "criterion", "form_urlencoded", "futures-util", @@ -1027,11 +1126,13 @@ dependencies = [ "parking_lot", "pyo3", "pyo3-async-runtimes", + "rust_decimal", "serde", "serde_json", "sqlx", "thiserror 1.0.69", "tokio", + "uuid", ] [[package]] @@ -1140,6 +1241,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1208,7 +1327,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1221,7 +1340,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1233,12 +1352,39 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1247,7 +1393,45 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1328,6 +1512,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1410,7 +1611,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1533,6 +1734,7 @@ dependencies = [ "base64", "bytes", "cfg-if", + "chrono", "crc", "crossbeam-queue", "either", @@ -1547,6 +1749,7 @@ dependencies = [ "log", "memchr", "percent-encoding", + "rust_decimal", "rustls", "serde", "serde_json", @@ -1557,6 +1760,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", "webpki-roots", ] @@ -1570,7 +1774,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -1593,7 +1797,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "thiserror 2.0.18", "tokio", "url", @@ -1608,6 +1812,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest 0.11.3", "dotenvy", @@ -1617,12 +1822,14 @@ dependencies = [ "generic-array", "log", "percent-encoding", + "rust_decimal", "serde", "sha1", "sha2 0.11.0", "sqlx-core", "thiserror 2.0.18", "tracing", + "uuid", ] [[package]] @@ -1635,6 +1842,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1648,7 +1856,8 @@ dependencies = [ "log", "md-5", "memchr", - "rand", + "rand 0.10.1", + "rust_decimal", "serde", "serde_json", "sha2 0.11.0", @@ -1657,6 +1866,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", + "uuid", "whoami", ] @@ -1667,6 +1877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", + "chrono", "flume", "form_urlencoded", "futures-channel", @@ -1682,6 +1893,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", + "uuid", ] [[package]] @@ -1718,6 +1930,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1726,7 +1949,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1761,7 +1984,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1772,7 +1995,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1866,6 +2089,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1886,7 +2139,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1961,6 +2214,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1989,6 +2252,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -2021,7 +2293,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2068,12 +2340,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2156,6 +2481,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -2181,7 +2521,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2202,7 +2542,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2222,7 +2562,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2262,7 +2602,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8229a4a..a8d6506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxyroute" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "High-performance RSGI web framework: Rust hot path, Python handlers, OpenAPI 3.1, rate limiting, and WebSockets" license = "MIT" @@ -31,7 +31,10 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } multer = "3.1" log = "0.4" parking_lot = "0.12" -sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls"] } +sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "sqlite", "tls-rustls", "uuid", "chrono", "rust_decimal"] } +uuid = { version = "1", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +rust_decimal = { version = "1", default-features = false } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/benches/hot_path.rs b/benches/hot_path.rs index 4bee1a6..c371843 100644 --- a/benches/hot_path.rs +++ b/benches/hot_path.rs @@ -13,11 +13,12 @@ use std::hint::black_box; use criterion::{criterion_group, criterion_main, Criterion}; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyString}; +use pyo3::types::{PyBytes, PyDict, PyList, PyString}; use serde_json::json; use _oxyroute::microbench::{ - json_to_py, map_handler_return_status, match_route_compiled, sample_compiled_routers, + header_get_lax, json_to_py, map_handler_return_status, match_route_compiled, py_to_json_bytes, + sample_compiled_routers, }; fn bench_match_route(c: &mut Criterion) { @@ -42,6 +43,10 @@ fn bench_map_handler_return(c: &mut Criterion) { Python::with_gil(|py| { let s = PyString::new(py, "hello world"); let buf = PyBytes::new(py, b"hello world"); + let d = PyDict::new(py); + d.set_item("message", "hello world").unwrap(); + d.set_item("count", 42).unwrap(); + let l = PyList::new(py, ["a", "b", "c"]).unwrap(); let mut group = c.benchmark_group("map_handler_return"); group.bench_function("str", |b| { b.iter(|| { @@ -55,6 +60,18 @@ fn bench_map_handler_return(c: &mut Criterion) { black_box(status) }) }); + group.bench_function("dict", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(d.as_any())).unwrap(); + black_box(status) + }) + }); + group.bench_function("list", |b| { + b.iter(|| { + let status = map_handler_return_status(py, black_box(l.as_any())).unwrap(); + black_box(status) + }) + }); group.finish(); }); } @@ -118,11 +135,65 @@ fn bench_json_scale(c: &mut Criterion) { }); } +fn bench_py_to_json(c: &mut Criterion) { + Python::with_gil(|py| { + let small = json!({"a": 1, "b": "x", "c": true}); + let py_small = json_to_py(py, &small).unwrap(); + let nested = json!({ + "items": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + "meta": {"ok": true, "n": 2} + }); + let py_nested = json_to_py(py, &nested).unwrap(); + let mut group = c.benchmark_group("py_to_json"); + group.bench_function("small_object", |b| { + b.iter(|| { + let bytes = py_to_json_bytes(py, black_box(py_small.bind(py))).unwrap(); + black_box(bytes) + }) + }); + group.bench_function("nested", |b| { + b.iter(|| { + let bytes = py_to_json_bytes(py, black_box(py_nested.bind(py))).unwrap(); + black_box(bytes) + }) + }); + group.finish(); + }); +} + +fn bench_header_get_lax(c: &mut Criterion) { + Python::with_gil(|py| { + let d = PyDict::new(py); + d.set_item("authorization", "Bearer token123").unwrap(); + d.set_item("content-type", "application/json").unwrap(); + d.set_item("host", "example.com").unwrap(); + d.set_item("user-agent", "bench/1.0").unwrap(); + let bound = d.as_any(); + + let mut group = c.benchmark_group("header_get_lax"); + group.bench_function("hit_exact", |b| { + b.iter(|| { + let val = header_get_lax(black_box(bound), black_box("authorization")); + black_box(val) + }) + }); + group.bench_function("miss", |b| { + b.iter(|| { + let val = header_get_lax(black_box(bound), black_box("cookie")); + black_box(val) + }) + }); + group.finish(); + }); +} + criterion_group!( benches, bench_match_route, bench_map_handler_return, bench_json_to_py, - bench_json_scale + bench_py_to_json, + bench_json_scale, + bench_header_get_lax ); criterion_main!(benches); diff --git a/docs/database.md b/docs/database.md index 07bff6d..2a41db3 100644 --- a/docs/database.md +++ b/docs/database.md @@ -60,6 +60,24 @@ The native Rust executor automatically maps and type-checks the following Python | `str` | `TEXT`, `VARCHAR`, `CHAR` | | `dict` / `list` | `JSON`, `JSONB` | +### Supported Result Decoding Types + +Rows returned from `DBQuery` are decoded into Python `dict`s according to their Postgres column type: + +| Postgres Type | Decoded Python Type / Representation | +|---|---| +| `NULL` | `None` | +| `BOOL` | `bool` | +| `INT2`, `INT4`, `INT8` | `int` | +| `FLOAT4`, `FLOAT8` | `float` | +| `NUMERIC`, `DECIMAL` | `str` (exact decimal representation) | +| `TEXT`, `VARCHAR`, `CHAR`, `NAME`, `CITEXT` | `str` | +| `JSON`, `JSONB` | `dict` / `list` / primitive | +| `UUID` | `str` (canonical UUID format) | +| `DATE`, `TIME`, `TIMETZ` | `str` (ISO-8601 date/time) | +| `TIMESTAMP`, `TIMESTAMPTZ` | `str` (ISO-8601 timestamp) | +| `BYTEA` | `bytes` | + --- ## Database Connection Pool Lifecycle @@ -81,13 +99,16 @@ async def shutdown(): --- -## Streaming & Chunked Row Decoding +## Batched & Chunked Row Decoding For large result sets, `DBQuery` streams row decoding directly from the PostgreSQL socket without accumulating unmanaged `PgRow` structs in Rust memory (issue #143). -You can also pass `chunk_size` to group rows into batches: +You can also pass `chunk_size` to group rows into batches (a list of row chunks `list[list[dict]]`): ```python -# Stream rows in chunks of 500 +# Group rows in batches of 500: [[row1, ...], [row501, ...]] query = DBQuery("SELECT * FROM large_log_table WHERE timestamp > $1", (since,), chunk_size=500) ``` + +> [!NOTE] +> `chunk_size` groups rows into sub-lists for bulk processing in handlers. The complete list of chunks is materialized in memory when the query resolves. diff --git a/docs/openapi.md b/docs/openapi.md index ba8dad4..7e6ac4b 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -37,7 +37,22 @@ app.mount_docs("/api/docs", ui="swagger") | `mount_docs(path, ui=...)` | Mount at a custom path | | Spec URL | Built-in `/openapi.json` | -UI scripts load from **jsDelivr**. If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. +UI scripts load from **jsDelivr** with pinned release versions and Subresource Integrity (SRI) hashes (`integrity="..."`, `crossorigin="anonymous"`) to protect against CDN or upstream supply-chain tampering: +- **Scalar**: pinned to `@scalar/api-reference@1.75.0` +- **Swagger UI**: pinned to `swagger-ui-dist@5.33.3` + +If you use `SecurityHeadersConfig` (or a strict CSP), allow `cdn.jsdelivr.net` in `script-src` / `style-src` for the docs route, or disable those headers on `/docs`. + +To self-host documentation assets without third-party CDN dependencies, pass custom URLs and optional SRI hashes to `app.mount_docs`: + +```python +app.mount_docs( + "/docs", + ui="swagger", + swagger_css_url="/static/swagger-ui.css", + swagger_js_url="/static/swagger-ui-bundle.js", +) +``` ## Title, version, info, and servers diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index b7b6267..d2eaaf8 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -40,13 +40,25 @@ The `rate_limit` string accepts `/`: By default, rate limiting is tracked per client IP (`rate_limit_key="ip"`). You can customize the key: ### 1. Client IP (default) -Tracks client IP using the `client` tuple or `X-Forwarded-For` / `X-Real-IP` headers when behind reverse proxies: +Tracks the **actual TCP peer address** (`scope.client`). This cannot be spoofed by the client, +and is the safe default: ```python @app.get("/login", rate_limit="5/minute", rate_limit_key="ip") def login() -> dict[str, str]: return {"status": "ok"} ``` +### 1b. Forwarded IP (opt-in, only behind a trusted reverse proxy) +`rate_limit_key="trusted-forwarded-ip"` honors `X-Forwarded-For` / `X-Real-IP` (falling back to +the peer address if neither is present). **Only use this when every request actually passes +through a reverse proxy that overwrites these headers** — otherwise any client can set its own +`X-Forwarded-For` value to dodge the limit, or spoof another client's IP to get it blocked: +```python +@app.get("/login", rate_limit="5/minute", rate_limit_key="trusted-forwarded-ip") +def login() -> dict[str, str]: + return {"status": "ok"} +``` + ### 2. Header-Based (e.g. API Keys or User IDs) Rate limit by custom HTTP header (e.g. `header:X-API-Key` or `header:Authorization`): ```python diff --git a/docs/usage.md b/docs/usage.md index a7d92f6..d465d1b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ [← Documentation index](index.md) This guide is the recommended end-to-end reference for using OxyRoute as an -application framework. It describes the current **v0.6.0** behavior: OxyRoute is +application framework. It describes the current **v0.7.0** behavior: OxyRoute is **RSGI-only** and is intended to run behind **Granian** with `--interface rsgi`. The removed ASGI bridge is not part of the supported runtime path. @@ -565,7 +565,7 @@ Production checklist: - Keep `OXYROUTE_DEBUG` unset in production. - Use external storage for cross-worker state. -## Known limitations in v0.6.0 +## Known limitations in v0.7.0 - Request bodies and multipart files are buffered in memory before parsing. - WebSocket subprotocol negotiation is not exposed as a high-level API. diff --git a/docs/websocket.md b/docs/websocket.md index c4a8599..87aa002 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -41,6 +41,7 @@ granian app:app --interface rsgi --host 127.0.0.1 --port 8000 |---|---|---| | `scope` | property | The Granian RSGI scope (`proto == "websocket"`). | | `path_params` | property | `dict[str, str]` of path parameters extracted by the router. | +| `origin` | property | The client's HTTP `Origin` header (`str` or `None`) sent during the handshake. | | `is_closed` | property | `True` once `close()` has run or the peer disconnected. | | `await ws.accept()` | coroutine | Performs the handshake; required before send/receive. | | `await ws.receive()` | coroutine | Returns the next frame as `str` **or** `bytes`. Raises `RuntimeError` if the peer closed. | @@ -55,6 +56,33 @@ Sync handlers are accepted by `@app.websocket(path)` for symmetry but should gen async — Granian dispatches WebSockets on its event loop and most useful patterns require `await`. +## Origin validation & CSWSH protection + +Browsers do not enforce Same-Origin Policy (SOP) or CORS for WebSocket connections. When using cookie-based or ambient authentication, malicious third-party websites can establish cross-site WebSocket connections and ride the victim's session (Cross-Site WebSocket Hijacking / CSWSH). + +OxyRoute provides native Origin validation via `allowed_origins`: + +```python +@app.websocket("/ws/chat", allowed_origins=["https://app.example.com"]) +async def chat(ws: WebSocket) -> None: + await ws.accept() + ... +``` + +* If `allowed_origins` is set, OxyRoute checks the client's `Origin` header before dispatching to the handler. +* If the `Origin` header is missing or disallowed, the connection is closed immediately with close code **`1008` (Policy Violation)**. +* Use `allowed_origins=["*"]` to explicitly permit any origin. +* You can also inspect `ws.origin` in Python for programmatic origin checks: + +```python +@app.websocket("/ws/custom") +async def custom(ws: WebSocket) -> None: + if ws.origin not in trusted_set: + await ws.close(1008) + return + await ws.accept() +``` + ## Routing semantics * WebSocket routes live in their own `matchit::Router`. They never collide with HTTP routes, diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 24a7661..0f84187 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -40,4 +40,4 @@ "stream_jsonl", "stream_text", ] -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/oxyroute/_oxyroute.pyi b/oxyroute/_oxyroute.pyi index c4540a6..c9b4006 100644 --- a/oxyroute/_oxyroute.pyi +++ b/oxyroute/_oxyroute.pyi @@ -32,7 +32,12 @@ class App: rate_limit: str | None = None, rate_limit_key: str | None = None, ) -> None: ... - def add_websocket_route(self, path: str, handler: Any) -> None: ... + def add_websocket_route( + self, + path: str, + handler: Any, + allowed_origins: list[str] | None = None, + ) -> None: ... def freeze(self) -> None: ... def set_openapi_served(self, enabled: bool) -> None: ... def set_openapi_title(self, title: str) -> None: ... @@ -68,6 +73,8 @@ class WebSocket: def path_params(self) -> dict[str, str]: ... @property def is_closed(self) -> bool: ... + @property + def origin(self) -> str | None: ... async def accept(self) -> None: ... async def receive(self) -> str | bytes: ... async def receive_text(self) -> str: ... diff --git a/oxyroute/_ws.py b/oxyroute/_ws.py new file mode 100644 index 0000000..91246c0 --- /dev/null +++ b/oxyroute/_ws.py @@ -0,0 +1,69 @@ +"""Internal asyncio coroutine helpers for native RSGI WebSocket transport. + +These coroutines run directly on Granian's Python asyncio event loop without +crossing the Tokio future bridge, avoiding task scheduling and context switching +overhead on every WebSocket frame (issue #257). +""" + +from __future__ import annotations + +import json +from typing import Any + + +async def ws_accept(proto: Any, ws: Any) -> None: + transport = await proto.accept() + ws._set_transport(transport) + + +async def ws_receive(transport: Any, ws: Any) -> Any: + msg = await transport.receive() + if msg.kind == 0: + ws._mark_closed() + raise RuntimeError("WebSocket closed by peer") + return msg.data + + +async def ws_receive_text(transport: Any, ws: Any) -> str: + msg = await transport.receive() + if msg.kind == 0: + ws._mark_closed() + raise RuntimeError("WebSocket closed by peer") + if msg.kind != 2: + raise ValueError("expected text frame") + if not isinstance(msg.data, str): + raise ValueError("expected text frame") + return msg.data + + +async def ws_receive_bytes(transport: Any, ws: Any) -> bytes: + msg = await transport.receive() + if msg.kind == 0: + ws._mark_closed() + raise RuntimeError("WebSocket closed by peer") + if msg.kind != 1: + raise ValueError("expected binary frame") + data = msg.data + if not isinstance(data, (bytes, bytearray, memoryview)): + raise ValueError("expected binary frame") + return bytes(data) if not isinstance(data, bytes) else data + + +async def ws_send_text(transport: Any, lock: Any, data: str) -> None: + async with lock: + await transport.send_str(data) + + +async def ws_send_bytes(transport: Any, lock: Any, data: bytes) -> None: + async with lock: + await transport.send_bytes(data) + + +async def ws_send_json(transport: Any, lock: Any, data: Any) -> None: + dumped = json.dumps(data) + async with lock: + await transport.send_str(dumped) + + +async def ws_close_noop() -> None: + return None diff --git a/oxyroute/app.py b/oxyroute/app.py index 5894f84..2e48ced 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -2,7 +2,7 @@ import inspect import json -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from types import SimpleNamespace from typing import Any, TypeVar @@ -48,10 +48,40 @@ def response_file(self, status: int, headers: list[tuple[str, str]], file_path: self.status = status self._inner.response_file(status, headers, file_path) + def response_file_range( + self, + status: int, + headers: list[tuple[str, str]], + file_path: str, + offset: int, + count: int, + ) -> None: + self.status = status + self._inner.response_file_range(status, headers, file_path, offset, count) + def response_stream(self, status: int, headers: list[tuple[str, str]]) -> Any: self.status = status return self._inner.response_stream(status, headers) + def accept(self, *args: Any, **kwargs: Any) -> Any: + self.status = 101 + return self._inner.accept(*args, **kwargs) + + def close(self, *args: Any, **kwargs: Any) -> Any: + return self._inner.close(*args, **kwargs) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self._inner(*args, **kwargs) + + def __aiter__(self) -> Any: + return self._inner.__aiter__() + + def __anext__(self) -> Any: + return self._inner.__anext__() + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + def _norm_dependencies( deps: list[tuple[str, Dep]] | None, @@ -190,6 +220,9 @@ def __init__( self.state: SimpleNamespace = SimpleNamespace() self._docs_ui: str | None = normalize_docs_ui(docs_ui) self._docs_mounted: bool = False + # Tracks request-middleware count so `set_middleware` can warn before it silently + # clobbers anything registered via `add_middleware` (issue #209). + self._request_mw_count: int = 0 if ( openapi_description is not None or openapi_contact is not None @@ -254,18 +287,45 @@ def mount_docs( *, ui: str = "scalar", openapi_url: str = "/openapi.json", + scalar_js_url: str | None = None, + scalar_js_sri: str | None = None, + swagger_css_url: str | None = None, + swagger_css_sri: str | None = None, + swagger_js_url: str | None = None, + swagger_js_sri: str | None = None, ) -> None: """ Register ``GET path`` serving Scalar or Swagger UI against ``openapi_url``. - UI assets load from a public CDN; set CSP ``script-src`` / ``style-src`` accordingly - (or disable security-header presets that block CDN scripts on the docs route). + UI assets load from a public CDN with pinned versions and Subresource Integrity (SRI); + set CSP ``script-src`` / ``style-src`` accordingly (or disable security-header presets + that block CDN scripts on the docs route). Custom or self-hosted asset URLs and SRIs + can be specified via ``scalar_js_url``, ``swagger_css_url``, ``swagger_js_url``, etc. """ ui_n = normalize_docs_ui(ui) if ui_n is None: raise ValueError("ui is required") path = path.rstrip("/") or "/docs" - html = docs_html(ui=ui_n, title=self.title, openapi_url=openapi_url) + + docs_kwargs: dict[str, str | None] = { + "ui": ui_n, + "title": self.title, + "openapi_url": openapi_url, + } + if scalar_js_url is not None: + docs_kwargs["scalar_js_url"] = scalar_js_url + if scalar_js_sri is not None: + docs_kwargs["scalar_js_sri"] = scalar_js_sri + if swagger_css_url is not None: + docs_kwargs["swagger_css_url"] = swagger_css_url + if swagger_css_sri is not None: + docs_kwargs["swagger_css_sri"] = swagger_css_sri + if swagger_js_url is not None: + docs_kwargs["swagger_js_url"] = swagger_js_url + if swagger_js_sri is not None: + docs_kwargs["swagger_js_sri"] = swagger_js_sri + + html = docs_html(**docs_kwargs) # type: ignore[arg-type] headers = {"content-type": "text/html; charset=utf-8"} def _docs() -> Response: @@ -331,8 +391,37 @@ def set_middleware(self, handler: Callable[..., Any] | None) -> None: (e.g. :class:`oxyroute.Response` or a ``dict`` with ``status`` / ``body`` / ``headers``); the response is sent and routing / body read is skipped. Runs **before** the request body is read (e.g. for CORS preflight). + + **Replaces** the entire request-middleware stack, including anything registered via + :meth:`add_middleware` (or :func:`oxyroute.cors.apply_cors` / :func:`oxyroute.csrf.apply_csrf`, + which use :meth:`add_middleware` internally). Prefer :meth:`add_middleware` to compose + multiple middlewares instead of replacing what is already registered (issue #209). """ + if handler is not None and self._request_mw_count > 0: + import warnings + + warnings.warn( + f"set_middleware() is replacing {self._request_mw_count} already-registered " + "request middleware(s) (e.g. from add_middleware/apply_cors/apply_csrf). " + "Use add_middleware() instead to compose middlewares without clobbering them.", + stacklevel=2, + ) self._app.set_middleware(handler) + self._request_mw_count = 1 if handler is not None else 0 + + def add_middleware(self, handler: Callable[..., Any], phase: str = "request") -> None: + """ + Append ``handler`` to the middleware stack instead of replacing it. + + ``phase`` is one of ``"request"`` (runs before routing, same contract as + :meth:`set_middleware`), ``"response"`` (runs on the outgoing response, before + CORS / security-header merging), or ``"both"``. Request middlewares run in + registration order; the first one to return a non-``None`` value short-circuits + the rest and is sent as the response. + """ + if phase in ("request", "both"): + self._request_mw_count += 1 + self._app.add_middleware(handler, phase) def set_cors(self, config: Any | None) -> None: """ @@ -576,7 +665,12 @@ def delete( rate_limit_key=rate_limit_key, ) - def websocket(self, path: str) -> Callable[[F], F]: + def websocket( + self, + path: str, + *, + allowed_origins: Sequence[str] | None = None, + ) -> Callable[[F], F]: """ Register a native RSGI WebSocket handler. @@ -584,12 +678,20 @@ def websocket(self, path: str) -> Callable[[F], F]: The handler receives a single :class:`oxyroute.WebSocket` argument; ``await`` :meth:`oxyroute.WebSocket.accept` once before sending or receiving frames. + Parameters: + - ``allowed_origins``: Optional list or tuple of trusted Origin header values + (e.g. ``["https://app.example.com"]`` or ``["*"]``) to protect against + Cross-Site WebSocket Hijacking (CSWSH). If specified and the handshake's + ``Origin`` header is missing or disallowed, the connection is closed immediately + with code 1008 (Policy Violation) before the handler runs or handshake completes. + Sync handlers run inline (the Rust dispatcher does not bridge them through Tokio twice); async handlers are awaited on Granian's loop. """ + origins_list = list(allowed_origins) if allowed_origins is not None else None def wrap(handler: F) -> F: - self._app.add_websocket_route(path, handler) + self._app.add_websocket_route(path, handler, allowed_origins=origins_list) return handler return wrap diff --git a/oxyroute/cors.py b/oxyroute/cors.py index c119cea..871538e 100644 --- a/oxyroute/cors.py +++ b/oxyroute/cors.py @@ -136,21 +136,23 @@ def apply_cors( ) -> None: """ Register CORS: stores ``config`` for native response merging and installs preflight - handling via :meth:`oxyroute.app.App.set_middleware`. If you already use middleware for + handling via :meth:`oxyroute.app.App.add_middleware`. If you already use middleware for other work, pass it as ``chain`` so it runs when the request is not a CORS preflight (your handler runs after the CORS layer returns ``None`` for continuation). - **Order:** this replaces ``set_middleware`` with an internal function. To combine with - another pre-route callback, use ``apply_cors(..., chain=your_middleware)``. + **Order:** this *appends* a request middleware rather than replacing the stack, so it + composes with anything already registered via :meth:`oxyroute.app.App.add_middleware` + (including another :func:`apply_cors` / :func:`oxyroute.csrf.apply_csrf` call) instead of + silently deleting it (issue #209). To combine with another pre-route callback that must run + strictly after the CORS preflight check, use ``apply_cors(..., chain=your_middleware)``. """ app.set_cors(config) + if chain is not None: - def _cors_middleware(scope: Any, protocol: Any) -> Response | None: - p = config.preflight_response(scope) - if p is not None: - return p - if chain is not None: + def _cors_middleware(scope: Any, protocol: Any) -> Response | None: + p = config.preflight_response(scope) + if p is not None: + return p return chain(scope, protocol) - return None - app.set_middleware(_cors_middleware) + app.add_middleware(_cors_middleware, phase="request") diff --git a/oxyroute/csrf.py b/oxyroute/csrf.py index 0d72d52..0e619d7 100644 --- a/oxyroute/csrf.py +++ b/oxyroute/csrf.py @@ -128,10 +128,11 @@ def apply_csrf( chain: _Middleware | None = None, ) -> None: """ - Installs **one** :meth:`oxyroute.app.App.set_middleware` that runs :meth:`CSRFConfig.guard` - first, then ``chain`` (if any), then continues routing. Replaces any previous - pre-route callback — combine manually or use :func:`csrf_layer` inside - :func:`oxyroute.cors.apply_cors`. + Appends (via :meth:`oxyroute.app.App.add_middleware`) a request middleware that runs + :meth:`CSRFConfig.guard` first, then ``chain`` (if any), then continues routing. Composes + with anything already registered via :meth:`oxyroute.app.App.add_middleware` instead of + replacing it (issue #209) — combine manually, or use :func:`csrf_layer` inside + :func:`oxyroute.cors.apply_cors` for strict ordering relative to the CORS preflight check. """ def _mw(scope: Any, protocol: Any) -> Response | None: @@ -142,4 +143,4 @@ def _mw(scope: Any, protocol: Any) -> Response | None: return chain(scope, protocol) return None - app.set_middleware(_mw) + app.add_middleware(_mw, phase="request") diff --git a/oxyroute/docs_ui.py b/oxyroute/docs_ui.py index 949344d..e94789e 100644 --- a/oxyroute/docs_ui.py +++ b/oxyroute/docs_ui.py @@ -1,13 +1,39 @@ -"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN.""" +"""Built-in OpenAPI docs UIs (Scalar / Swagger UI) loaded from CDN with pinned versions and SRI.""" from __future__ import annotations from html import escape -__all__ = ["docs_html", "normalize_docs_ui"] +__all__ = [ + "SCALAR_JS_SRI", + "SCALAR_JS_URL", + "SCALAR_VERSION", + "SWAGGER_CSS_SRI", + "SWAGGER_CSS_URL", + "SWAGGER_JS_SRI", + "SWAGGER_JS_URL", + "SWAGGER_UI_VERSION", + "docs_html", + "normalize_docs_ui", +] _VALID = frozenset({"scalar", "swagger"}) +# Pinned versions and Subresource Integrity (SRI) hashes for public CDN assets +SCALAR_VERSION = "1.75.0" +SCALAR_JS_URL = f"https://cdn.jsdelivr.net/npm/@scalar/api-reference@{SCALAR_VERSION}/dist/browser/standalone.js" +SCALAR_JS_SRI = "sha384-xgelKlDcc+BYp0O+ZYzFesLnWCgGpEbXVVZWNQT6WyZYygN01tbEMygnjPXb5OPI" + +SWAGGER_UI_VERSION = "5.33.3" +SWAGGER_CSS_URL = ( + f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css" +) +SWAGGER_CSS_SRI = "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW" +SWAGGER_JS_URL = ( + f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js" +) +SWAGGER_JS_SRI = "sha384-h00llMzD1afyNow1pDmnZKlHsUZgp9KE9HXLFXF2CvbwsBY0+WBO4f75SfGwvM4K" + def normalize_docs_ui(ui: str | None) -> str | None: if ui is None: @@ -23,6 +49,12 @@ def docs_html( ui: str, title: str, openapi_url: str = "/openapi.json", + scalar_js_url: str = SCALAR_JS_URL, + scalar_js_sri: str | None = SCALAR_JS_SRI, + swagger_css_url: str = SWAGGER_CSS_URL, + swagger_css_sri: str | None = SWAGGER_CSS_SRI, + swagger_js_url: str = SWAGGER_JS_URL, + swagger_js_sri: str | None = SWAGGER_JS_SRI, ) -> str: """Return HTML for Scalar or Swagger UI pointing at ``openapi_url``.""" ui_n = normalize_docs_ui(ui) @@ -31,11 +63,31 @@ def docs_html( safe_title = escape(title) safe_url = escape(openapi_url, quote=True) if ui_n == "scalar": - return _scalar_html(safe_title, safe_url) - return _swagger_html(safe_title, safe_url) + return _scalar_html( + title=safe_title, + openapi_url=safe_url, + js_url=scalar_js_url, + js_sri=scalar_js_sri, + ) + return _swagger_html( + title=safe_title, + openapi_url=safe_url, + css_url=swagger_css_url, + css_sri=swagger_css_sri, + js_url=swagger_js_url, + js_sri=swagger_js_sri, + ) -def _scalar_html(title: str, openapi_url: str) -> str: +def _scalar_html( + title: str, + openapi_url: str, + js_url: str, + js_sri: str | None, +) -> str: + sri_attr = ( + f' integrity="{escape(js_sri, quote=True)}" crossorigin="anonymous"' if js_sri else "" + ) return f""" @@ -48,13 +100,26 @@ def _scalar_html(title: str, openapi_url: str) -> str: + src="{escape(js_url, quote=True)}"{sri_attr}> """ -def _swagger_html(title: str, openapi_url: str) -> str: +def _swagger_html( + title: str, + openapi_url: str, + css_url: str, + css_sri: str | None, + js_url: str, + js_sri: str | None, +) -> str: + css_sri_attr = ( + f' integrity="{escape(css_sri, quote=True)}" crossorigin="anonymous"' if css_sri else "" + ) + js_sri_attr = ( + f' integrity="{escape(js_sri, quote=True)}" crossorigin="anonymous"' if js_sri else "" + ) return f""" @@ -62,12 +127,12 @@ def _swagger_html(title: str, openapi_url: str) -> str: {title} — API docs + href="{escape(css_url, quote=True)}"{css_sri_attr}/>
- +