From 56ec040dbc02500346528044c6b557f5f8601cb9 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Tue, 1 Sep 2026 22:09:30 -0700 Subject: [PATCH 1/2] Update for the middleware ABI minor 3 vhost_id / KV-scope change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ePHPm #448 takes the native-middleware ABI to minor 3 and redefines two surfaces these examples teach against. Nothing here failed to compile — none of the four modules called `vhost_id()` — but two of them were teaching the wrong thing once the host changed underneath them. `Request::vhost_id()` now returns `Option<&str>` carrying the router's canonical site key, NULL for a host that matched no vhost, where it used to return the raw `Host` header (ephpm#390). And the host table's `kv_*` callbacks now resolve the serving vhost's keyspace rather than the process-global store, with the global store reachable through the appended `kv_*_global` slots (ephpm#376). api-key: the KV credential lookup moves to `kv_get_global`. On a multi-tenant node the per-site store is writable by that site's own PHP, so a `key -> consumer-id` map living there would let any tenant mint itself a consumer identity. The global store is where operator-owned state belongs, and it is also the pre-minor-3 behaviour, so this is a no-op for existing deployments rather than a migration. api-key also gains an optional `` placeholder in `kv_key_template` so a per-tenant key map is expressible. It resolves from `vhost_id()` and DENIES when there is no tenant — the fail-closed half of the pattern, as against the deliberate `UNMATCHED_VHOST` bucket a rate limiter wants. Three tests cover it: per-tenant isolation, the fail-closed branch, and a template without `` staying node-wide. The api-key test helper seeds through `kv_set_global` and every test now uses key names no sibling touches: the store is process-wide, the tests run in parallel, and `Store::set_local` removes before it inserts, so two tests writing one key left a window where a third read saw a miss. redirect: documents why reading the `Host` header is correct *here* — a canonicalizing redirect exists to rewrite what the client asked for, which is exactly the thing `vhost_id()` is not. Also drops the stale claim that the ABI exposes no request scheme (minor 2 added it). README: a Tenancy section with the two patterns (fail closed vs bucket under `UNMATCHED_VHOST`) and the `kv_*` / `kv_*_global` split, plus the ABI minor history. Refs ephpm/ephpm#449 --- Cargo.lock | 6 +- Cargo.toml | 26 ++- README.md | 69 +++++- crates/ephpm-middleware-api-key/src/lib.rs | 224 ++++++++++++++++++-- crates/ephpm-middleware-redirect/src/lib.rs | 29 ++- 5 files changed, 317 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05a14bc..6d23d32 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=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" +source = "git+https://github.com/ephpm/ephpm.git?rev=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" 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=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" +source = "git+https://github.com/ephpm/ephpm.git?rev=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" 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=e63284838d07d348e2155e76916daaf9782c012b#e63284838d07d348e2155e76916daaf9782c012b" +source = "git+https://github.com/ephpm/ephpm.git?rev=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" dependencies = [ "ephpm-kv", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dd0b3b0..d336c90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,16 +21,30 @@ repository = "https://github.com/ephpm/middleware-examples" # rebuild these examples against a newer host, replace `rev` and run # `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 -# header-transform example builds on. -ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } +# Pinned at ePHPm main `21a7c8a7832b62d0ab8af960931a33e8e9778784` — the merge of +# ephpm#448, which took the ABI to **minor 3**. Two things these examples care +# about landed there (ephpm#449 tracks this bump): +# +# * `Request::vhost_id()` returns `Option<&str>`: the router's CANONICAL SITE +# KEY, and `None` for a host that matched no virtual host — where it used to +# return the raw `Host` header and could never be absent. It is a tenant +# identity now, so a gate can fail closed on `None` instead of keying policy +# on a client-supplied string (ephpm#390). +# * The host table's `kv_*` callbacks resolve THE SERVING VHOST'S keyspace +# rather than the process-global store (ephpm#376). The appended +# `kv_*_global` slots reach the process-wide store, which is where +# operator-owned state — such as `api-key`'s credential map — has to live on +# a multi-tenant node, because no tenant's PHP can write there. +# +# The previous pin was `e63284838d07d348e2155e76916daaf9782c012b` (#408, the +# response-phase hook the header-transform example builds on); everything from +# that rev is still present, since ABI growth is additive within major 1. +ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "21a7c8a7832b62d0ab8af960931a33e8e9778784" } # Test-only: the same embedded KV store the host wires into the middleware host # table, so the api-key and ratelimit examples exercise the real KV path in # their unit tests. 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 = "e63284838d07d348e2155e76916daaf9782c012b" } +ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "21a7c8a7832b62d0ab8af960931a33e8e9778784" } # Every example parses its config out of a `serde_json::Value`. serde_json = "1" diff --git a/README.md b/README.md index 54326c5..35d1e26 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Four modules, chosen to cover the range rather than every use case: | Example | Crate | Teaches | |---------|-------|---------| | `basic-auth` | `ephpm-middleware-basic-auth` | The **simplest whole-site auth gate**: verify an `Authorization: Basic` credential (RFC 7617) with a constant-time compare, `401` + `WWW-Authenticate` otherwise. No KV. Gates static assets and PHP alike (ePHPm #408/#395). Start here. | -| `api-key` | `ephpm-middleware-api-key` | A **request-phase auth gate** that also **uses the KV store**: read a key from a header (or query param), validate it against a static map **or** a `kv_get` lookup with a constant-time compare, and forward the resolved consumer id to PHP — or short-circuit `401`. | +| `api-key` | `ephpm-middleware-api-key` | A **request-phase auth gate** that also **uses the KV store**: read a key from a header (or query param), validate it against a static map **or** a `kv_get_global` lookup with a constant-time compare, and forward the resolved consumer id to PHP — or short-circuit `401`. Also the **multi-tenancy** example: an optional `` in the KV key template scopes the credential map per tenant, and fails closed when the request matched no vhost. | | `redirect` | `ephpm-middleware-redirect` | The **simplest early-return**: compute a canonical URL (scheme / host / trailing slash) and emit a single `301`/`308`, or `CONTINUE`. No KV, no extra deps. | | `header-transform` | `ephpm-middleware-header-transform` | The **response phase**: `declare!(Type, response)`, setting request headers PHP sees *and* setting/removing response headers on the way out. | @@ -96,18 +96,73 @@ impl ResponseMiddleware for MyGate { **KV access.** The request carries a handle to ePHPm's embedded KV store — `req.host().kv_get(key)`, `kv_set`, `kv_incr_ttl(key, by, ttl)` — the same -gossip-replicated store PHP uses. See `api-key` for a real `kv_get` lookup. +gossip-replicated store PHP uses. Since ABI minor 3 those resolve **the serving +vhost's** keyspace, and `kv_get_global` / `kv_set_global` / `kv_incr_ttl_global` +resolve the process-wide store; see [Tenancy](#tenancy-vhost_id-and-kv-scope) +below. `api-key` shows both. + +### Tenancy: `vhost_id()` and KV scope + +One mount serves every vhost, so a module on a multi-tenant node +(`[server] sites_dir`) has to decide *whose* request it is looking at. Two rules +carry all of it: + +**1. `req.vhost_id()` is the tenant identity; the `Host` header is not.** It +returns `Option<&str>` — the **canonical site key** the router resolved, which +is the same identity that picks the request's per-site database, KV keyspace and +OPcache vhost. It is normalized by the router, so `Site.Example`, +`site.example:8080` and `site.example.` are one key, and a configured +`sites_domain_suffix` is already stripped. + +`None` means the request matched **no** virtual host. That is a decision, not a +missing value: ePHPm serves unrecognised hosts from the default document root, +so `req.http_host()` there is arbitrary client input. Two correct ways to handle +it, and no third: + +```rust +// Auth gate — fail closed. No tenant, no policy. +let Some(site) = req.vhost_id() else { + return Response::respond(404, "unknown host"); +}; + +// Rate limiter / counter — one deliberate bucket, never one per Host value. +let site = req.vhost_id().unwrap_or(ephpm_middleware::UNMATCHED_VHOST); +``` + +Substituting the header instead hands a caller a fresh keyspace — and a fresh +rate-limit budget — per `Host` they invent. That was ePHPm +[#390](https://github.com/ephpm/ephpm/issues/390). Use `req.http_host()` only +when you genuinely want the host as sent (a canonical-host redirect, a log +line); `redirect` is the example of that. + +**2. `kv_*` is per-tenant, `kv_*_global` is node-wide.** Since ePHPm +[#376](https://github.com/ephpm/ephpm/issues/376) the plain `kv_*` callbacks +resolve the serving vhost's own store — the same one that tenant's PHP writes +through `ephpm_kv_set()`. A counter or flag is therefore per-tenant with no key +prefixing, and a module can share a key with the app it fronts. But **anything a +tenant must not be able to forge belongs in `kv_*_global`**: a credential map in +the per-site store is writable by that site's own PHP. `api-key` puts its map in +the global store for exactly that reason. + +On a single-site node there is one store and the two are the same thing. ### The ABI is versioned Every module is built against ePHPm's native-middleware **C ABI**, whose **major byte** gates compatibility: `declare!` embeds the major, and a module built against a different host major refuses to initialise rather than corrupt memory -at the FFI boundary (current major: `1`). The ABI/trait crate `ephpm-middleware` -is **not** vendored here — it is the shared contract owned by the ePHPm host, so -these examples depend on it by git `rev` (see the root `Cargo.toml`), pinned to -one specific host commit exactly the way ePHPm pins litewire. To build against a -newer host, bump that `rev` and `cargo update`. +at the FFI boundary (current major: `1`). The lower three bytes are an additive +**minor** level; these examples are built against **minor 3** +(`0x0100_0003`) — minor 1 added the response phase, minor 2 the +scheme/`is_secure`/normalized-host request accessors and a real request body, +and minor 3 the process-global KV slots plus the two redefinitions described +under [Tenancy](#tenancy-vhost_id-and-kv-scope). + +The ABI/trait crate `ephpm-middleware` is **not** vendored here — it is the +shared contract owned by the ePHPm host, so these examples depend on it by git +`rev` (see the root `Cargo.toml`), pinned to one specific host commit exactly the +way ePHPm pins litewire. To build against a newer host, bump that `rev` and +`cargo update`. ## Building a module diff --git a/crates/ephpm-middleware-api-key/src/lib.rs b/crates/ephpm-middleware-api-key/src/lib.rs index 99f1084..d2a30cd 100644 --- a/crates/ephpm-middleware-api-key/src/lib.rs +++ b/crates/ephpm-middleware-api-key/src/lib.rs @@ -18,9 +18,9 @@ //! The key is read from a configurable request header (default `X-Api-Key`) //! and, only when explicitly enabled, from a query parameter (default off — //! see the security note). It is validated against either a static -//! `key → consumer-id` map baked into the config, a KV lookup (`kv_get` on a -//! `kv_key_template` like `apikey:` whose value is the consumer id), or -//! both (the static map is consulted first). On success the module `REWRITE`s +//! `key → consumer-id` map baked into the config, a KV lookup (`kv_get_global` +//! on a `kv_key_template` like `apikey:` whose value is the consumer id), +//! or both (the static map is consulted first). On success the module `REWRITE`s //! the request, injecting the consumer id in a header (default //! `X-Consumer-Id`) that PHP reads — the exact mechanism `jwt` uses to forward //! claims. The injected header **overwrites** any same-named header the client @@ -46,6 +46,43 @@ //! `key_headers` at the same header (e.g. `["X-Api-Key"]`) to get per-key //! rate limiting in front of, or alongside, this auth gate. //! +//! ## The credential map lives in the PROCESS-GLOBAL store (ABI minor 3) +//! +//! Since ePHPm [#376](https://github.com/ephpm/ephpm/issues/376) the host +//! table's plain `kv_get` resolves **the serving vhost's** keyspace — the same +//! physically separate store that tenant's PHP writes through `ephpm_kv_set()`. +//! For a per-tenant counter that is exactly what you want. For a **credential +//! map it is a privilege escalation**: on a multi-tenant node any tenant's PHP +//! could write `apikey:` into its own store and mint itself a +//! consumer identity that this gate would then honour. +//! +//! So the lookup here uses [`Host::kv_get_global`], the process-wide store, +//! which no tenant's PHP can reach. That is also the pre-minor-3 behaviour, so +//! nothing changes for an existing deployment — it just stays correct once the +//! host starts scoping `kv_*` per vhost. Seed it out of band (the RESP listener +//! or a control-plane process), not from a tenant's application code. +//! +//! Mounted on a host older than ABI minor 3 the global slot does not exist, the +//! safe wrapper returns `None`, and the KV path therefore denies every request +//! (the static `keys` map still works). Failing closed is the right direction +//! for an auth gate, and the pinned `rev` in `Cargo.toml` makes it moot in +//! practice. +//! +//! ## Per-tenant key maps: `` and failing closed +//! +//! One mount serves every vhost, so a multi-tenant deployment usually wants one +//! key map per tenant. Put the optional `` placeholder in +//! `kv_key_template` (`apikey::`) and it is substituted with the +//! request's **canonical site key** — [`Request::vhost_id`], the identity the +//! router resolved, never the `Host` header a client sent +//! ([#390](https://github.com/ephpm/ephpm/issues/390)). +//! +//! `vhost_id()` is `None` for a request that matched no virtual host, and this +//! module then **denies** rather than substituting anything: an auth gate that +//! guessed a site there would be keying policy on arbitrary client input. That +//! is the fail-closed half of the pattern — a rate limiter, which only has to +//! bucket, would instead use `ephpm_middleware::UNMATCHED_VHOST`. +//! //! Configuration (`[[middleware]] config = { ... }`): //! //! | key | default | meaning | @@ -53,10 +90,12 @@ //! | `header` (string) | `"X-Api-Key"` | request header carrying the key | //! | `query_param` (string) | unset (disabled) | also accept the key from this query parameter — see the security note | //! | `keys` (object) | unset | static `key → consumer-id` map | -//! | `kv_key_template` (string) | unset | KV lookup key with a `` placeholder, e.g. `apikey:`; the value is the consumer id | +//! | `kv_key_template` (string) | unset | global-store lookup key with a required `` placeholder and an optional `` one, e.g. `apikey:` or `apikey::`; the value is the consumer id | //! | `consumer_header` (string) | `"X-Consumer-Id"` | header injected for PHP with the resolved consumer id | //! //! At least one of `keys` / `kv_key_template` must be configured. +//! +//! [`Host::kv_get_global`]: ephpm_middleware::Host::kv_get_global use ephpm_middleware::{Middleware, Request, Response}; use subtle::ConstantTimeEq; @@ -64,6 +103,11 @@ use subtle::ConstantTimeEq; /// The literal replaced with the presented key in `kv_key_template`. const KEY_PLACEHOLDER: &str = ""; +/// The optional literal replaced with the request's canonical site key in +/// `kv_key_template`. Its presence is what makes a key map per-tenant — and +/// what makes a request with no tenant identity fail closed. +const SITE_PLACEHOLDER: &str = ""; + /// API-key validation policy, built once at `init`. pub struct ApiKey { header: String, @@ -72,10 +116,29 @@ pub struct ApiKey { /// Static `key → consumer-id` entries. Keys are stored as bytes for the /// constant-time comparison. keys: Vec<(Vec, String)>, - /// KV lookup template containing [`KEY_PLACEHOLDER`], e.g. `apikey:`. + /// KV lookup template containing [`KEY_PLACEHOLDER`], e.g. `apikey:`, + /// and optionally [`SITE_PLACEHOLDER`], e.g. `apikey::`. kv_key_template: Option, } +/// Outcome of the KV credential lookup. +/// +/// The third variant exists so `invoke` can tell "this key is not in the store" +/// apart from "this request has no tenant, so a per-tenant key map cannot even +/// be addressed". Both deny — but only one of them is a statement about the +/// presented credential, and collapsing them would hide the fail-closed branch +/// this example exists to demonstrate. +enum KvLookup { + /// The key resolved to this consumer id. + Consumer(String), + /// No KV template configured, or the key is absent from the store. + Miss, + /// The template is site-scoped (``) and [`Request::vhost_id`] is + /// `None` — the request matched no virtual host, so there is no tenant + /// whose key map could be consulted (ephpm#390). + NoTenant, +} + /// Constant-time byte-slice equality. Wraps [`subtle::ConstantTimeEq`] so the /// comparison does not short-circuit on the first differing byte (unequal /// lengths still return `false` fast, leaking only length). This is the helper @@ -159,12 +222,41 @@ impl ApiKey { /// Look the presented key up in the KV store via `kv_key_template`. The /// stored value (UTF-8, non-empty) is the consumer id. - fn match_kv(&self, req: &Request<'_>, presented: &str) -> Option { - let template = self.kv_key_template.as_ref()?; - let lookup = template.replace(KEY_PLACEHOLDER, presented); - let value = req.host().kv_get(&lookup)?; - let consumer = String::from_utf8(value).ok()?; - (!consumer.is_empty()).then_some(consumer) + /// + /// Two deliberate choices, both explained at length in the module docs: + /// + /// * the lookup uses `kv_get_global`, so the credential map lives in the + /// process-wide store where no tenant's PHP can write it; + /// * a ``-scoped template resolves the site component from + /// `req.vhost_id()` — the canonical site key — and **fails closed** when + /// there is no tenant, instead of falling back to the `Host` header. + fn match_kv(&self, req: &Request<'_>, presented: &str) -> KvLookup { + let Some(template) = self.kv_key_template.as_ref() else { + return KvLookup::Miss; + }; + let lookup = if template.contains(SITE_PLACEHOLDER) { + // Fail closed: no tenant identity, no per-tenant key map. Never + // substitute `req.http_host()` here — that is client input, and + // accepting it would let a caller pick which tenant's key map its + // credential is checked against. + let Some(site) = req.vhost_id() else { + return KvLookup::NoTenant; + }; + // Site first, key second, and the order is load-bearing: the + // presented key is client input, so substituting it first would let + // a caller inject a literal `` that the next `replace` then + // expanded. This way anything the client sends stays inert. + template.replace(SITE_PLACEHOLDER, site).replace(KEY_PLACEHOLDER, presented) + } else { + template.replace(KEY_PLACEHOLDER, presented) + }; + let Some(value) = req.host().kv_get_global(&lookup) else { + return KvLookup::Miss; + }; + match String::from_utf8(value) { + Ok(consumer) if !consumer.is_empty() => KvLookup::Consumer(consumer), + _ => KvLookup::Miss, + } } /// Admit the request, injecting the consumer id for PHP (mirrors how `jwt` @@ -237,10 +329,11 @@ impl Middleware for ApiKey { if let Some(consumer) = self.match_static(key.as_bytes()) { return self.grant(consumer); } - if let Some(consumer) = self.match_kv(req, &key) { - return self.grant(&consumer); + match self.match_kv(req, &key) { + KvLookup::Consumer(consumer) => self.grant(&consumer), + KvLookup::NoTenant => self.unauthorized("unknown host"), + KvLookup::Miss => self.unauthorized("invalid api key"), } - self.unauthorized("invalid api key") } } @@ -265,14 +358,22 @@ mod tests { ApiKey::init(&config).expect("init") } - /// Invoke with headers and an optional query string against a fresh ctx. - fn invoke_q(mw: &ApiKey, query: &str, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new("GET", "/api/x", query, "203.0.113.9", "example.test", headers); + /// Invoke against a fresh ctx bound to `site` — the fifth `RequestCtx` + /// argument is the request's **canonical site key** since ABI minor 3, and + /// the empty string is how the host says "this request matched no virtual + /// host" (the C accessor turns it into a NULL, so `vhost_id()` is `None`). + fn invoke_on(mw: &ApiKey, site: &str, query: &str, headers: &[(String, String)]) -> Response { + let ctx = RequestCtx::new("GET", "/api/x", query, "203.0.113.9", site, headers); // SAFETY: `ctx` outlives the view; host_table() is 'static. let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; mw.invoke(&req) } + /// Invoke with headers and an optional query string against a fresh ctx. + fn invoke_q(mw: &ApiKey, query: &str, headers: &[(String, String)]) -> Response { + invoke_on(mw, "example", query, headers) + } + fn invoke(mw: &ApiKey, headers: &[(String, String)]) -> Response { invoke_q(mw, "", headers) } @@ -283,14 +384,27 @@ mod tests { /// Wire a real in-memory Store into the host table (first call wins; all /// tests in this binary share it) and seed one `apikey:*` entry via the - /// host's own `kv_set`. + /// host's own `kv_set_global`. + /// + /// **Every test must seed key names no other test uses.** The store is + /// process-wide and the tests run in parallel; `Store::set_local` removes + /// the old entry before inserting the new one, so two tests writing the + /// *same* key leave a window in which a third read sees a miss. Isolating + /// the key names removes the interference rather than papering over it. fn setup_kv_with(entries: &[(&str, &str)]) { set_kv_store(&ephpm_kv::store::Store::new(ephpm_kv::store::StoreConfig::default())); let ctx = RequestCtx::new("GET", "/", "", "127.0.0.1", "seed", &[]); // SAFETY: `ctx` outlives the view; host_table() is 'static. let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; for (k, v) in entries { - assert!(req.host().kv_set(k, v.as_bytes(), 0), "seed kv_set failed for {k}"); + // `kv_set_global` on purpose: this seeds the PROCESS-GLOBAL store, + // which is the one `match_kv` reads. (In-process there is no site + // scope active here either way, but naming the slot keeps the test + // honest about which store the module depends on.) + assert!( + req.host().kv_set_global(k, v.as_bytes(), 0), + "seed kv_set_global failed for {k}", + ); } } @@ -434,4 +548,76 @@ mod tests { Some("from-kv"), ); } + + /// ePHPm #390 / #376. A ``-scoped template gives each tenant its own + /// key map, keyed by the CANONICAL site key — so the same presented key is + /// a different credential on a different vhost, and neither tenant can + /// spend the other's. + #[test] + fn a_site_scoped_template_keys_the_map_per_tenant() { + setup_kv_with(&[ + ("apikey:blog:shared-secret", "blog-consumer"), + ("apikey:shop:shared-secret", "shop-consumer"), + ("apikey:blog:blog-only", "blog-consumer"), + ]); + let mw = api_key(serde_json::json!({ "kv_key_template": "apikey::" })); + + let key = hdr("X-Api-Key", "shared-secret"); + assert_eq!( + consumer_header(&invoke_on(&mw, "blog", "", &key)).as_deref(), + Some("blog-consumer"), + ); + assert_eq!( + consumer_header(&invoke_on(&mw, "shop", "", &key)).as_deref(), + Some("shop-consumer"), + ); + + // A key that only exists in `blog`'s map is not a credential on `shop`. + let blog_only = hdr("X-Api-Key", "blog-only"); + assert_eq!( + consumer_header(&invoke_on(&mw, "blog", "", &blog_only)).as_deref(), + Some("blog-consumer"), + ); + assert_401(&invoke_on(&mw, "shop", "", &blog_only), "invalid api key"); + } + + /// The fail-closed half of ePHPm #390: a request that matched no virtual + /// host has no tenant identity (`vhost_id()` is `None`), and a site-scoped + /// gate must deny rather than invent one from the `Host` header. + #[test] + fn a_site_scoped_template_fails_closed_on_an_unmatched_host() { + setup_kv_with(&[ + ("apikey:blog:closed-secret", "blog-consumer"), + ("apikey::closed-secret", "nope"), + ]); + let mw = api_key(serde_json::json!({ "kv_key_template": "apikey::" })); + + // Sanity: the credential itself is good on the vhost that owns it. + assert_eq!( + consumer_header(&invoke_on(&mw, "blog", "", &hdr("X-Api-Key", "closed-secret"))) + .as_deref(), + Some("blog-consumer"), + ); + // Empty site key == no vhost matched == `vhost_id() == None`. Denied, + // and denied as "unknown host" — the gate never reached the store. + // Note `apikey::closed-secret` IS seeded: an empty site component is + // not a bucket this can fall into, it is a refusal to look at all. + assert_401(&invoke_on(&mw, "", "", &hdr("X-Api-Key", "closed-secret")), "unknown host"); + } + + /// A template with no `` stays node-wide, and is unaffected by which + /// vhost is serving — the pre-minor-3 shape, kept working. + #[test] + fn a_template_without_site_is_node_wide() { + setup_kv_with(&[("apikey:node-wide-key", "node-wide-consumer")]); + let mw = api_key(serde_json::json!({ "kv_key_template": "apikey:" })); + for site in ["blog", "shop", ""] { + assert_eq!( + consumer_header(&invoke_on(&mw, site, "", &hdr("X-Api-Key", "node-wide-key"))) + .as_deref(), + Some("node-wide-consumer"), + "site {site:?} should reach the node-wide key map", + ); + } + } } diff --git a/crates/ephpm-middleware-redirect/src/lib.rs b/crates/ephpm-middleware-redirect/src/lib.rs index d5252f0..ed9ad3e 100644 --- a/crates/ephpm-middleware-redirect/src/lib.rs +++ b/crates/ephpm-middleware-redirect/src/lib.rs @@ -27,17 +27,42 @@ //! | `status` (integer) | `308` | redirect status — `301` or `308`; `308` preserves the request method | //! | `forwarded_proto_header` (string) | `"X-Forwarded-Proto"` | header the current scheme is derived from | //! -//! **Scheme derivation.** The v1 middleware ABI exposes no request scheme or -//! "is secure" flag, so the current scheme is read from +//! **Scheme derivation.** The current scheme is read from //! `forwarded_proto_header` (default `X-Forwarded-Proto`); a request with no //! such header is treated as `http`. Behind a TLS-terminating proxy the proxy //! **must** set that header, or `force_https` would redirect an //! already-secure request and loop — the same requirement nginx/Traefik place //! on the operator. //! +//! ABI minor 2 added `req.scheme()` / `req.is_secure()`, which report the +//! scheme of the connection **as ePHPm terminated it**. That is the better +//! source when ePHPm itself is the TLS endpoint, and the wrong one when a proxy +//! in front of it terminated TLS and forwarded cleartext — where the connection +//! really is `http` and only the forwarded header knows better. This example +//! keeps the header-derived form so it works in both topologies; a deployment +//! that terminates TLS in ePHPm can simplify it to `req.is_secure()` and drop +//! the `forwarded_proto_header` knob (and with it the trust assumption). +//! //! **Scope.** Config is per-mount (there is no per-vhost config idiom in the //! ABI). Use `host_map` to canonicalize several hosts from one mount; the //! request's own `Host` header is what every rule is computed against. +//! +//! **Why `Host`, and not `req.vhost_id()`.** This module is the one place in +//! this repo where reading the client's `Host` is the *correct* choice, so it +//! is worth being explicit about the distinction ABI minor 3 draws +//! ([ephpm#390](https://github.com/ephpm/ephpm/issues/390)): +//! +//! * `req.vhost_id()` is the **tenant identity** — the canonical site key the +//! router resolved, `None` when the request matched no virtual host. Use it +//! for anything that decides *policy* (which credentials apply, whose budget +//! is spent, whose data is read). +//! * `req.header("Host")` / `req.http_host()` is **what the client asked for**. +//! A canonicalizing redirect exists precisely to rewrite that, so it has to +//! read it — and it must not be treated as a tenant identity. +//! +//! This module deliberately uses the raw header rather than the normalized +//! `req.http_host()` accessor (minor 2), because the `Location` it builds has +//! to preserve the request's port, which the normalized form strips. use ephpm_middleware::{Middleware, Request, Response}; From 79ad4fc3f845f2479bbb3506548ccfb07a6a42e6 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Wed, 2 Sep 2026 17:28:56 -0700 Subject: [PATCH 2/2] Re-pin the ABI crate to v0.8.9 (ephpm#448 as released) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous rev `21a7c8a7` was ephpm#448's PR-branch head, which never landed on main: #448 was squash-merged as `691e6fef`, so the old pin is unreachable once the branch is deleted. Re-pinned to `c2774ab6` — the commit tagged v0.8.9, the first published ePHPm release carrying minor 3. Pinning the tag's commit rather than the tag name keeps the pin immutable; pinning the release rather than the raw merge commit means these examples build against an ABI that shipped in a host binary operators can actually run. No API drift between the two revs: the only changes to the consumed crates are documentation plus a `!Send` marker on the host-side `SiteKvScope`, which nothing here uses. Manifests are byte-identical, so the lockfile needed only the rev rewrite. --- Cargo.lock | 6 +++--- Cargo.toml | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d23d32..107296b 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=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" +source = "git+https://github.com/ephpm/ephpm.git?rev=c2774ab61b9d25b835282b2f70281fb45e11334d#c2774ab61b9d25b835282b2f70281fb45e11334d" 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=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" +source = "git+https://github.com/ephpm/ephpm.git?rev=c2774ab61b9d25b835282b2f70281fb45e11334d#c2774ab61b9d25b835282b2f70281fb45e11334d" 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=21a7c8a7832b62d0ab8af960931a33e8e9778784#21a7c8a7832b62d0ab8af960931a33e8e9778784" +source = "git+https://github.com/ephpm/ephpm.git?rev=c2774ab61b9d25b835282b2f70281fb45e11334d#c2774ab61b9d25b835282b2f70281fb45e11334d" dependencies = [ "ephpm-kv", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index d336c90..24ae586 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,14 @@ repository = "https://github.com/ephpm/middleware-examples" # rebuild these examples against a newer host, replace `rev` and run # `cargo update`. # -# Pinned at ePHPm main `21a7c8a7832b62d0ab8af960931a33e8e9778784` — the merge of -# ephpm#448, which took the ABI to **minor 3**. Two things these examples care -# about landed there (ephpm#449 tracks this bump): +# Pinned at ePHPm `c2774ab61b9d25b835282b2f70281fb45e11334d` — the commit +# tagged **v0.8.9**, the first published release carrying ephpm#448 (merged to +# main as `691e6fefa702e8f2a695af85377164866eedfbc5`), which took the ABI to +# **minor 3**. The rev is the released tag's commit rather than the tag name so +# the pin is immutable, and rather than the raw merge commit so these examples +# are provably built against an ABI that actually shipped in a host binary +# operators can run. Two things these examples care about landed there +# (ephpm#449 tracks this bump): # # * `Request::vhost_id()` returns `Option<&str>`: the router's CANONICAL SITE # KEY, and `None` for a host that matched no virtual host — where it used to @@ -39,12 +44,12 @@ repository = "https://github.com/ephpm/middleware-examples" # The previous pin was `e63284838d07d348e2155e76916daaf9782c012b` (#408, the # response-phase hook the header-transform example builds on); everything from # that rev is still present, since ABI growth is additive within major 1. -ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "21a7c8a7832b62d0ab8af960931a33e8e9778784" } +ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "c2774ab61b9d25b835282b2f70281fb45e11334d" } # Test-only: the same embedded KV store the host wires into the middleware host # table, so the api-key and ratelimit examples exercise the real KV path in # their unit tests. 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 = "21a7c8a7832b62d0ab8af960931a33e8e9778784" } +ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "c2774ab61b9d25b835282b2f70281fb45e11334d" } # Every example parses its config out of a `serde_json::Value`. serde_json = "1"