tensor_wasm_api/middleware.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Tower middleware helpers: timeouts, concurrency limits, body limits,
5//! authentication, tenant scoping, and tracing spans.
6//!
7//! Each helper returns a single `tower` layer (or middleware function) that
8//! the server module composes into the axum router. Keeping the helpers thin
9//! makes them easy to reuse in integration tests and benchmarks where a custom
10//! stack is desired.
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::Duration;
15
16use axum::extract::Request;
17use axum::http::{HeaderMap, StatusCode};
18use axum::middleware::Next;
19use axum::response::{IntoResponse, Response};
20use axum::Json;
21use serde_json::json;
22use subtle::ConstantTimeEq;
23use tensor_wasm_core::types::TenantId;
24use tower::limit::ConcurrencyLimitLayer;
25use tower_http::classify::{ServerErrorsAsFailures, SharedClassifier};
26use tower_http::cors::{AllowOrigin, CorsLayer};
27use tower_http::timeout::TimeoutLayer;
28use tower_http::trace::TraceLayer;
29
30use crate::token_scope::{parse_tokens_env, TokenScope};
31
32/// Default per-request timeout used by [`crate::server::build_router`].
33pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
34
35/// Default process-wide cap on in-flight requests. Retained for callers that
36/// want a single number; production deployments should prefer the per-route
37/// caps below.
38pub const DEFAULT_CONCURRENCY_LIMIT: usize = 64;
39
40/// Per-route concurrency caps (api S-26). A single global semaphore lets a
41/// probe storm starve `/invoke`; per-route caps isolate the budgets.
42///
43/// Probe routes (`/healthz`, `/metrics`) get a generous budget because they
44/// are cheap and a k8s deployment may have many replicas all scraping at
45/// once. Invoke is the heaviest path — keep it tight. Reads and writes get
46/// asymmetric caps because writes tend to compile Wasm and allocate engine
47/// resources.
48pub const PROBE_CONCURRENCY_LIMIT: usize = 256;
49/// Concurrent `/invoke` ceiling. Tighter than the default because invokes
50/// hold a Wasmtime instance lock across `call_async`.
51pub const INVOKE_CONCURRENCY_LIMIT: usize = 32;
52/// Concurrent read-route ceiling (GETs that are not probes).
53pub const READ_CONCURRENCY_LIMIT: usize = 64;
54/// Concurrent write-route ceiling (POST/PUT/DELETE excluding invoke).
55pub const WRITE_CONCURRENCY_LIMIT: usize = 16;
56
57/// Maximum allowed inbound request body size, in bytes. 64 MiB.
58///
59/// Documented in `API.md` under "Request limits". Requests larger than this
60/// are rejected with `413 Payload Too Large` by axum's
61/// [`DefaultBodyLimit`](axum::extract::DefaultBodyLimit) at extract time
62/// (i.e., when a handler reads the body via `Bytes`, `Json`, etc.). We use
63/// axum's native `DefaultBodyLimit::max` rather than
64/// `tower_http::limit::RequestBodyLimitLayer`: the tower-http layer rewraps
65/// the request body in `Limited<Body>`, which breaks composition with
66/// `axum::middleware::from_fn` (bearer auth, tenant scope) downstream, and
67/// `DefaultBodyLimit::max` gives the same 413 contract without the rewrap.
68pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
69
70/// Environment variable carrying a comma-separated allowlist of bearer
71/// tokens accepted by [`bearer_auth`]. Empty / unset = dev mode pass-through
72/// (but only when [`ENV_ALLOW_DEV_MODE`] explicitly opts in — see below).
73pub const ENV_API_TOKENS: &str = "TENSOR_WASM_API_TOKENS";
74
75/// Environment variable that explicitly opts the gateway into dev mode
76/// (auth disabled, every request passes through with `AuthContext::dev`).
77///
78/// Accepted truthy values are `"1"` and `"true"` (case-insensitive, trimmed).
79/// Any other value — including unset — leaves dev mode *disabled*.
80///
81/// Closes the M4 finding: an empty / unset [`ENV_API_TOKENS`] used to make
82/// [`bearer_auth`] fail *open* (wildcard pass-through), so a deployment that
83/// merely forgot to populate the allowlist silently accepted every request.
84/// We now fail *closed*: when no tokens are configured AND this opt-in is not
85/// set, [`bearer_auth`] rejects every request with `401 Unauthorized`. The
86/// startup `warn!` in [`AuthConfig::from_env`] is preserved so an operator who
87/// genuinely wants dev mode still sees the loud signal — they just have to
88/// acknowledge it by setting this variable.
89///
90/// Only [`AuthConfig::from_env`] consults this variable. Configs built
91/// programmatically via [`AuthConfig::from_tokens`] / [`AuthConfig::from_scopes`]
92/// / [`AuthConfig::default`] are treated as an explicit in-process opt-in
93/// (they cannot be poisoned by hostile ambient environment), so they preserve
94/// the historical pass-through behaviour for tests and embedders.
95pub const ENV_ALLOW_DEV_MODE: &str = "TENSOR_WASM_API_ALLOW_DEV_MODE";
96
97/// Environment variable carrying a comma-separated allowlist of bearer
98/// tokens that are additionally permitted to call `POST /kernels` (the
99/// kernel-publish scope — see [`KernelPublishTokens`]).
100///
101/// Empty / unset = no token is permitted to publish. In that case
102/// `POST /kernels` returns `403 kernel_publish_scope_required`. The
103/// `/kernels` GET routes are unaffected — every authenticated tenant may
104/// list and resolve.
105///
106/// Each entry must be a raw bearer token string that also appears in
107/// [`ENV_API_TOKENS`] (otherwise [`bearer_auth`] would 401 the request
108/// before scope evaluation runs). Allowlisting a token here that is not
109/// in `TENSOR_WASM_API_TOKENS` is harmless — the request never reaches
110/// the scope gate — but logically nonsensical.
111///
112/// Closes the T1 security finding: previously `POST /kernels` accepted
113/// any allowlisted bearer (or any caller in dev mode), letting a
114/// tenant-1 token publish kernels that every other tenant could then
115/// resolve. The publish gate is now distinct from the API allowlist so
116/// operators can hand out tenant tokens widely while restricting
117/// publish authority to a small set of trusted clients.
118pub const ENV_KERNEL_PUBLISH_TOKENS: &str = "TENSOR_WASM_API_KERNEL_PUBLISH_TOKENS";
119
120/// Maximum byte length permitted for an inbound `Authorization` header
121/// value before [`bearer_auth`] will even attempt to parse it.
122///
123/// Hyper's default cap on the combined header block sits around 16 KiB,
124/// so an attacker can still send a single `Authorization: Bearer <huge>`
125/// value of several KiB. The downstream constant-time comparison in
126/// [`AuthConfig::scope_for`] is `O(token_len)` per allowlisted token,
127/// so unbounded oversized values let a hostile client burn CPU at
128/// roughly `O(num_tokens * token_len)` per request. Capping the value
129/// length here (1 KiB) keeps that cost flat — any legitimate bearer
130/// token in production is well under this limit (JWTs are typically
131/// 500–800 bytes, opaque tokens are far smaller).
132///
133/// A value longer than this returns `401 Unauthorized` with
134/// `kind: "invalid_auth"`. We deliberately reject as `401` rather than
135/// `400` so the response shape stays uniform with other auth failures
136/// (missing header, mismatched token) and an attacker cannot use the
137/// status code to probe the size cap.
138pub const MAX_AUTH_HEADER_BYTES: usize = 1024;
139
140/// Environment variable that, when set to `1`, makes the `X-TensorWasm-Tenant`
141/// header mandatory. Otherwise its absence defaults to tenant `0`.
142pub const ENV_REQUIRE_TENANT: &str = "TENSOR_WASM_API_REQUIRE_TENANT";
143
144/// Name of the header used to scope a request to a tenant.
145pub const HEADER_TENANT: &str = "X-TensorWasm-Tenant";
146
147/// Environment variable that, when set, restricts the set of `Host` header
148/// values the server will accept. Comma-separated list of authority strings
149/// (e.g. `api.example.com,api2.example.com:8443`). Unset = accept any
150/// `Host`, which is the previous behaviour but is unsafe behind a layered
151/// proxy that may pass arbitrary `Host` values through.
152///
153/// Closes api S-30 (lack of Host validation). The check rejects requests
154/// whose `Host` is not in the allowlist with `400 Bad Request`.
155pub const ENV_TRUSTED_HOSTS: &str = "TENSOR_WASM_API_TRUSTED_HOSTS";
156
157/// Parsed `Host` allowlist used by [`host_validate`].
158///
159/// Closes api S-30 (lack of Host validation). The previous implementation
160/// cached the parsed env value in a process-wide `OnceLock`, which made
161/// tests that wanted to vary the allowlist unable to do so (the first test
162/// to touch the cell froze it for every later test in the same process).
163///
164/// Now the allowlist travels through `axum::Extension<TrustedHosts>`:
165/// [`crate::server::build_router_with_audit`] inserts a `from_env()` value
166/// at build time; tests can override by inserting a different value into
167/// the router extensions. Tests that bypass the server builder still get
168/// the env-var fallback (cached per-process in a private `OnceLock` so the
169/// per-request cost stays zero), but an explicit extension always wins.
170///
171/// Precedence: **explicit `axum::Extension<TrustedHosts>` > env-var
172/// fallback (`TENSOR_WASM_API_TRUSTED_HOSTS`)**.
173#[derive(Debug, Clone, Default)]
174pub struct TrustedHosts(Arc<Vec<String>>);
175
176impl TrustedHosts {
177 /// Parse the allowlist from [`ENV_TRUSTED_HOSTS`]. Splits on `,`,
178 /// trims surrounding whitespace, drops empty entries, and lowercases
179 /// each remaining entry for case-insensitive matching. Unset / empty
180 /// env var yields an empty list (= [`Self::allow_all`]).
181 pub fn from_env() -> Self {
182 let raw = std::env::var(ENV_TRUSTED_HOSTS).unwrap_or_default();
183 Self::from_raw(&raw)
184 }
185
186 /// Helper: parse a comma-separated string as if it were the env
187 /// variable. Public for the explicit-construction path used by tests.
188 pub fn from_raw(raw: &str) -> Self {
189 let parsed: Vec<String> = raw
190 .split(',')
191 .map(|s| s.trim().to_ascii_lowercase())
192 .filter(|s| !s.is_empty())
193 .collect();
194 Self(Arc::new(parsed))
195 }
196
197 /// Explicit "no allowlist" constructor — every Host value is admitted
198 /// (the legacy default when the env var is unset).
199 pub fn allow_all() -> Self {
200 Self(Arc::new(Vec::new()))
201 }
202
203 /// Construct from an iterator of allowlist entries. Entries are
204 /// lowercased on insertion so case-insensitive matching in
205 /// [`Self::contains`] is just a byte comparison.
206 pub fn from_hosts<I, S>(iter: I) -> Self
207 where
208 I: IntoIterator<Item = S>,
209 S: Into<String>,
210 {
211 let parsed: Vec<String> = iter
212 .into_iter()
213 .map(|s| s.into().trim().to_ascii_lowercase())
214 .filter(|s| !s.is_empty())
215 .collect();
216 Self(Arc::new(parsed))
217 }
218
219 /// `true` when no entries are configured — every host is admitted.
220 pub fn is_empty(&self) -> bool {
221 self.0.is_empty()
222 }
223
224 /// `true` when the supplied host (raw value from `Host:` or
225 /// `:authority`) matches one of the allowlist entries.
226 ///
227 /// Matching rules:
228 ///
229 /// * **Case-insensitive exact match** on the supplied host.
230 /// * If the supplied host carries a default-port suffix (`:80` or
231 /// `:443`) and no allowlist entry contains a `:`, the port is
232 /// stripped before comparison. This lets operators list bare
233 /// hostnames (`api.example.com`) and still admit clients that
234 /// include the default port in the `Host` header. If any
235 /// allowlist entry contains a port, we do an exact match on the
236 /// full `host:port` string — the operator chose to be specific.
237 pub fn contains(&self, host: &str) -> bool {
238 if self.0.is_empty() {
239 return true;
240 }
241 let host_lc = host.trim().to_ascii_lowercase();
242 if self.0.contains(&host_lc) {
243 return true;
244 }
245 // Default-port strip: only apply when no allowlist entry carries
246 // a port (otherwise the operator's port-bound entry must match
247 // exactly).
248 let allow_has_port = self.0.iter().any(|a| a.contains(':'));
249 if allow_has_port {
250 return false;
251 }
252 if let Some(stripped) = strip_default_port(&host_lc) {
253 return self.0.iter().any(|allowed| allowed == stripped);
254 }
255 false
256 }
257}
258
259/// Strip a trailing `:80` or `:443` from a (already-lowercased) host
260/// string. Returns `None` if no default-port suffix is present.
261fn strip_default_port(host_lc: &str) -> Option<&str> {
262 for suffix in [":443", ":80"] {
263 if let Some(stripped) = host_lc.strip_suffix(suffix) {
264 return Some(stripped);
265 }
266 }
267 None
268}
269
270/// Per-process cached env-var fallback for [`host_validate`] when no
271/// `axum::Extension<TrustedHosts>` is present. Tests that drive the
272/// middleware through the server builder always get an explicit
273/// extension and never touch this; tests that bypass the builder
274/// (e.g. wrap `host_validate` with `axum::middleware::from_fn` directly)
275/// see the env value parsed once.
276fn env_trusted_hosts_fallback() -> TrustedHosts {
277 static ONCE: std::sync::OnceLock<TrustedHosts> = std::sync::OnceLock::new();
278 ONCE.get_or_init(TrustedHosts::from_env).clone()
279}
280
281/// Allowlist of bearer tokens permitted to call `POST /kernels`.
282///
283/// Internally stores the [`crate::rate_limit::TokenId`] of each
284/// allowlisted bearer (the `xxhash64` digest used elsewhere for rate
285/// limiting), NOT the raw token string. Keeping `TokenId`s here means
286/// the value can flow through `axum::Extension` and tracing without
287/// risking the raw secret leaking into a span attribute or audit
288/// record, and `allows()` reduces to a hash-set lookup.
289///
290/// The list is empty by default — that is the safe posture: with no
291/// publish-scoped tokens configured, `POST /kernels` returns
292/// `403 kernel_publish_scope_required`. Dev mode (empty
293/// `TENSOR_WASM_API_TOKENS`) is even stricter: the publish handler
294/// rejects outright with `kernel_publish_disabled_in_dev_mode` to deny
295/// the unauthenticated-attacker path the security review flagged.
296///
297/// Sourced from [`ENV_KERNEL_PUBLISH_TOKENS`] at server startup; tests
298/// drive it directly via [`KernelPublishTokens::from_tokens`] so they
299/// do not poison the process environment.
300#[derive(Debug, Clone, Default)]
301pub struct KernelPublishTokens {
302 /// Stable token identifiers permitted to publish kernels. Stored as
303 /// `Arc<HashSet<...>>` so cheap clones into request extensions and
304 /// per-request reads stay allocation-free.
305 token_ids: Arc<std::collections::HashSet<crate::rate_limit::TokenId>>,
306}
307
308impl KernelPublishTokens {
309 /// Parse the allowlist from [`ENV_KERNEL_PUBLISH_TOKENS`]. Splits on
310 /// `,`, trims surrounding whitespace, drops empty entries, hashes
311 /// each remaining entry into a [`crate::rate_limit::TokenId`]. Unset
312 /// / empty env var yields the empty allowlist — every `POST
313 /// /kernels` is rejected with `kernel_publish_scope_required`.
314 pub fn from_env() -> Self {
315 let raw = std::env::var(ENV_KERNEL_PUBLISH_TOKENS).unwrap_or_default();
316 Self::from_raw(&raw)
317 }
318
319 /// Parse a comma-separated string as if it were the env variable.
320 /// Public for the explicit-construction path used by tests.
321 pub fn from_raw(raw: &str) -> Self {
322 let token_ids = raw
323 .split(',')
324 .map(str::trim)
325 .filter(|s| !s.is_empty())
326 .map(crate::rate_limit::TokenId::from_bearer)
327 .collect();
328 Self {
329 token_ids: Arc::new(token_ids),
330 }
331 }
332
333 /// Construct from an iterator of raw bearer-token strings. Used by
334 /// integration tests that drive the publish-scope path directly.
335 pub fn from_tokens<I, S>(iter: I) -> Self
336 where
337 I: IntoIterator<Item = S>,
338 S: AsRef<str>,
339 {
340 let token_ids = iter
341 .into_iter()
342 .map(|s| crate::rate_limit::TokenId::from_bearer(s.as_ref()))
343 .collect();
344 Self {
345 token_ids: Arc::new(token_ids),
346 }
347 }
348
349 /// `true` when no entries are configured. With an empty allowlist
350 /// the kernel-publish gate denies every `POST /kernels` — the safe
351 /// default.
352 pub fn is_empty(&self) -> bool {
353 self.token_ids.is_empty()
354 }
355
356 /// `true` when `token_id` is permitted to publish kernels.
357 pub fn allows(&self, token_id: crate::rate_limit::TokenId) -> bool {
358 self.token_ids.contains(&token_id)
359 }
360}
361
362/// Middleware: reject requests whose `Host` header (or HTTP/2
363/// `:authority` pseudo-header) is not in the configured allowlist.
364///
365/// Source of truth for the allowlist:
366///
367/// 1. `axum::Extension<TrustedHosts>` if present on the request — the
368/// server builder inserts this from
369/// [`TrustedHosts::from_env`] at startup, and tests can override.
370/// 2. Otherwise, a per-process env-var fallback parsed once from
371/// `TENSOR_WASM_API_TRUSTED_HOSTS`.
372///
373/// Empty allowlist (no entries / env unset) = pass-through.
374///
375/// Host extraction order:
376///
377/// 1. `Host:` request header.
378/// 2. If absent, `req.uri().authority()` — the URI carries the HTTP/2
379/// `:authority` pseudo-header in `hyper`'s normalised request form.
380/// 3. If both absent and the allowlist is non-empty, respond `400`.
381///
382/// Closes api S-30. T19 perf re-order: layered OUTSIDE the trace layer
383/// (and the CORS layer) so hostile Host probes are rejected before any
384/// trace span is allocated or any `traceparent` propagator hop runs.
385/// Still layered BEFORE bearer_auth so an attacker probing for valid
386/// hosts cannot also probe for valid tokens. The probe router inherits
387/// this gate too; operators with split-Host probes can simply omit the
388/// env var. Rejected requests do NOT get a trace span and are NOT
389/// counted by the HTTP metrics layer (which sits inside the trace
390/// layer) — an intentional tradeoff so a hostile Host probe storm
391/// cannot burn either trace budget or metric cardinality. See the
392/// comment block in `build_router_full`.
393pub async fn host_validate(req: Request, next: Next) -> Response {
394 let allow = req
395 .extensions()
396 .get::<TrustedHosts>()
397 .cloned()
398 .unwrap_or_else(env_trusted_hosts_fallback);
399 if allow.is_empty() {
400 return next.run(req).await;
401 }
402 // 1) Try the `Host:` header first (HTTP/1.1 canonical path).
403 let host_header = req
404 .headers()
405 .get(axum::http::header::HOST)
406 .and_then(|h| h.to_str().ok())
407 .map(|s| s.to_owned());
408 // 2) Fall back to the URI authority (HTTP/2 `:authority` pseudo-header
409 // surfaces here in hyper's normalised request form).
410 let authority = req.uri().authority().map(|a| a.as_str().to_owned());
411 let host = host_header.or(authority);
412
413 match host {
414 Some(h) if allow.contains(&h) => next.run(req).await,
415 _ => envelope(
416 StatusCode::BAD_REQUEST,
417 "bad_request",
418 "Host header missing or not in TENSOR_WASM_API_TRUSTED_HOSTS allowlist",
419 ),
420 }
421}
422
423/// Build a per-request timeout layer.
424///
425/// Requests that exceed `d` are aborted with `408 Request Timeout`.
426pub fn timeout_layer(d: Duration) -> TimeoutLayer {
427 TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, d)
428}
429
430/// Build the default HTTP tracing layer.
431///
432/// Emits a `tracing` span per request capturing method, URI, and response
433/// status. The classifier treats `5xx` responses as failures.
434pub fn trace_layer() -> TraceLayer<SharedClassifier<ServerErrorsAsFailures>> {
435 TraceLayer::new_for_http()
436}
437
438/// Build a process-wide concurrency limit layer that allows at most `max`
439/// in-flight requests.
440pub fn concurrency_limit_layer(max: usize) -> ConcurrencyLimitLayer {
441 ConcurrencyLimitLayer::new(max)
442}
443
444/// Build the global request-body size cap (64 MiB by default).
445///
446/// Returns `413 Payload Too Large` for any body that exceeds `max_bytes`
447/// when a handler tries to extract the body. See [`MAX_REQUEST_BODY_BYTES`]
448/// for the rationale on using axum's native limit rather than tower-http's.
449pub fn body_limit_layer(max_bytes: usize) -> axum::extract::DefaultBodyLimit {
450 axum::extract::DefaultBodyLimit::max(max_bytes)
451}
452
453/// Environment variable carrying a comma-separated allowlist of origins
454/// permitted for cross-origin requests. Empty / unset = reject all
455/// cross-origin requests.
456pub const ENV_CORS_ALLOWED_ORIGINS: &str = "TENSOR_WASM_API_CORS_ALLOWED_ORIGINS";
457
458/// HTTP headers permitted on cross-origin requests. Covers the standard
459/// `Authorization` and `Content-Type`, the TensorWasm tenant header used to
460/// scope per-tenant calls, and the W3C `traceparent` header so browser
461/// callers can stitch their own trace context into the gateway's spans.
462const CORS_ALLOWED_HEADERS: &[&str] = &[
463 "authorization",
464 "content-type",
465 "x-tensorwasm-tenant",
466 "traceparent",
467];
468
469/// Cross-origin policy snapshot loaded from the process environment.
470///
471/// `allowed_origins` is the explicit allowlist of cross-origin browser
472/// callers that may reach the API. The default is empty — i.e.
473/// **cross-origin requests are rejected** until the operator widens the
474/// allowlist. This matches the gateway's other security defaults
475/// (`TENSOR_WASM_API_TOKENS` dev mode is the only opt-out).
476///
477/// To widen, list one origin per entry exactly as the browser sends the
478/// `Origin` header (scheme + host + optional port), comma-separated:
479///
480/// ```text
481/// TENSOR_WASM_API_CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
482/// ```
483#[derive(Debug, Clone, Default)]
484pub struct CorsConfig {
485 /// Origins permitted for cross-origin requests. Empty = reject all.
486 pub allowed_origins: Vec<String>,
487}
488
489impl CorsConfig {
490 /// Load the allowlist from `$TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`.
491 /// Unset or empty = reject all cross-origin requests.
492 pub fn from_env() -> Self {
493 let raw = std::env::var(ENV_CORS_ALLOWED_ORIGINS).unwrap_or_default();
494 let allowed_origins: Vec<String> = raw
495 .split(',')
496 .map(|s| s.trim().to_owned())
497 .filter(|s| !s.is_empty())
498 .collect();
499 Self { allowed_origins }
500 }
501
502 /// Construct directly from an explicit list of origins. The empty list
503 /// yields the safe default (no cross-origin requests admitted).
504 pub fn from_origins<I, S>(iter: I) -> Self
505 where
506 I: IntoIterator<Item = S>,
507 S: Into<String>,
508 {
509 Self {
510 allowed_origins: iter.into_iter().map(Into::into).collect(),
511 }
512 }
513}
514
515/// Build the CORS layer for the gateway router.
516///
517/// * Empty allowlist (`cfg.allowed_origins.is_empty()`) → returns
518/// `CorsLayer::new()`, which sets no `Access-Control-Allow-Origin` header
519/// and therefore rejects every cross-origin request. This is the safe
520/// default for fresh installs — operators opt in by setting
521/// `TENSOR_WASM_API_CORS_ALLOWED_ORIGINS`.
522/// * Non-empty allowlist → returns a layer that admits exactly those
523/// origins (parsed back into `HeaderValue`s; unparseable entries are
524/// silently dropped — they were already rejected at startup by the
525/// bearer-auth allowlist parser's stricter sibling and would never match
526/// a real browser `Origin` header anyway).
527///
528/// The allowed methods (`GET`, `POST`, `DELETE`) and headers
529/// (`Authorization`, `Content-Type`, `X-TensorWasm-Tenant`, `Traceparent`)
530/// match the API's wire surface — see `API.md`.
531pub fn cors_layer(cfg: &CorsConfig) -> CorsLayer {
532 use axum::http::Method;
533 // Cover every method the gateway routes use: `GET` (healthz, metrics,
534 // job poll), `POST` (deploy, invoke, invoke-async), and `DELETE`
535 // (function tear-down).
536 let allowed_methods = [Method::GET, Method::POST, Method::DELETE];
537 let base = CorsLayer::new()
538 .allow_methods(allowed_methods)
539 .allow_headers(
540 CORS_ALLOWED_HEADERS
541 .iter()
542 .filter_map(|h| h.parse::<axum::http::HeaderName>().ok())
543 .collect::<Vec<_>>(),
544 );
545
546 if cfg.allowed_origins.is_empty() {
547 // No origins configured — `CorsLayer::new()` admits no origins, so
548 // no cross-origin browser caller will see an
549 // `Access-Control-Allow-Origin` header and the request is rejected
550 // by the browser's preflight check.
551 base
552 } else {
553 let parsed: Vec<axum::http::HeaderValue> = cfg
554 .allowed_origins
555 .iter()
556 .filter_map(|origin| origin.parse::<axum::http::HeaderValue>().ok())
557 .collect();
558 base.allow_origin(AllowOrigin::list(parsed))
559 }
560}
561
562/// Snapshot of authentication configuration loaded from the process
563/// environment at server start. Cloned cheaply into each request.
564///
565/// `scopes` is empty in dev mode (no `TENSOR_WASM_API_TOKENS` set or env
566/// empty), in which case [`bearer_auth`] passes every request through
567/// unchecked. Otherwise each allowlisted bearer token maps to the
568/// [`TokenScope`] that came out of [`parse_tokens_env`] at startup.
569#[derive(Debug, Clone)]
570pub struct AuthConfig {
571 /// Allowlisted bearer tokens → tenant scope. Empty = dev mode
572 /// (pass-through with startup warning).
573 pub scopes: Arc<HashMap<String, TokenScope>>,
574 /// Count of entries that used the legacy bare-token shape. The server
575 /// emits a single deprecation warning at startup if this is nonzero.
576 pub deprecated_count: usize,
577 /// Whether dev-mode pass-through is permitted when `scopes` is empty.
578 ///
579 /// Only meaningful in dev mode (`scopes.is_empty()`). When `false`,
580 /// [`bearer_auth`] fails *closed* and rejects every request with
581 /// `401 Unauthorized` rather than passing it through with
582 /// `AuthContext::dev`. Set from [`ENV_ALLOW_DEV_MODE`] by
583 /// [`AuthConfig::from_env`]; defaults to `true` for programmatic
584 /// constructors (see [`ENV_ALLOW_DEV_MODE`] for the rationale).
585 pub dev_mode_allowed: bool,
586}
587
588impl Default for AuthConfig {
589 /// An empty allowlist with dev mode *allowed*. Programmatic construction
590 /// is an explicit in-process opt-in, so it preserves the historical
591 /// pass-through behaviour (the [`ENV_ALLOW_DEV_MODE`] gate applies only to
592 /// the env-driven [`AuthConfig::from_env`] path).
593 fn default() -> Self {
594 Self {
595 scopes: Arc::new(HashMap::new()),
596 deprecated_count: 0,
597 dev_mode_allowed: true,
598 }
599 }
600}
601
602impl AuthConfig {
603 /// Load the allowlist from `$TENSOR_WASM_API_TOKENS`. Unset or empty
604 /// means "no auth" (dev mode). Logs a one-shot warning in dev mode and
605 /// a one-shot deprecation warning whenever any legacy bare entries were
606 /// observed.
607 pub fn from_env() -> Self {
608 let raw = std::env::var(ENV_API_TOKENS).unwrap_or_default();
609 let parsed = parse_tokens_env(&raw);
610 // M4: empty allowlist is only honoured as dev-mode pass-through when
611 // the operator explicitly opts in via ENV_ALLOW_DEV_MODE. Otherwise we
612 // fail closed (bearer_auth 401s every request). Accept `1` / `true`
613 // (case-insensitive, trimmed) as the truthy set; anything else (incl.
614 // unset) leaves dev mode disabled.
615 let dev_mode_allowed = std::env::var(ENV_ALLOW_DEV_MODE).is_ok_and(|v| {
616 let v = v.trim();
617 v == "1" || v.eq_ignore_ascii_case("true")
618 });
619 if parsed.token_scopes.is_empty() {
620 tracing::warn!(
621 target: "tensor_wasm_api::middleware",
622 env = ENV_API_TOKENS,
623 "TENSOR_WASM_API_TOKENS empty; API accepts all requests (dev mode)",
624 );
625 if !dev_mode_allowed {
626 tracing::warn!(
627 target: "tensor_wasm_api::middleware",
628 env = ENV_ALLOW_DEV_MODE,
629 "{} not set; refusing dev-mode pass-through — every request \
630 will be rejected with 401. Configure {} to enable bearer \
631 auth, or set {}=1 to explicitly acknowledge an open gateway",
632 ENV_ALLOW_DEV_MODE,
633 ENV_API_TOKENS,
634 ENV_ALLOW_DEV_MODE,
635 );
636 }
637 }
638 if parsed.deprecated_count > 0 {
639 tracing::warn!(
640 target: "tensor_wasm_api::middleware",
641 env = ENV_API_TOKENS,
642 count = parsed.deprecated_count,
643 "bare bearer tokens in {} are deprecated; switch to \
644 `token:tenant=...` (or `token:tenant=*` for the current \
645 wildcard behaviour) — bare entries are scheduled for \
646 removal in v1.0",
647 ENV_API_TOKENS,
648 );
649 }
650 Self {
651 scopes: Arc::new(parsed.token_scopes),
652 deprecated_count: parsed.deprecated_count,
653 dev_mode_allowed,
654 }
655 }
656
657 /// Construct directly from an explicit allowlist. Each token gets the
658 /// wildcard scope — preserves backwards-compatible behaviour for tests
659 /// that pre-date scoped tokens. For tests that need a non-wildcard
660 /// scope, build the map directly or use [`AuthConfig::from_scopes`].
661 pub fn from_tokens<I, S>(iter: I) -> Self
662 where
663 I: IntoIterator<Item = S>,
664 S: Into<String>,
665 {
666 let mut scopes: HashMap<String, TokenScope> = HashMap::new();
667 for s in iter {
668 scopes.insert(s.into(), TokenScope::all());
669 }
670 Self {
671 scopes: Arc::new(scopes),
672 deprecated_count: 0,
673 dev_mode_allowed: true,
674 }
675 }
676
677 /// Construct from an explicit token → scope map. Used by integration
678 /// tests that drive scoped-token paths directly.
679 pub fn from_scopes<I, S>(iter: I) -> Self
680 where
681 I: IntoIterator<Item = (S, TokenScope)>,
682 S: Into<String>,
683 {
684 let mut scopes: HashMap<String, TokenScope> = HashMap::new();
685 for (k, v) in iter {
686 scopes.insert(k.into(), v);
687 }
688 Self {
689 scopes: Arc::new(scopes),
690 deprecated_count: 0,
691 dev_mode_allowed: true,
692 }
693 }
694
695 /// `true` if the supplied bearer token is allowlisted.
696 ///
697 /// Uses a constant-time byte comparison against every allowlisted entry
698 /// so the time taken to reject a bad token does not leak which prefix
699 /// (if any) matched an allowlist entry. Delegates to `scope_for`.
700 pub fn accepts(&self, token: &str) -> bool {
701 self.scope_for(token).is_some()
702 }
703
704 /// Resolve `token` to its [`TokenScope`] if allowlisted.
705 ///
706 /// Iterates the full allowlist and uses [`subtle::ConstantTimeEq`] for
707 /// each entry rather than a `HashMap::get`. The hash-table lookup
708 /// short-circuits on hash mismatch and then bytes-eq matching entries,
709 /// which is timing-leakable for token discovery — an attacker can
710 /// measure how long the gateway took to reject a candidate token and
711 /// infer how close it got to a real entry. The loop runs over every
712 /// allowlist entry on every call, and we deliberately do NOT `break`
713 /// after a hit so the wall-clock cost is constant w.r.t. the matched
714 /// entry's position. Hashing still happens internally (`scopes` is an
715 /// `Arc<HashMap>` for cheap clones) but the lookup path is no longer
716 /// hash-keyed; the map is used purely as a `(token, scope)` store here.
717 pub fn scope_for(&self, token: &str) -> Option<&TokenScope> {
718 let mut found_scope: Option<&TokenScope> = None;
719 let token_bytes = token.as_bytes();
720 for (allow_token, scope) in self.scopes.iter() {
721 // ct_eq requires equal-length inputs; mismatched lengths cannot
722 // be equal so we skip them. The length itself is not secret —
723 // the operator's `TENSOR_WASM_API_TOKENS` allowlist is fixed at
724 // startup and its lengths are observable through other means.
725 if allow_token.len() == token_bytes.len()
726 && allow_token.as_bytes().ct_eq(token_bytes).into()
727 {
728 found_scope = Some(scope);
729 // Intentionally NOT `break` — keep iterating so the loop
730 // time is constant w.r.t. the matched entry's position.
731 }
732 }
733 found_scope
734 }
735
736 /// `true` if no allowlist was configured (dev mode).
737 pub fn is_dev_mode(&self) -> bool {
738 self.scopes.is_empty()
739 }
740}
741
742/// Snapshot of tenant-header policy loaded from the process environment.
743#[derive(Debug, Clone, Copy, Default)]
744pub struct TenantConfig {
745 /// `true` when `TENSOR_WASM_API_REQUIRE_TENANT=1` was set at startup.
746 pub require_header: bool,
747}
748
749impl TenantConfig {
750 /// Load policy from `$TENSOR_WASM_API_REQUIRE_TENANT` (`"1"` = required).
751 pub fn from_env() -> Self {
752 let require_header = std::env::var(ENV_REQUIRE_TENANT).is_ok_and(|v| v.trim() == "1");
753 Self { require_header }
754 }
755}
756
757/// Render the standard `{ "error": { "kind": ..., "message": ... } }`
758/// envelope at `status`. Shared helper for middleware that cannot import
759/// `crate::routes::ApiError` without a cycle.
760fn envelope(status: StatusCode, kind: &str, message: &str) -> Response {
761 let body = Json(json!({
762 "error": { "kind": kind, "message": message }
763 }));
764 (status, body).into_response()
765}
766
767/// Parse the credentials portion of an `Authorization` header value when the
768/// scheme matches `Bearer` case-insensitively (RFC 6750 §2.1 / RFC 7235 §2.1).
769///
770/// The header value is split on the first run of whitespace separating the
771/// scheme token from the credentials. The scheme is compared via
772/// `eq_ignore_ascii_case("bearer")` so `Bearer`, `bearer`, and `BEARER`
773/// (the latter two emerge from upstream load balancers that normalise
774/// header names/values) all match. Both space and horizontal tab are
775/// accepted as separators (RFC 7235 BWS = bad whitespace). The trailing
776/// credentials are trimmed of surrounding ASCII whitespace before being
777/// returned.
778///
779/// Returns `None` when the value has no whitespace (i.e. is a single token
780/// such as `Bearer`) or the scheme is not bearer. An empty-credential
781/// case (e.g. `"Bearer "`) returns `Some("")` so the caller can still
782/// enforce its empty-token rejection rule.
783///
784/// **Control-byte defence.** RFC 7230 §3.2.6 bans control characters
785/// (other than horizontal tab) from header field values, and
786/// [`axum::http::HeaderValue`] already rejects NUL/CR/LF at construction.
787/// The `to_str()` conversion in [`bearer_auth`] further restricts the
788/// input to "visible ASCII" plus tab. We nonetheless apply an explicit
789/// belt-and-braces check here so any future refactor that fans this
790/// helper out behind a more permissive byte source cannot silently
791/// re-open a CRLF/NUL injection channel into downstream consumers of
792/// the returned token (audit log fields, span attributes, etc.).
793fn parse_bearer_credentials(value: &str) -> Option<&str> {
794 // Defence in depth: reject the entire value if any control byte
795 // (other than the horizontal tab we explicitly use as a separator)
796 // is present. Covers NUL (C-string truncation), CR/LF (log-line
797 // forgery), and the DEL byte (terminal-escape smuggling).
798 if value.bytes().any(|b| b != b'\t' && (b < 0x20 || b == 0x7F)) {
799 return None;
800 }
801 // Find the first whitespace byte (space or tab) — anything else
802 // separating scheme from credentials would itself be a protocol
803 // violation, so we don't bother with general Unicode whitespace.
804 let split = value
805 .as_bytes()
806 .iter()
807 .position(|&b| b == b' ' || b == b'\t')?;
808 let (scheme, rest) = value.split_at(split);
809 if !scheme.eq_ignore_ascii_case("bearer") {
810 return None;
811 }
812 // Trim the leading whitespace run (BWS) plus any trailing whitespace
813 // around the credentials. `trim_matches` over the BWS set keeps the
814 // behaviour aligned with `str::trim`'s ASCII-whitespace semantics.
815 Some(rest.trim_matches(|c: char| c == ' ' || c == '\t'))
816}
817
818/// Bearer-token authentication middleware.
819///
820/// If the allowlist is empty the request passes through (dev mode — already
821/// warned at startup) and a synthetic `AuthContext::dev` is inserted into
822/// the request extensions. Otherwise the `Authorization: Bearer <token>`
823/// header must match one of the allowlisted tokens; missing or mismatched
824/// headers produce `401 Unauthorized` with the standard error envelope and
825/// `kind: "unauthorized"`. On success an `AuthContext` keyed by the
826/// stable [`crate::rate_limit::TokenId`] derived from the bearer token is
827/// inserted into the request extensions for downstream middleware (rate
828/// limiting, audit) to consume.
829pub async fn bearer_auth(mut req: Request, next: Next) -> Response {
830 let cfg = req
831 .extensions()
832 .get::<AuthConfig>()
833 .cloned()
834 .unwrap_or_default();
835
836 if cfg.is_dev_mode() {
837 // M4: fail closed unless dev mode was explicitly opted into. An empty
838 // allowlist that nobody acknowledged is treated as a misconfiguration,
839 // not as "auth disabled" — every request is rejected rather than
840 // silently granted the wildcard `AuthContext::dev` scope.
841 if !cfg.dev_mode_allowed {
842 return envelope(
843 StatusCode::UNAUTHORIZED,
844 "unauthorized",
845 "no bearer tokens configured and dev mode is not enabled; set \
846 TENSOR_WASM_API_TOKENS to enable bearer auth, or set \
847 TENSOR_WASM_API_ALLOW_DEV_MODE=1 to allow unauthenticated access",
848 );
849 }
850 req.extensions_mut()
851 .insert(crate::rate_limit::AuthContext::dev());
852 return next.run(req).await;
853 }
854
855 // api S-3: refuse requests with more than one `Authorization` header.
856 // `HeaderMap::get` returns the first occurrence — a buggy or hostile
857 // client sending two headers would silently get one accepted and the
858 // other invisible. Some proxies also concatenate duplicates with
859 // commas, which would round-trip through `parse_bearer_credentials`
860 // unpredictably. Reject outright.
861 let auth_count = req
862 .headers()
863 .get_all(axum::http::header::AUTHORIZATION)
864 .iter()
865 .count();
866 if auth_count > 1 {
867 return envelope(
868 StatusCode::BAD_REQUEST,
869 "bad_request",
870 "duplicate Authorization headers are not allowed",
871 );
872 }
873 // api header-hardening: cap the inbound Authorization value length
874 // BEFORE the constant-time compare loop runs. Hyper's default cap on
875 // the header block (~16 KiB) is large enough that a single oversized
876 // `Authorization: Bearer <huge>` would still burn CPU through every
877 // `ct_eq` iteration. See [`MAX_AUTH_HEADER_BYTES`] for the rationale.
878 // We inspect the raw bytes so a non-UTF-8 value (rejected by
879 // `to_str()` below) is bounded too.
880 let raw_auth = req.headers().get(axum::http::header::AUTHORIZATION);
881 if let Some(value) = raw_auth {
882 if value.as_bytes().len() > MAX_AUTH_HEADER_BYTES {
883 return envelope(
884 StatusCode::UNAUTHORIZED,
885 "invalid_auth",
886 "Authorization header exceeds the maximum permitted length",
887 );
888 }
889 }
890
891 let header = raw_auth.and_then(|h| h.to_str().ok());
892
893 let token = match header.and_then(parse_bearer_credentials) {
894 Some(t) if !t.is_empty() => t.to_owned(),
895 _ => {
896 return envelope(
897 StatusCode::UNAUTHORIZED,
898 "unauthorized",
899 "missing or malformed Authorization: Bearer <token> header",
900 );
901 }
902 };
903
904 let scope = match cfg.scope_for(&token) {
905 Some(s) => s.clone(),
906 None => {
907 return envelope(
908 StatusCode::UNAUTHORIZED,
909 "unauthorized",
910 "bearer token is not allowlisted",
911 );
912 }
913 };
914
915 req.extensions_mut()
916 .insert(crate::rate_limit::AuthContext::with_scope(&token, scope));
917 next.run(req).await
918}
919
920/// Parse the `X-TensorWasm-Tenant` header into a `TenantId`, applying the
921/// configured policy.
922///
923/// Outcomes:
924///
925/// * **More than one `X-TensorWasm-Tenant` header present** => `Err(400
926/// duplicate_tenant_header)`. `HeaderMap::get` returns only the first
927/// match, so an attacker behind a permissive proxy could send
928/// `X-TensorWasm-Tenant: 1, X-TensorWasm-Tenant: 999` and confuse
929/// downstream observers about which tenant the request really
930/// belongs to. We reject outright before any single value is read.
931/// * **Header absent + `TENSOR_WASM_API_REQUIRE_TENANT=1`** => `Err(400
932/// missing_tenant)`.
933/// * **Header absent otherwise** => `Ok(TenantId(0))`.
934/// * **Header present but not a `u64`** => `Err(400 invalid_tenant)`.
935/// The distinct `invalid_tenant` kind separates a malformed value
936/// from the legitimately-absent case so dashboards can alert on each
937/// class independently — a spike in `invalid_tenant` typically
938/// indicates a client bug or a probing attacker, whereas a spike in
939/// `missing_tenant` indicates a misconfigured client.
940#[allow(clippy::result_large_err)]
941pub fn extract_tenant(headers: &HeaderMap, cfg: TenantConfig) -> Result<TenantId, Response> {
942 // Fix 1: refuse requests carrying more than one X-TensorWasm-Tenant
943 // header. The single-`get` path would otherwise pick the first
944 // occurrence silently while a downstream observer sees the second.
945 let header_count = headers.get_all(HEADER_TENANT).iter().count();
946 if header_count > 1 {
947 return Err(envelope(
948 StatusCode::BAD_REQUEST,
949 "duplicate_tenant_header",
950 "multiple X-TensorWasm-Tenant headers are not allowed",
951 ));
952 }
953 let raw = headers.get(HEADER_TENANT).and_then(|h| h.to_str().ok());
954 match raw {
955 None => {
956 if cfg.require_header {
957 Err(envelope(
958 StatusCode::BAD_REQUEST,
959 "missing_tenant",
960 "X-TensorWasm-Tenant header is required (TENSOR_WASM_API_REQUIRE_TENANT=1)",
961 ))
962 } else {
963 Ok(TenantId(0))
964 }
965 }
966 Some(s) => match s.trim().parse::<u64>() {
967 Ok(v) => Ok(TenantId(v)),
968 // Fix 2: the header is PRESENT but unparseable — emit
969 // `invalid_tenant`, distinct from the absent-and-required
970 // `missing_tenant` case above.
971 Err(_) => Err(envelope(
972 StatusCode::BAD_REQUEST,
973 "invalid_tenant",
974 "X-TensorWasm-Tenant must be a u64",
975 )),
976 },
977 }
978}
979
980/// Middleware that resolves the tenant from `X-TensorWasm-Tenant` and stores it
981/// in the request's [`axum::http::Extensions`] for handlers to pick up via
982/// `Extension<TenantId>`. On parse failure / required-but-missing, emits
983/// the standard error envelope and short-circuits the chain.
984pub async fn tenant_scope(mut req: Request, next: Next) -> Response {
985 let cfg = req
986 .extensions()
987 .get::<TenantConfig>()
988 .copied()
989 .unwrap_or_default();
990
991 let tenant = match extract_tenant(req.headers(), cfg) {
992 Ok(t) => t,
993 Err(resp) => return resp,
994 };
995
996 req.extensions_mut().insert(tenant);
997 next.run(req).await
998}
999
1000/// Returns a [`TraceLayer`] that, in addition to the per-request span,
1001/// reads the W3C `traceparent` header from the incoming request and uses
1002/// it as the parent context for the resulting span.
1003///
1004/// When a downstream service (or load test client) sends the W3C standard
1005/// header, traces stitch correctly across the boundary. When no header is
1006/// present, the span is parented to the local context as usual.
1007///
1008/// The function delegates the actual parsing to the OpenTelemetry global
1009/// `TextMapPropagator`. [`crate::server::build_router_with_audit`] calls
1010/// [`crate::trace_propagation::install_w3c_propagator`] before any
1011/// request can hit this layer, so the parsing path is reliably wired
1012/// regardless of whether the `otlp` feature is enabled on
1013/// `tensor-wasm-core`. If for some reason no propagator is installed —
1014/// e.g. in a test that bypasses the server builder — the extraction
1015/// returns an empty context and the span is parented locally.
1016pub fn trace_layer_with_propagation() -> tower_http::trace::TraceLayer<
1017 tower_http::classify::SharedClassifier<tower_http::classify::ServerErrorsAsFailures>,
1018 impl Fn(&axum::http::Request<axum::body::Body>) -> tracing::Span + Clone,
1019> {
1020 use tracing_opentelemetry::OpenTelemetrySpanExt;
1021
1022 TraceLayer::new_for_http().make_span_with(|req: &axum::http::Request<axum::body::Body>| {
1023 // Surface the raw traceparent value as a span field for log-based
1024 // correlation, even when no OTel propagator is installed. The
1025 // header is sanitised first (see `sanitise_traceparent`) so a
1026 // hostile client cannot smuggle CR/LF, NUL, or megabytes of
1027 // arbitrary text into every log line that touches this request.
1028 let raw_tp = req
1029 .headers()
1030 .get("traceparent")
1031 .and_then(|v| v.to_str().ok())
1032 .unwrap_or("");
1033 let sanitised_tp = sanitise_traceparent(raw_tp);
1034
1035 // Record only the URI **path**, never the query string. Query
1036 // parameters frequently carry tokens/secrets (e.g. an attacker
1037 // probing `GET /healthz?secret=exfil`) and we MUST NOT plant
1038 // them in span attributes that flow to log sinks. Handlers that
1039 // legitimately need the query string read it from
1040 // `req.uri().query()` themselves under their own scrubbing.
1041 //
1042 // Both `path` and `method` flow through bounded sanitisers
1043 // (`sanitize_path` / `normalize_method`) before reaching the
1044 // span so that path-traversal probes (`/functions/../etc/passwd`)
1045 // and CRLF-injection payloads (`/foo%0d%0aevil-header:%20yes`)
1046 // can neither forge log lines nor smuggle terminal-escape
1047 // sequences into operator dashboards. `traceparent` has its
1048 // own dedicated sanitiser above.
1049 let sanitised_path = sanitize_path(req.uri().path());
1050 let normalised_method = normalize_method(req.method().as_str());
1051 let span = tracing::info_span!(
1052 "http.request",
1053 method = %normalised_method,
1054 path = %sanitised_path,
1055 version = ?req.version(),
1056 traceparent = %sanitised_tp,
1057 );
1058
1059 // Extract the parent `opentelemetry::Context` from the incoming
1060 // headers via the globally-installed `TextMapPropagator`. The
1061 // `set_parent` call hooks the freshly-created tracing span into
1062 // the upstream W3C trace, so subsequent `#[instrument]` spans on
1063 // tenant lookup, executor spawn, snapshot restore, and dispatch
1064 // all share the same `trace_id`.
1065 let parent_cx = crate::trace_propagation::extract_parent_context(req.headers());
1066 span.set_parent(parent_cx);
1067
1068 span
1069 })
1070}
1071
1072/// Maximum byte length a `traceparent` header value is allowed to
1073/// occupy in a span attribute. The W3C Trace Context spec caps a
1074/// well-formed value at 55 bytes; 64 gives a tiny margin for future
1075/// versioned suffixes while still bounding the per-span log footprint
1076/// to a constant. Anything longer is truncated.
1077const TRACEPARENT_MAX_BYTES: usize = 64;
1078
1079/// Sentinel emitted in span attributes when a `traceparent` header
1080/// contains characters that would corrupt log output (CR, LF, NUL).
1081/// Choosing a fixed token rather than the original (filtered) value
1082/// keeps grep/audit signatures stable across hostile inputs.
1083const TRACEPARENT_INVALID_SENTINEL: &str = "<invalid>";
1084
1085/// Render an inbound `traceparent` header value as a bounded, log-safe
1086/// string for use as a tracing span attribute.
1087///
1088/// The header is attacker-controlled, so we apply three defences:
1089///
1090/// 1. **Reject control characters.** Any CR, LF, or NUL byte indicates
1091/// an attempt to inject a fake log line (CRLF) or terminate a C
1092/// string in a downstream consumer (NUL). We collapse the entire
1093/// value to the [`TRACEPARENT_INVALID_SENTINEL`] in that case rather
1094/// than try to filter — partial sanitisation is exactly the kind of
1095/// surface that breeds bypasses.
1096/// 2. **Clamp length to 64 bytes.** The W3C grammar caps a valid value
1097/// at 55 bytes; anything longer is either malformed or hostile
1098/// padding. Truncation happens at a UTF-8 char boundary so we never
1099/// return invalid UTF-8 to the tracing layer.
1100/// 3. **Strip non-printable bytes** (anything outside `0x20..=0x7E`).
1101/// Printable ASCII is the entire grammar the spec allows, and
1102/// keeping span attributes plain ASCII prevents terminal-escape
1103/// smuggling through log viewers.
1104///
1105/// When the input is already a clean ASCII-printable string short
1106/// enough to fit, we return `Cow::Borrowed` to avoid the allocation.
1107fn sanitise_traceparent(raw: &str) -> std::borrow::Cow<'_, str> {
1108 use std::borrow::Cow;
1109
1110 // Defence 1: reject the whole value when control chars appear.
1111 // CRLF is the classic log-injection vector; NUL the classic C
1112 // boundary trick. Bail before doing any other processing so the
1113 // sentinel is stable.
1114 if raw.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
1115 return Cow::Owned(TRACEPARENT_INVALID_SENTINEL.to_string());
1116 }
1117
1118 // Fast path: already short, already printable ASCII -> borrow.
1119 let is_clean =
1120 raw.len() <= TRACEPARENT_MAX_BYTES && raw.bytes().all(|b| (0x20..=0x7E).contains(&b));
1121 if is_clean {
1122 return Cow::Borrowed(raw);
1123 }
1124
1125 // Slow path: build a filtered, clamped owned copy. Walk by char so
1126 // truncation never lands mid-codepoint, and skip anything outside
1127 // printable ASCII.
1128 let mut out = String::with_capacity(raw.len().min(TRACEPARENT_MAX_BYTES));
1129 for ch in raw.chars() {
1130 let b = ch as u32;
1131 if !(0x20..=0x7E).contains(&b) {
1132 continue;
1133 }
1134 let ch_len = ch.len_utf8();
1135 if out.len() + ch_len > TRACEPARENT_MAX_BYTES {
1136 break;
1137 }
1138 out.push(ch);
1139 }
1140 Cow::Owned(out)
1141}
1142
1143/// Maximum byte length that a request path is allowed to occupy in a
1144/// tracing span attribute (see [`sanitize_path`]). Anything longer is
1145/// truncated with a `…` suffix to keep operator log lines bounded and
1146/// to deny a hostile client a cheap way to balloon every log record
1147/// that touches their request.
1148///
1149/// 256 bytes comfortably accommodates every route template the API
1150/// exposes today (the longest, `/functions/{id}/invoke-async`, is far
1151/// under that even after path-parameter substitution) while still
1152/// bounding the per-span attribute footprint to a constant. Bump only
1153/// after auditing the dashboard layouts in `docs/OBSERVABILITY.md` —
1154/// a wider value widens the log-line budget linearly.
1155pub const MAX_PATH_LEN: usize = 256;
1156
1157/// Sentinel returned by [`normalize_method`] for any HTTP method that
1158/// does not match the `[A-Z]{1,16}` shape. Keeping a fixed token rather
1159/// than the original (filtered) value preserves grep/audit signatures
1160/// across hostile inputs — every malformed method bucket maps to the
1161/// same string.
1162const METHOD_OTHER_SENTINEL: &str = "OTHER";
1163
1164/// Upper bound on the byte length of an accepted HTTP method name.
1165/// Standard methods are at most 7 bytes (`OPTIONS`); 16 leaves room
1166/// for legitimate WebDAV-style extensions (`MKCALENDAR`, `PROPPATCH`)
1167/// while rejecting padding-style abuse.
1168const METHOD_MAX_LEN: usize = 16;
1169
1170/// Render a request path as a bounded, log-safe string for use as a
1171/// tracing span attribute.
1172///
1173/// The path is attacker-controlled (it flows out of the request URI
1174/// after axum's routing layer), so we apply three defences:
1175///
1176/// 1. **Truncate to [`MAX_PATH_LEN`] bytes** with a `…` ellipsis
1177/// suffix when the input is longer. Truncation lands on a UTF-8
1178/// char boundary so we never emit invalid UTF-8 to the tracing
1179/// layer. The ellipsis is one Unicode code point (`U+2026`, three
1180/// UTF-8 bytes) so the returned `Cow::Owned` is at most
1181/// `MAX_PATH_LEN + 3` bytes — the test in
1182/// `tests/trace_sanitization_test.rs` asserts `MAX_PATH_LEN + 4`
1183/// to give a one-byte margin against future ellipsis tweaks.
1184/// 2. **Replace non-printable / non-ASCII bytes with `?`.** Anything
1185/// outside `0x20..=0x7E` (printable ASCII) is collapsed to a
1186/// single `?` so terminal-escape sequences cannot smuggle out of
1187/// a log viewer and multi-byte UTF-8 sequences (e.g. `é` →
1188/// `0xC3 0xA9`) cannot be reconstructed downstream.
1189/// 3. **Strip CR / LF / NUL specifically.** The printable-byte filter
1190/// above already catches these, but we apply the explicit strip as
1191/// defence in depth: a future relaxation of the printable filter
1192/// must NOT re-open the CRLF-injection channel (forging fake JSON
1193/// log lines) or the NUL channel (terminating C-string consumers).
1194///
1195/// When the input is already a clean ASCII-printable string short
1196/// enough to fit, we return `Cow::Borrowed` to avoid the allocation.
1197pub fn sanitize_path(raw: &str) -> std::borrow::Cow<'_, str> {
1198 use std::borrow::Cow;
1199
1200 // Fast path: already short, already printable ASCII, no
1201 // CR/LF/NUL -> borrow. Walking once via `bytes().all` lets the
1202 // compiler fuse the checks; we only allocate when something
1203 // actually needs rewriting.
1204 let is_clean = raw.len() <= MAX_PATH_LEN
1205 && raw
1206 .bytes()
1207 .all(|b| (0x20..=0x7E).contains(&b) && b != b'\r' && b != b'\n' && b != 0);
1208 if is_clean {
1209 return Cow::Borrowed(raw);
1210 }
1211
1212 // Slow path: build a filtered, clamped owned copy. Walk by char
1213 // so truncation never lands mid-codepoint; replace anything
1214 // outside printable ASCII with a literal `?` rather than dropping
1215 // it, so a path like `/café` round-trips to `/caf??` (two bytes
1216 // for `é` → two `?` substitutions) and the resulting attribute
1217 // length is observable to operators rather than silently
1218 // shrinking.
1219 //
1220 // We reserve `MAX_PATH_LEN` up front; the ellipsis (if any) is
1221 // appended afterwards so the truncation check operates on the
1222 // pre-ellipsis byte budget.
1223 let mut out = String::with_capacity(raw.len().min(MAX_PATH_LEN));
1224 let mut truncated = false;
1225 for ch in raw.chars() {
1226 // Defence 3: strip CR/LF/NUL explicitly even though the
1227 // printable filter below would catch them. A future relaxation
1228 // must not re-open the injection channel.
1229 if ch == '\r' || ch == '\n' || ch == '\0' {
1230 out.push('?');
1231 if out.len() >= MAX_PATH_LEN {
1232 truncated = true;
1233 break;
1234 }
1235 continue;
1236 }
1237 let b = ch as u32;
1238 let replacement = if (0x20..=0x7E).contains(&b) {
1239 // Printable ASCII: keep the character as-is (it occupies
1240 // one byte in UTF-8 by definition).
1241 ch
1242 } else {
1243 // Non-printable or non-ASCII: collapse to `?`. This
1244 // includes every byte of a multi-byte UTF-8 sequence
1245 // because we iterate by `char`, not by byte, so each
1246 // non-ASCII code point contributes exactly one `?` to
1247 // the output regardless of how many bytes it occupies
1248 // in the input.
1249 '?'
1250 };
1251 let ch_len = replacement.len_utf8();
1252 if out.len() + ch_len > MAX_PATH_LEN {
1253 truncated = true;
1254 break;
1255 }
1256 out.push(replacement);
1257 }
1258 if truncated {
1259 // Append a single-code-point ellipsis (`…`, U+2026, 3 bytes
1260 // in UTF-8) so the truncated value is visually distinguishable
1261 // from a non-truncated one. Total length stays bounded at
1262 // `MAX_PATH_LEN + 3` bytes — see the doc comment above.
1263 out.push('…');
1264 }
1265 Cow::Owned(out)
1266}
1267
1268/// Normalise an HTTP method name for use as a tracing span attribute.
1269///
1270/// Returns the input borrowed when it matches `[A-Z]{1,16}` exactly
1271/// (every standard method — `GET`, `POST`, `PUT`, `PATCH`, `DELETE`,
1272/// `HEAD`, `OPTIONS`, `TRACE`, `CONNECT` — passes through verbatim).
1273/// Anything else — lowercase (`get`), mixed case (`Get`), control
1274/// characters, non-ASCII, oversized padding — collapses to the
1275/// `METHOD_OTHER_SENTINEL` (`"OTHER"`).
1276///
1277/// HTTP method names are far less risky than paths (hyper rejects
1278/// most malformed values before they reach this layer) but we still
1279/// normalise so that:
1280///
1281/// * dashboard label cardinality stays bounded (no per-request method
1282/// variant exploding the metrics index),
1283/// * a custom client cannot smuggle non-standard bytes into the
1284/// `method` span field that the path sanitiser would have rejected.
1285pub fn normalize_method(raw: &str) -> std::borrow::Cow<'_, str> {
1286 use std::borrow::Cow;
1287
1288 let bytes = raw.as_bytes();
1289 let valid = !bytes.is_empty()
1290 && bytes.len() <= METHOD_MAX_LEN
1291 && bytes.iter().all(|b| b.is_ascii_uppercase());
1292 if valid {
1293 Cow::Borrowed(raw)
1294 } else {
1295 Cow::Owned(METHOD_OTHER_SENTINEL.to_string())
1296 }
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use axum::http::HeaderValue;
1303
1304 #[test]
1305 fn timeout_layer_constructs() {
1306 let _ = timeout_layer(Duration::from_millis(1));
1307 let _ = timeout_layer(DEFAULT_REQUEST_TIMEOUT);
1308 }
1309
1310 #[test]
1311 fn trace_layer_constructs() {
1312 let _ = trace_layer();
1313 }
1314
1315 #[test]
1316 fn concurrency_limit_layer_constructs() {
1317 let _ = concurrency_limit_layer(1);
1318 let _ = concurrency_limit_layer(DEFAULT_CONCURRENCY_LIMIT);
1319 }
1320
1321 #[test]
1322 fn body_limit_layer_constructs() {
1323 let _ = body_limit_layer(MAX_REQUEST_BODY_BYTES);
1324 }
1325
1326 #[test]
1327 fn cors_config_default_is_empty_allowlist() {
1328 let cfg = CorsConfig::default();
1329 assert!(cfg.allowed_origins.is_empty());
1330 }
1331
1332 #[test]
1333 fn cors_config_from_origins_round_trips() {
1334 let cfg = CorsConfig::from_origins(["https://app.example.com"]);
1335 assert_eq!(cfg.allowed_origins, vec!["https://app.example.com"]);
1336 }
1337
1338 #[test]
1339 fn cors_layer_constructs_empty_allowlist() {
1340 // The empty-allowlist branch is the safe default: it must produce
1341 // a layer that does not set Access-Control-Allow-Origin. We only
1342 // exercise construction here; the wire-level rejection check sits
1343 // in the integration test suite where a full router is in scope.
1344 let _ = cors_layer(&CorsConfig::default());
1345 }
1346
1347 #[test]
1348 fn cors_layer_constructs_with_origins() {
1349 let _ = cors_layer(&CorsConfig::from_origins([
1350 "https://app.example.com",
1351 "https://admin.example.com",
1352 ]));
1353 }
1354
1355 #[test]
1356 fn trace_layer_with_propagation_constructs() {
1357 let _ = trace_layer_with_propagation();
1358 }
1359
1360 #[test]
1361 fn sanitise_traceparent_passes_through_well_formed_value() {
1362 // A literal example from the W3C Trace Context spec. Already
1363 // printable ASCII, already under the 64-byte cap, so the
1364 // helper must return the borrowed pointer (no allocation,
1365 // exact byte equality).
1366 let sample = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
1367 let out = sanitise_traceparent(sample);
1368 assert_eq!(out.as_ref(), sample);
1369 assert!(
1370 matches!(out, std::borrow::Cow::Borrowed(_)),
1371 "well-formed value must be borrowed, not allocated",
1372 );
1373 }
1374
1375 #[test]
1376 fn sanitise_traceparent_rejects_crlf_injection() {
1377 // CRLF in a header value is the classic log-injection vector;
1378 // a hostile client could plant a forged "log line" into our
1379 // structured output. The helper must collapse the whole value
1380 // to a stable sentinel, NOT a partially-filtered string.
1381 let attack = "\r\nSET-COOKIE: x=y\r\n";
1382 let out = sanitise_traceparent(attack);
1383 assert_eq!(out.as_ref(), "<invalid>");
1384 }
1385
1386 #[test]
1387 fn sanitise_traceparent_rejects_embedded_nul() {
1388 // NUL bytes truncate C strings in downstream consumers; treat
1389 // them as hostile and emit the sentinel.
1390 let attack = "00-aaaa\0bbbb";
1391 let out = sanitise_traceparent(attack);
1392 assert_eq!(out.as_ref(), "<invalid>");
1393 }
1394
1395 #[test]
1396 fn sanitise_traceparent_truncates_oversized_input_to_64_bytes() {
1397 // A 100-byte all-'a' string is printable ASCII but exceeds
1398 // the 64-byte cap. The helper must clamp to exactly 64
1399 // bytes — the W3C maximum is 55, so 64 already accommodates
1400 // every legitimate value with room to spare.
1401 let input = "a".repeat(100);
1402 let out = sanitise_traceparent(&input);
1403 assert_eq!(out.len(), 64);
1404 assert!(out.chars().all(|c| c == 'a'));
1405 }
1406
1407 #[test]
1408 fn sanitise_traceparent_filters_non_printable_bytes() {
1409 // ESC (0x1B) is outside printable ASCII and could be used to
1410 // smuggle terminal escape sequences through log viewers.
1411 // The helper must strip the byte but keep the surrounding
1412 // printable context.
1413 let attack = "00-aaaa\x1Bbbbb-cc";
1414 let out = sanitise_traceparent(attack);
1415 assert!(!out.contains('\x1B'));
1416 assert!(out.contains("aaaa"));
1417 assert!(out.contains("bbbb"));
1418 }
1419
1420 #[test]
1421 fn auth_config_dev_mode_when_empty() {
1422 let cfg = AuthConfig::from_tokens(Vec::<String>::new());
1423 assert!(cfg.is_dev_mode());
1424 // dev-mode `accepts` is irrelevant, but should return `false` —
1425 // the dev gate runs in `bearer_auth`, not here.
1426 assert!(!cfg.accepts("anything"));
1427 }
1428
1429 // ---- M4: dev-mode opt-in gate -------------------------------------
1430
1431 /// Programmatic constructors are an explicit in-process opt-in, so they
1432 /// keep the historical pass-through behaviour (`dev_mode_allowed == true`).
1433 /// This preserves every test/embedder that builds a dev `AuthConfig`
1434 /// directly without touching the process environment.
1435 #[test]
1436 fn auth_config_programmatic_constructors_allow_dev_mode() {
1437 assert!(AuthConfig::default().dev_mode_allowed);
1438 assert!(AuthConfig::from_tokens(Vec::<String>::new()).dev_mode_allowed);
1439 assert!(AuthConfig::from_scopes(Vec::<(String, TokenScope)>::new()).dev_mode_allowed);
1440 }
1441
1442 /// `from_env` with an empty allowlist and no opt-in must produce a config
1443 /// that forbids dev mode (M4 fail-closed). The env mutation is scoped by
1444 /// `temp_env` so it cannot race other tests.
1445 #[test]
1446 fn auth_config_from_env_empty_without_opt_in_forbids_dev_mode() {
1447 temp_env::with_vars(
1448 [
1449 (ENV_API_TOKENS, None::<&str>),
1450 (ENV_ALLOW_DEV_MODE, None::<&str>),
1451 ],
1452 || {
1453 let cfg = AuthConfig::from_env();
1454 assert!(cfg.is_dev_mode());
1455 assert!(
1456 !cfg.dev_mode_allowed,
1457 "empty allowlist + no opt-in must fail closed",
1458 );
1459 },
1460 );
1461 }
1462
1463 /// The opt-in honours both `1` and (case-insensitive) `true`.
1464 #[test]
1465 fn auth_config_from_env_opt_in_values_enable_dev_mode() {
1466 for val in ["1", "true", "TRUE", " true "] {
1467 temp_env::with_vars(
1468 [
1469 (ENV_API_TOKENS, None::<&str>),
1470 (ENV_ALLOW_DEV_MODE, Some(val)),
1471 ],
1472 || {
1473 let cfg = AuthConfig::from_env();
1474 assert!(cfg.is_dev_mode());
1475 assert!(
1476 cfg.dev_mode_allowed,
1477 "ENV_ALLOW_DEV_MODE={val:?} must enable dev mode",
1478 );
1479 },
1480 );
1481 }
1482 }
1483
1484 /// A non-truthy opt-in value leaves dev mode disabled.
1485 #[test]
1486 fn auth_config_from_env_non_truthy_opt_in_forbids_dev_mode() {
1487 for val in ["0", "false", "yes", ""] {
1488 temp_env::with_vars(
1489 [
1490 (ENV_API_TOKENS, None::<&str>),
1491 (ENV_ALLOW_DEV_MODE, Some(val)),
1492 ],
1493 || {
1494 assert!(
1495 !AuthConfig::from_env().dev_mode_allowed,
1496 "ENV_ALLOW_DEV_MODE={val:?} must NOT enable dev mode",
1497 );
1498 },
1499 );
1500 }
1501 }
1502
1503 /// When tokens ARE configured the opt-in is irrelevant — the config is
1504 /// not in dev mode and `bearer_auth` enforces the allowlist as before.
1505 #[test]
1506 fn auth_config_from_env_with_tokens_is_not_dev_mode() {
1507 temp_env::with_vars(
1508 [
1509 (ENV_API_TOKENS, Some("secret:tenant=*")),
1510 (ENV_ALLOW_DEV_MODE, None::<&str>),
1511 ],
1512 || {
1513 let cfg = AuthConfig::from_env();
1514 assert!(!cfg.is_dev_mode());
1515 assert!(cfg.accepts("secret"));
1516 },
1517 );
1518 }
1519
1520 /// End-to-end: a fail-closed dev config (`dev_mode_allowed == false`) must
1521 /// reject an unauthenticated request through the real `bearer_auth`
1522 /// middleware with `401 Unauthorized`, rather than passing it through.
1523 #[tokio::test]
1524 async fn bearer_auth_fails_closed_when_dev_mode_not_allowed() {
1525 use axum::routing::get;
1526 use tower::ServiceExt as _;
1527
1528 let cfg = temp_env::with_vars(
1529 [
1530 (ENV_API_TOKENS, None::<&str>),
1531 (ENV_ALLOW_DEV_MODE, None::<&str>),
1532 ],
1533 AuthConfig::from_env,
1534 );
1535 assert!(!cfg.dev_mode_allowed);
1536
1537 let router = axum::Router::new()
1538 .route("/x", get(|| async { "ok" }))
1539 .layer(axum::middleware::from_fn(bearer_auth))
1540 .layer(axum::Extension(cfg));
1541
1542 let resp = router
1543 .oneshot(
1544 Request::builder()
1545 .uri("/x")
1546 .body(axum::body::Body::empty())
1547 .unwrap(),
1548 )
1549 .await
1550 .unwrap();
1551 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1552 }
1553
1554 /// End-to-end counterpart: with dev mode explicitly allowed the same
1555 /// unauthenticated request passes through to the handler (`200 OK`).
1556 #[tokio::test]
1557 async fn bearer_auth_passes_through_when_dev_mode_allowed() {
1558 use axum::routing::get;
1559 use tower::ServiceExt as _;
1560
1561 // `default()` is an explicit in-process opt-in (dev_mode_allowed).
1562 let router = axum::Router::new()
1563 .route("/x", get(|| async { "ok" }))
1564 .layer(axum::middleware::from_fn(bearer_auth))
1565 .layer(axum::Extension(AuthConfig::default()));
1566
1567 let resp = router
1568 .oneshot(
1569 Request::builder()
1570 .uri("/x")
1571 .body(axum::body::Body::empty())
1572 .unwrap(),
1573 )
1574 .await
1575 .unwrap();
1576 assert_eq!(resp.status(), StatusCode::OK);
1577 }
1578
1579 #[test]
1580 fn auth_config_accepts_matching_token() {
1581 let cfg = AuthConfig::from_tokens(["foo", "bar"]);
1582 assert!(!cfg.is_dev_mode());
1583 assert!(cfg.accepts("foo"));
1584 assert!(cfg.accepts("bar"));
1585 assert!(!cfg.accepts("baz"));
1586 }
1587
1588 #[test]
1589 fn auth_config_from_tokens_defaults_to_wildcard_scope() {
1590 let cfg = AuthConfig::from_tokens(["foo"]);
1591 let scope = cfg.scope_for("foo").expect("scope present");
1592 assert!(
1593 scope.tenants.is_all(),
1594 "from_tokens must default to wildcard"
1595 );
1596 }
1597
1598 #[test]
1599 fn auth_config_from_scopes_round_trips() {
1600 let cfg = AuthConfig::from_scopes([
1601 (
1602 "foo",
1603 crate::token_scope::TokenScope::from_tenants([TenantId(1)]),
1604 ),
1605 ("bar", crate::token_scope::TokenScope::all()),
1606 ]);
1607 assert!(cfg.accepts("foo"));
1608 assert!(cfg.accepts("bar"));
1609 let foo = cfg.scope_for("foo").expect("foo");
1610 assert!(foo.allows(TenantId(1)));
1611 assert!(!foo.allows(TenantId(2)));
1612 let bar = cfg.scope_for("bar").expect("bar");
1613 assert!(bar.allows(TenantId(99)));
1614 }
1615
1616 #[test]
1617 fn extract_tenant_default_zero_when_optional() {
1618 let headers = HeaderMap::new();
1619 let cfg = TenantConfig {
1620 require_header: false,
1621 };
1622 let tid = extract_tenant(&headers, cfg).expect("default to TenantId(0)");
1623 assert_eq!(tid, TenantId(0));
1624 }
1625
1626 #[test]
1627 fn extract_tenant_errors_when_required_and_missing() {
1628 let headers = HeaderMap::new();
1629 let cfg = TenantConfig {
1630 require_header: true,
1631 };
1632 let err = extract_tenant(&headers, cfg).expect_err("required header missing");
1633 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1634 }
1635
1636 #[test]
1637 fn extract_tenant_parses_header_value() {
1638 let mut headers = HeaderMap::new();
1639 headers.insert(HEADER_TENANT, HeaderValue::from_static("7"));
1640 let tid = extract_tenant(
1641 &headers,
1642 TenantConfig {
1643 require_header: false,
1644 },
1645 )
1646 .expect("parses");
1647 assert_eq!(tid, TenantId(7));
1648 }
1649
1650 #[test]
1651 fn parse_bearer_credentials_accepts_canonical_scheme() {
1652 assert_eq!(parse_bearer_credentials("Bearer abc"), Some("abc"));
1653 }
1654
1655 #[test]
1656 fn parse_bearer_credentials_is_case_insensitive() {
1657 // Load balancers (e.g. Envoy / nginx lowercase plugins) may have
1658 // normalised the scheme; RFC 6750 §2.1 says scheme matching is
1659 // case-insensitive.
1660 assert_eq!(parse_bearer_credentials("bearer abc"), Some("abc"));
1661 assert_eq!(parse_bearer_credentials("BEARER abc"), Some("abc"));
1662 assert_eq!(parse_bearer_credentials("BeArEr abc"), Some("abc"));
1663 }
1664
1665 #[test]
1666 fn parse_bearer_credentials_accepts_tab_separator() {
1667 assert_eq!(parse_bearer_credentials("Bearer\tabc"), Some("abc"));
1668 }
1669
1670 #[test]
1671 fn parse_bearer_credentials_trims_surrounding_whitespace() {
1672 assert_eq!(parse_bearer_credentials("Bearer abc"), Some("abc"));
1673 assert_eq!(parse_bearer_credentials("Bearer abc "), Some("abc"));
1674 assert_eq!(parse_bearer_credentials("Bearer \t abc \t "), Some("abc"));
1675 }
1676
1677 #[test]
1678 fn parse_bearer_credentials_rejects_other_schemes() {
1679 assert_eq!(parse_bearer_credentials("Basic ZGVhZGJlZWY="), None);
1680 assert_eq!(parse_bearer_credentials("Token abc"), None);
1681 }
1682
1683 #[test]
1684 fn parse_bearer_credentials_returns_none_for_no_whitespace() {
1685 // No separator at all => not a parseable Authorization value.
1686 assert_eq!(parse_bearer_credentials("Bearer"), None);
1687 assert_eq!(parse_bearer_credentials(""), None);
1688 }
1689
1690 #[test]
1691 fn parse_bearer_credentials_empty_token_is_some_empty() {
1692 // Caller (`bearer_auth`) is responsible for the empty-token check;
1693 // we surface the empty string so it can reject with the same shape
1694 // as a missing header.
1695 assert_eq!(parse_bearer_credentials("Bearer "), Some(""));
1696 }
1697
1698 #[test]
1699 fn extract_tenant_rejects_garbage_header() {
1700 let mut headers = HeaderMap::new();
1701 headers.insert(HEADER_TENANT, HeaderValue::from_static("not-a-number"));
1702 let err = extract_tenant(
1703 &headers,
1704 TenantConfig {
1705 require_header: false,
1706 },
1707 )
1708 .expect_err("rejects garbage");
1709 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1710 }
1711}