diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7edf53..2e81d6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,9 @@ jobs: {"short":"redirect", "crate":"ephpm-middleware-redirect"}, {"short":"security-headers", "crate":"ephpm-middleware-security-headers"}, {"short":"maintenance-mode", "crate":"ephpm-middleware-maintenance-mode"}, - {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"} + {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"}, + {"short":"request-id", "crate":"ephpm-middleware-request-id"}, + {"short":"header-transform", "crate":"ephpm-middleware-header-transform"} ]' if [ "$ONLY_HOST" = "true" ]; then diff --git a/Cargo.lock b/Cargo.lock index 82ea37c..672059b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "ephpm-config" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "figment", "serde", @@ -184,7 +184,7 @@ dependencies = [ [[package]] name = "ephpm-kv" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "anyhow", "brotli", @@ -205,7 +205,7 @@ dependencies = [ [[package]] name = "ephpm-middleware" version = "0.1.0" -source = "git+https://github.com/ephpm/ephpm.git?rev=a6e5317718dfa11ed2b7f6970996139b305eed73#a6e5317718dfa11ed2b7f6970996139b305eed73" +source = "git+https://github.com/ephpm/ephpm.git?rev=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" dependencies = [ "ephpm-kv", "serde_json", @@ -228,6 +228,14 @@ dependencies = [ "ephpm-middleware-modules", ] +[[package]] +name = "ephpm-middleware-header-transform" +version = "0.1.0" +dependencies = [ + "ephpm-middleware", + "ephpm-middleware-modules", +] + [[package]] name = "ephpm-middleware-ip-allowlist" version = "0.1.0" @@ -285,6 +293,14 @@ dependencies = [ "ephpm-middleware-modules", ] +[[package]] +name = "ephpm-middleware-request-id" +version = "0.1.0" +dependencies = [ + "ephpm-middleware", + "ephpm-middleware-modules", +] + [[package]] name = "ephpm-middleware-security-headers" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index aa74b10..66e9cf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,20 @@ repository = "https://github.com/ephpm/middleware" # by `rev` exactly like ePHPm pins litewire: a drift in `EphpmHostV1` / `ABI_V1` # would be silent UB at the FFI boundary, so the module is provably built # against one specific host-ABI commit. Bump = replace `rev` + `cargo update`. +# +# Pinned at ePHPm main `e63284838d07d348e2155e76916daaf9782c012b` — the merge of +# #408, which added the response-phase ABI hook (`ResponseMiddleware` / +# `declare!(Type, response)` / the `ResponseView` accessors) the request-id and +# header-transform modules build on. Do NOT advance this to #409's +# scheme/host/body accessors: neither module needs them. # The rlib of shared module implementations — re-exported by the cdylib shells. ephpm-middleware-modules = { path = "crates/ephpm-middleware-modules" } -ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "a6e5317718dfa11ed2b7f6970996139b305eed73" } +ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } # Test-only: the same embedded KV store the host wires into the middleware host # table, so the ratelimit unit tests exercise the real `kv_incr_ttl` path. Same # rev as the ABI crate so both resolve to one crate instance and the `Store` # type matches `ephpm_middleware::host::set_kv_store`. -ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "a6e5317718dfa11ed2b7f6970996139b305eed73" } +ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } serde_json = "1" # ip-allowlist: CIDR parsing + membership for IPv4/IPv6. Default features only diff --git a/README.md b/README.md index 09dc9ad..ea5855a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,14 @@ rather than compile into the server. > the repo is private those downloads require a token. The owner flips it > public when ready. -ePHPm runs middleware **in front of PHP, before PHP dispatch** — reject, -rewrite, or annotate a request at native speed, with direct access to the -embedded (cluster-replicated) KV store. See the +ePHPm runs middleware in two phases. The **request phase** runs **in front of +PHP, before PHP dispatch** — reject, rewrite, or annotate a request at native +speed, with direct access to the embedded (cluster-replicated) KV store; it +fails **closed**. The optional **response phase** runs **after** the response +is generated (PHP, static file, or error page), in reverse chain order, to +*transform* it — header injection, correlation ids; it fails +**safe** and is not a security gate. A module opts into the response phase with +`declare!(Type, response)`. See the [Native Middleware guide](https://github.com/ephpm/ephpm/blob/main/site/content/guides/native-middleware.md) for the operator view and chain semantics. @@ -28,6 +33,15 @@ for the operator view and chain semantics. | `security-headers` | `ephpm-middleware-security-headers` | Append standard security response headers (HSTS, CSP, `X-Frame-Options`, …). | | `maintenance-mode` | `ephpm-middleware-maintenance-mode` | Flip a tenant into a `503` holding page via a per-site KV flag — no redeploy (`Retry-After`; IP/path bypass; fails **open**). | | `ip-allowlist` | `ephpm-middleware-ip-allowlist` | Allow/deny requests by client IP against CIDR lists, fail-closed (`403`); deny beats allow. | +| `request-id` | `ephpm-middleware-request-id` | **Request + response phase.** Give every request a correlation id: generate or honor an inbound `X-Request-Id`, inject it for PHP, and echo it on the response. | +| `header-transform` | `ephpm-middleware-header-transform` | **Request + response phase.** Set request headers seen by PHP; set/remove response headers out. | + +> **No `compression` module.** Response-body compression is deliberately *not* +> shipped as a middleware: ePHPm's core already compresses buffered responses +> by default (`[server.response] compression`, **on**, brotli-then-gzip), +> negotiating `Accept-Encoding` and running **before** the response phase — so +> a middleware compressor would be redundant and inert on a stock server. Use +> the built-in knob, not a module. Per-module configuration keys are documented in each crate's module docs (`crates/ephpm-middleware-/src/lib.rs` re-exports the implementation from @@ -103,8 +117,14 @@ crates/ ephpm-middleware-security-headers cdylib shell ephpm-middleware-maintenance-mode cdylib shell ephpm-middleware-ip-allowlist cdylib shell + ephpm-middleware-request-id cdylib shell: declare!(RequestId, response) + ephpm-middleware-header-transform cdylib shell: declare!(HeaderTransform, response) ``` +The last two opt into the **response phase** with `declare!(Type, response)` — +the host runs their `invoke_response` after the response is generated to +transform it, in addition to their request phase. + The impl/shell split is deliberate: multiple crates each exporting the same `ephpm_middleware_*` symbols cannot be linked into one binary, so the implementations live symbol-free in `ephpm-middleware-modules` and each cdylib diff --git a/crates/ephpm-middleware-header-transform/Cargo.toml b/crates/ephpm-middleware-header-transform/Cargo.toml new file mode 100644 index 0000000..08642fe --- /dev/null +++ b/crates/ephpm-middleware-header-transform/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ephpm-middleware-header-transform" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ePHPm native middleware: set request headers seen by PHP and set/remove response headers out (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" + +[lib] +# cdylib = the loadable module for the dlopen lane; rlib for tests + the +# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision +# rationale behind the impl/shell split. +crate-type = ["cdylib", "rlib"] + +[dependencies] +ephpm-middleware.workspace = true +ephpm-middleware-modules.workspace = true + +[lints] +workspace = true diff --git a/crates/ephpm-middleware-header-transform/src/lib.rs b/crates/ephpm-middleware-header-transform/src/lib.rs new file mode 100644 index 0000000..cb560f4 --- /dev/null +++ b/crates/ephpm-middleware-header-transform/src/lib.rs @@ -0,0 +1,11 @@ +//! `header-transform` — loadable cdylib shell around the shared implementation +//! in [`ephpm_middleware_modules::header_transform`]. +//! +//! The middleware itself (request/response header set + response header remove, +//! the request + response phase logic, docs and tests included) lives in +//! `ephpm-middleware-modules`. This crate only adds the C ABI exports +//! (`declare!(HeaderTransform, response)`) for the `dlopen` lane. + +pub use ephpm_middleware_modules::header_transform::HeaderTransform; + +ephpm_middleware::declare!(HeaderTransform, response); diff --git a/crates/ephpm-middleware-modules/src/header_transform.rs b/crates/ephpm-middleware-modules/src/header_transform.rs new file mode 100644 index 0000000..1825552 --- /dev/null +++ b/crates/ephpm-middleware-modules/src/header_transform.rs @@ -0,0 +1,302 @@ +//! `header-transform` — ePHPm native middleware that rewrites request headers +//! seen by PHP and response headers sent to the client. +//! +//! Analogous to Traefik's `headers` (`customRequestHeaders` / +//! `customResponseHeaders`), Kong's request/response transformer, or nginx's +//! `proxy_set_header` / `add_header` / `more_clear_headers`. +//! +//! # Two phases +//! +//! - **Request phase** ([`Middleware::invoke`]) — set request headers before +//! PHP runs (PHP reads them as `$_SERVER['HTTP_']`). +//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — set or +//! remove response headers on the way out, on **every** response (PHP, +//! static file, error page). +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! ```toml +//! [middleware.config.request] +//! set = { "X-Env" = "prod", "X-Tenant" = "acme" } +//! +//! [middleware.config.response] +//! set = { "X-Served-By" = "ephpm" } +//! remove = ["Server", "X-Powered-By"] +//! ``` +//! +//! | section | key | effect | +//! |---------|-----|--------| +//! | `request` | `set` (object) | replace-or-add each request header PHP sees | +//! | `response` | `set` (object) | replace-or-add each response header | +//! | `response` | `remove` (array) | delete each response header (case-insensitive) | +//! +//! # v1 ABI scope (why there is no `add` or request `remove`) +//! +//! The host applies both request-header overrides and response `set` as +//! **replace-or-add** (one occurrence, case-insensitive) — there is no +//! duplicate-append primitive in the v1 middleware ABI, so `add` and `set` +//! would be identical; only `set` is offered. And the request phase can only +//! *override* a request header, not delete one, so request-side `remove` is +//! not offered (it would be a silent no-op). Header **removal is response-side +//! only**, where the ABI supports it. Both are honest reflections of the ABI, +//! not omissions. + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; + +/// A parsed set of `(name, value)` header assignments, order preserved. +type Assignments = Vec<(String, String)>; + +/// Header rewrite policy, built once at `init`. +pub struct HeaderTransform { + request_set: Assignments, + response_set: Assignments, + response_remove: Vec, +} + +/// Parse an optional `{ name: value, ... }` object into ordered string pairs. +/// Rejects non-string values and empty names. +fn parse_set(section: &serde_json::Value, path: &str) -> Result { + match section.get("set") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Object(map)) => { + let mut out = Vec::with_capacity(map.len()); + for (name, value) in map { + if name.is_empty() { + return Err(format!("`{path}.set` has an empty header name")); + } + let v = value.as_str().ok_or_else(|| { + format!("`{path}.set` values must be strings, got {value} for `{name}`") + })?; + out.push((name.clone(), v.to_owned())); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.set` must be an object, got {other}")), + } +} + +/// Parse an optional `["Name", ...]` array of header names to remove. +fn parse_remove(section: &serde_json::Value, path: &str) -> Result, String> { + match section.get("remove") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let name = item.as_str().ok_or_else(|| { + format!("`{path}.remove` entries must be strings, got {item}") + })?; + if name.is_empty() { + return Err(format!("`{path}.remove` has an empty header name")); + } + out.push(name.to_owned()); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.remove` must be an array, got {other}")), + } +} + +/// Fetch a top-level section object (`request` / `response`), defaulting to a +/// JSON null (an empty section) when absent. Rejects a non-object section. +fn section<'a>(config: &'a serde_json::Value, key: &str) -> Result<&'a serde_json::Value, String> { + const NULL: serde_json::Value = serde_json::Value::Null; + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(&NULL), + Some(v @ serde_json::Value::Object(_)) => Ok(v), + Some(other) => Err(format!("`{key}` must be an object, got {other}")), + } +} + +impl Middleware for HeaderTransform { + fn init(config: &serde_json::Value) -> Result { + let request = section(config, "request")?; + let response = section(config, "response")?; + + // `request.remove` cannot be honored (see module docs); reject it loudly + // rather than silently ignore it. + if request.get("remove").is_some_and(|v| !v.is_null()) { + return Err("`request.remove` is not supported: the v1 ABI request phase can \ + only set/override request headers, not delete them" + .into()); + } + + Ok(Self { + request_set: parse_set(request, "request")?, + response_set: parse_set(response, "response")?, + response_remove: parse_remove(response, "response")?, + }) + } + + fn invoke(&self, _req: &Request<'_>) -> Response { + if self.request_set.is_empty() { + return Response::cont(); + } + let mut r = Response::rewrite(); + for (name, value) in &self.request_set { + r = r.header(name.clone(), value.clone()); + } + r + } +} + +impl ResponseMiddleware for HeaderTransform { + fn invoke_response(&self, _req: &Request<'_>, resp: &mut ResponseView<'_>) { + for name in &self.response_remove { + resp.remove_header(name.clone()); + } + for (name, value) in &self.response_set { + resp.set_header(name.clone(), value.clone()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_REWRITE}; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> HeaderTransform { + HeaderTransform::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn invoke(mw: &HeaderTransform) -> Response { + let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + fn invoke_response( + mw: &HeaderTransform, + resp_headers: Vec<(String, String)>, + ) -> Vec<(String, String)> { + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(200, resp_headers, b"body".to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (status, body, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = status { + rctx.set_status(s); + } + if let Some(b) = body { + rctx.replace_body(b); + } + } + let (_status, headers, _body) = rctx.into_parts(); + headers + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + // ── request phase ───────────────────────────────────────────────────── + + #[test] + fn request_set_injects_headers() { + let mw = init(serde_json::json!({ + "request": { "set": { "X-Env": "prod", "X-Tenant": "acme" } } + })); + let resp = invoke(&mw); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(get(resp.__headers(), "X-Env"), Some("prod")); + assert_eq!(get(resp.__headers(), "X-Tenant"), Some("acme")); + } + + #[test] + fn no_request_config_continues() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-A": "b" } } + })); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + } + + // ── response phase ──────────────────────────────────────────────────── + + #[test] + fn response_set_replaces_or_adds() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-Served-By": "ephpm", "Content-Type": "text/plain" } } + })); + let out = invoke_response(&mw, vec![hdr("Content-Type", "text/html")]); + assert_eq!(get(&out, "X-Served-By"), Some("ephpm")); + // Replace, not duplicate. + assert_eq!(get(&out, "Content-Type"), Some("text/plain")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Content-Type")).count(), 1); + } + + #[test] + fn response_remove_deletes() { + let mw = init(serde_json::json!({ + "response": { "remove": ["Server", "X-Powered-By"] } + })); + let out = invoke_response( + &mw, + vec![hdr("Server", "nginx"), hdr("X-Powered-By", "PHP/8.5"), hdr("X-Keep", "1")], + ); + assert_eq!(get(&out, "Server"), None); + assert_eq!(get(&out, "X-Powered-By"), None); + assert_eq!(get(&out, "X-Keep"), Some("1")); + } + + #[test] + fn remove_then_set_same_header_nets_set() { + let mw = init(serde_json::json!({ + "response": { "set": { "Server": "ephpm" }, "remove": ["Server"] } + })); + let out = invoke_response(&mw, vec![hdr("Server", "nginx")]); + assert_eq!(get(&out, "Server"), Some("ephpm")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Server")).count(), 1); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn request_remove_is_rejected() { + assert!( + HeaderTransform::init(&serde_json::json!({ + "request": { "remove": ["X-Foo"] } + })) + .is_err() + ); + } + + #[test] + fn bad_config_fails_init() { + assert!( + HeaderTransform::init(&serde_json::json!({ "request": { "set": { "X": 1 } } })) + .is_err() + ); + assert!( + HeaderTransform::init(&serde_json::json!({ "response": { "remove": [42] } })).is_err() + ); + assert!(HeaderTransform::init(&serde_json::json!({ "request": "nope" })).is_err()); + assert!(HeaderTransform::init(&serde_json::json!({ "response": { "set": [] } })).is_err()); + } + + #[test] + fn empty_config_is_a_noop() { + let mw = init(serde_json::Value::Null); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + let out = invoke_response(&mw, vec![hdr("X-A", "b")]); + assert_eq!(get(&out, "X-A"), Some("b")); + } +} diff --git a/crates/ephpm-middleware-modules/src/lib.rs b/crates/ephpm-middleware-modules/src/lib.rs index c936fec..ed00a9b 100644 --- a/crates/ephpm-middleware-modules/src/lib.rs +++ b/crates/ephpm-middleware-modules/src/lib.rs @@ -17,9 +17,11 @@ pub mod api_key; pub mod cors; +pub mod header_transform; pub mod ip_allowlist; pub mod jwt; pub mod maintenance_mode; pub mod ratelimit; pub mod redirect; +pub mod request_id; pub mod security_headers; diff --git a/crates/ephpm-middleware-modules/src/request_id.rs b/crates/ephpm-middleware-modules/src/request_id.rs new file mode 100644 index 0000000..9d4b794 --- /dev/null +++ b/crates/ephpm-middleware-modules/src/request_id.rs @@ -0,0 +1,388 @@ +//! `request-id` — ePHPm native middleware that gives every request a stable +//! correlation id, injects it for PHP, and echoes it on the response. +//! +//! Analogous to Caddy's `request_id`, Kong's `correlation-id`, Traefik's +//! request-id plugins, or nginx's `$request_id`. One id per request ties the +//! access log, the PHP application log, and the client's copy of the header +//! together. +//! +//! # Two phases +//! +//! - **Request phase** ([`Middleware::invoke`]) — resolve the id (honor a +//! trusted inbound header, otherwise generate a fresh UUIDv4), inject it as a +//! request header so PHP sees `$_SERVER['HTTP_
']`, and stage the same +//! value as a response header so the dynamic response carries exactly the id +//! PHP logged. +//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — guarantee +//! the header on responses the request phase never touched (the static-file +//! path runs **no** request phase) and stay idempotent on the PHP path. +//! +//! # Why the request phase also stages the response header +//! +//! The v1 response phase cannot see state its own request phase set — it is +//! handed a request view rebuilt from the *original* inbound headers, and it +//! may run with no preceding `invoke` at all (static files, an upstream +//! short-circuit). So a *generated* id known only to the request phase could +//! not be re-derived in the response phase; regenerating there would echo a +//! different id than PHP received. The request phase therefore carries the id +//! to the response itself (`response_header`), and the response phase only +//! *fills in* the header when it is absent — never overwrites it. +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! | key | default | meaning | +//! |-----|---------|---------| +//! | `header` (string) | `"X-Request-Id"` | the request/response header name carrying the id | +//! | `trust_inbound` (bool) | `false` | when true, reuse a well-formed inbound `header` value instead of generating; when false, always generate (the inbound value is ignored) | +//! +//! An inbound id is only trusted when it is a short, printable ASCII token +//! (no control characters, no whitespace, ≤ 200 bytes). A trusted value that +//! fails that check is replaced with a generated id rather than reflected — a +//! client must not be able to smuggle CR/LF or oversized junk into logs and +//! downstream headers through a "trusted" correlation id. + +use std::cell::Cell; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; + +/// Max length of an inbound id we are willing to reflect. +const MAX_INBOUND_LEN: usize = 200; + +/// Resolved request-id policy, built once at `init`. +pub struct RequestId { + /// Header name carrying the id (as configured; used verbatim on the wire). + header: String, + /// Whether a well-formed inbound value is reused instead of generated. + trust_inbound: bool, +} + +/// Read an optional string config key with a default. +fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default.to_owned()), + Some(serde_json::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(format!("`{key}` must be a string, got {other}")), + } +} + +/// Read an optional boolean config key with a default. +fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), + } +} + +/// True when `id` is a safe correlation token: non-empty, ≤ [`MAX_INBOUND_LEN`] +/// bytes, and every byte a printable ASCII graphic (no controls, no spaces). +fn is_safe_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_INBOUND_LEN && id.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Per-process entropy mixed into every generated id, so two processes (or two +/// restarts) do not produce the same id stream. Seeded once from wall-clock +/// nanos and the address of a stack local. +static SEED: AtomicU64 = AtomicU64::new(0); + +thread_local! { + /// Per-thread SplitMix64 state. Lazily seeded from the process seed, the + /// thread identity, and a monotonic counter so distinct threads never + /// walk the same sequence. + static RNG: Cell = const { Cell::new(0) }; +} + +/// SplitMix64 — a tiny, fast, well-distributed 64-bit generator. Not +/// cryptographic; request ids need collision resistance, not unpredictability. +fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Initialise the process seed exactly once. +fn ensure_seed() { + if SEED.load(Ordering::Relaxed) == 0 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0x1234_5678, |d| d.as_nanos() as u64); + let local = 0u8; + let addr = std::ptr::from_ref(&local) as u64; + let mut s = nanos ^ addr.rotate_left(17) ^ 0xA5A5_5A5A_1234_ABCD; + if s == 0 { + s = 0xDEAD_BEEF_CAFE_F00D; + } + // Racy is fine: any winner leaves a usable non-zero seed. + SEED.store(s, Ordering::Relaxed); + } +} + +/// Draw the next two 64-bit words of randomness from the thread-local RNG. +fn next_u128() -> (u64, u64) { + ensure_seed(); + RNG.with(|cell| { + let mut state = cell.get(); + if state == 0 { + static THREAD_COUNTER: AtomicU64 = AtomicU64::new(1); + let tc = THREAD_COUNTER.fetch_add(1, Ordering::Relaxed); + state = SEED + .load(Ordering::Relaxed) + .wrapping_mul(0x2545_F491_4F6C_DD1D) + .wrapping_add(tc.rotate_left(32)); + if state == 0 { + state = 0x1; + } + } + let hi = splitmix64(&mut state); + let lo = splitmix64(&mut state); + cell.set(state); + (hi, lo) + }) +} + +/// Generate a random UUIDv4 string (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`). +fn generate_id() -> String { + let (mut hi, mut lo) = next_u128(); + // Version 4 in the high nibble of byte 6. + hi = (hi & 0xFFFF_FFFF_FFFF_0FFF) | 0x0000_0000_0000_4000; + // Variant 10xx in the two high bits of byte 8. + lo = (lo & 0x3FFF_FFFF_FFFF_FFFF) | 0x8000_0000_0000_0000; + let b = |v: u64, shift: u32| ((v >> shift) & 0xFF) as u8; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b(hi, 56), + b(hi, 48), + b(hi, 40), + b(hi, 32), + b(hi, 24), + b(hi, 16), + b(hi, 8), + b(hi, 0), + b(lo, 56), + b(lo, 48), + b(lo, 40), + b(lo, 32), + b(lo, 24), + b(lo, 16), + b(lo, 8), + b(lo, 0), + ) +} + +impl RequestId { + /// The id to use for this request: a trusted, well-formed inbound value + /// when `trust_inbound` is on, otherwise a freshly generated UUIDv4. + fn resolve(&self, inbound: Option<&str>) -> String { + if self.trust_inbound + && let Some(v) = inbound + && is_safe_id(v) + { + return v.to_owned(); + } + generate_id() + } +} + +impl Middleware for RequestId { + fn init(config: &serde_json::Value) -> Result { + let header = opt_string(config, "header", "X-Request-Id")?; + if header.is_empty() { + return Err("`header` must not be empty".into()); + } + Ok(Self { header, trust_inbound: opt_bool(config, "trust_inbound", false)? }) + } + + fn invoke(&self, req: &Request<'_>) -> Response { + let id = self.resolve(req.header(&self.header)); + // Inject the request header (PHP sees $_SERVER['HTTP_...']) AND echo the + // same value on the response, so the dynamic path carries exactly the + // id PHP logged. The response phase fills the header in only when it is + // still absent (e.g. the static-file path, which runs no request phase). + Response::rewrite() + .header(self.header.clone(), id.clone()) + .response_header(self.header.clone(), id) + } +} + +impl ResponseMiddleware for RequestId { + fn invoke_response(&self, req: &Request<'_>, resp: &mut ResponseView<'_>) { + // Idempotent: if the header is already present (request phase echoed it, + // or PHP set its own), leave it untouched — never overwrite or duplicate. + if resp.header(&self.header).is_some() { + return; + } + // No request phase ran for this response (static file / short-circuit), + // so honor a trusted inbound value or generate a fresh id. + let id = self.resolve(req.header(&self.header)); + resp.set_header(self.header.clone(), id); + } +} + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use ephpm_middleware::abi::ACTION_REWRITE; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> RequestId { + RequestId::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn invoke(mw: &RequestId, headers: &[(String, String)]) -> Response { + let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + /// Drive the response phase against a fabricated response, returning the + /// resulting `(status, headers, body)`. + fn invoke_response( + mw: &RequestId, + req_headers: &[(String, String)], + resp_status: u16, + resp_headers: Vec<(String, String)>, + resp_body: &[u8], + ) -> (u16, Vec<(String, String)>, Vec) { + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", req_headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(resp_status, resp_headers, resp_body.to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (status, body, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = status { + rctx.set_status(s); + } + if let Some(b) = body { + rctx.replace_body(b); + } + } + rctx.into_parts() + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + fn looks_like_uuid(v: &str) -> bool { + v.len() == 36 && v.as_bytes()[14] == b'4' && v.chars().filter(|c| *c == '-').count() == 4 + } + + // ── request phase ───────────────────────────────────────────────────── + + #[test] + fn generates_and_injects_when_absent() { + let mw = init(serde_json::Value::Null); + let resp = invoke(&mw, &[]); + assert_eq!(resp.__action(), ACTION_REWRITE); + // Request header override (PHP-visible). + let req_id = get(resp.__headers(), "X-Request-Id").expect("request header"); + assert!(looks_like_uuid(req_id), "{req_id}"); + // Response echo — same value. + let resp_id = get(resp.__response_headers(), "X-Request-Id").expect("response header"); + assert_eq!(req_id, resp_id, "PHP and the client must see the same id"); + } + + #[test] + fn ignores_inbound_when_not_trusted() { + let mw = init(serde_json::Value::Null); + let resp = invoke(&mw, &[hdr("X-Request-Id", "client-supplied-123")]); + let id = get(resp.__headers(), "X-Request-Id").unwrap(); + assert_ne!(id, "client-supplied-123"); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn honors_trusted_inbound() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + let resp = invoke(&mw, &[hdr("X-Request-Id", "abc-123-DEF")]); + assert_eq!(get(resp.__headers(), "X-Request-Id"), Some("abc-123-DEF")); + assert_eq!(get(resp.__response_headers(), "X-Request-Id"), Some("abc-123-DEF")); + } + + #[test] + fn trusted_but_unsafe_inbound_is_regenerated() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + // CR/LF injection attempt — must not be reflected. + let resp = invoke(&mw, &[hdr("X-Request-Id", "bad\r\nInjected: 1")]); + let id = get(resp.__headers(), "X-Request-Id").unwrap(); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn custom_header_name() { + let mw = init(serde_json::json!({ "header": "X-Correlation-Id", "trust_inbound": true })); + let resp = invoke(&mw, &[hdr("X-Correlation-Id", "corr-1")]); + assert_eq!(get(resp.__headers(), "X-Correlation-Id"), Some("corr-1")); + } + + #[test] + fn generated_ids_are_unique() { + let mw = init(serde_json::Value::Null); + let a = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); + let b = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); + assert_ne!(a, b); + } + + // ── response phase ──────────────────────────────────────────────────── + + #[test] + fn response_phase_adds_header_when_absent() { + // Static-file path: no request phase ran, response has no id yet. + let mw = init(serde_json::Value::Null); + let (_status, headers, _body) = invoke_response(&mw, &[], 200, vec![], b"body"); + let id = get(&headers, "X-Request-Id").expect("id added"); + assert!(looks_like_uuid(id), "{id}"); + } + + #[test] + fn response_phase_is_idempotent_when_present() { + // PHP path: the request phase already echoed the id — do not overwrite. + let mw = init(serde_json::Value::Null); + let (_status, headers, _body) = + invoke_response(&mw, &[], 200, vec![hdr("X-Request-Id", "existing-id-42")], b"body"); + assert_eq!(get(&headers, "X-Request-Id"), Some("existing-id-42")); + // Exactly one occurrence — no duplicate. + assert_eq!( + headers.iter().filter(|(n, _)| n.eq_ignore_ascii_case("X-Request-Id")).count(), + 1 + ); + } + + #[test] + fn response_phase_honors_trusted_inbound_on_static_path() { + let mw = init(serde_json::json!({ "trust_inbound": true })); + let (_status, headers, _body) = + invoke_response(&mw, &[hdr("X-Request-Id", "inbound-77")], 200, vec![], b"body"); + assert_eq!(get(&headers, "X-Request-Id"), Some("inbound-77")); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn bad_config_fails_init() { + assert!(RequestId::init(&serde_json::json!({ "header": "" })).is_err()); + assert!(RequestId::init(&serde_json::json!({ "header": 5 })).is_err()); + assert!(RequestId::init(&serde_json::json!({ "trust_inbound": "yes" })).is_err()); + } +} diff --git a/crates/ephpm-middleware-request-id/Cargo.toml b/crates/ephpm-middleware-request-id/Cargo.toml new file mode 100644 index 0000000..933d300 --- /dev/null +++ b/crates/ephpm-middleware-request-id/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ephpm-middleware-request-id" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ePHPm native middleware: per-request correlation id — generate/propagate X-Request-Id for PHP and echo it on the response (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" + +[lib] +# cdylib = the loadable module for the dlopen lane; rlib for tests + the +# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision +# rationale behind the impl/shell split. +crate-type = ["cdylib", "rlib"] + +[dependencies] +ephpm-middleware.workspace = true +ephpm-middleware-modules.workspace = true + +[lints] +workspace = true diff --git a/crates/ephpm-middleware-request-id/src/lib.rs b/crates/ephpm-middleware-request-id/src/lib.rs new file mode 100644 index 0000000..da83ba8 --- /dev/null +++ b/crates/ephpm-middleware-request-id/src/lib.rs @@ -0,0 +1,11 @@ +//! `request-id` — loadable cdylib shell around the shared implementation in +//! [`ephpm_middleware_modules::request_id`]. +//! +//! The middleware itself (id generation/propagation, the request + response +//! phase logic, docs and tests included) lives in `ephpm-middleware-modules`. +//! This crate only adds the C ABI exports (`declare!(RequestId, response)`, so +//! both the request and response phase are exported) for the `dlopen` lane. + +pub use ephpm_middleware_modules::request_id::RequestId; + +ephpm_middleware::declare!(RequestId, response);