diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index d21926f9..a5ad8b2d 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -102,11 +102,88 @@ pub struct ProviderKey { #[serde(default, skip_serializing_if = "Option::is_none")] pub response: Option, + /// Inbound request headers to strip before forwarding to the + /// upstream provider on the passthrough endpoint (#411). + /// + /// Defaults (when the field is absent on the wire) to the 4 + /// canonical credential headers: `authorization`, `cookie`, + /// `set-cookie`, `x-api-key`. Customers can: + /// - Remove a default entry → that header reaches upstream + /// (the dashboard warns when removing a default). + /// - Add custom entries → extra headers stripped. + /// + /// Case-insensitive. Compared lowercased against the inbound + /// header name. Non-configurable headers (`host`, `content-length`, + /// RFC 7230 §6.1 hop-by-hop) are stripped separately by the + /// passthrough handler and cannot be removed via this list. + /// + /// Entries are normalised on deserialize via + /// `normalize_strip_headers`: trimmed, lowercased, dedup'd, + /// empties dropped. This prevents the "operator typed `' cookie '`, + /// the strip set has `' cookie '` but the inbound header is + /// `'cookie'` → no match → silent credential leak" footgun. + #[serde( + default = "default_strip_headers", + deserialize_with = "deserialize_normalized_strip_headers" + )] + pub strip_headers: Vec, + /// Filled in by the snapshot loader from the etcd key path. #[serde(skip)] pub(crate) runtime_id: String, } +/// Default header-strip list for a freshly-created ProviderKey +/// on the passthrough endpoint, per issue #411. These four headers +/// are credentials that the upstream LLM provider has no legitimate +/// use for; stripping by default protects against accidental +/// session-token disclosure. Customers can remove entries via the +/// dashboard (with a warning) if they have a specific audit / +/// forwarding need. +pub fn default_strip_headers() -> Vec { + vec![ + "authorization".to_string(), + "cookie".to_string(), + "set-cookie".to_string(), + "x-api-key".to_string(), + ] +} + +/// Normalize a single strip-list entry: trim whitespace, lowercase +/// ASCII. Returns `None` for entries that, post-trim, are empty or +/// reference-invalid HTTP header names. Non-ASCII chars survive +/// `to_ascii_lowercase` (no-op for them) but are unusual in practice; +/// the passthrough handler's `to_ascii_lowercase` comparison will +/// still match correctly. +fn normalize_strip_entry(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_ascii_lowercase()) +} + +/// Deserialize + normalize: drop empties, lowercase, dedup. Preserves +/// first-occurrence order so a hand-curated list reads sanely in the +/// dashboard. Per issue #411 audit MEDIUM-1. +fn deserialize_normalized_strip_headers<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize as _; + let raw: Vec = Vec::deserialize(de)?; + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + if let Some(normalized) = normalize_strip_entry(&entry) { + if seen.insert(normalized.clone()) { + out.push(normalized); + } + } + } + Ok(out) +} + /// Telemetry attribution tags emitted alongside requests routed /// through this `ProviderKey`. Introduced as a skeleton for issue /// #302 Phase A — no metric/log path consumes these fields yet. @@ -437,6 +514,7 @@ mod tests { telemetry_tags: TelemetryTags::default(), request: None, response: None, + strip_headers: default_strip_headers(), runtime_id: String::new(), }; let s = serde_json::to_string(&original).unwrap(); @@ -610,4 +688,60 @@ mod tests { let r: Result = serde_json::from_str(r#"{"top_p_max": 0.9}"#); assert!(r.is_err()); } + + // ---- Issue #411 strip_headers deserialize/normalize ---- + + fn pk_with_strip(strip_json: &str) -> ProviderKey { + let json = format!(r#"{{"display_name":"x","secret":"sk","strip_headers":{strip_json}}}"#); + serde_json::from_str(&json).unwrap() + } + + #[test] + fn strip_headers_default_applies_when_field_absent() { + let pk: ProviderKey = + serde_json::from_str(r#"{"display_name":"x","secret":"sk"}"#).unwrap(); + assert_eq!(pk.strip_headers, default_strip_headers()); + } + + #[test] + fn strip_headers_explicit_empty_array_is_preserved() { + // The "customer cleared all defaults" override case must + // produce an empty Vec, NOT fall through to the default. + let pk = pk_with_strip("[]"); + assert!(pk.strip_headers.is_empty()); + } + + #[test] + fn strip_headers_trims_whitespace() { + // Without the normalize hook, " cookie " would never match + // an inbound `cookie` header → silent credential leak. + let pk = pk_with_strip(r#"[" cookie ", "\tauthorization\n"]"#); + assert_eq!(pk.strip_headers, vec!["cookie", "authorization"]); + } + + #[test] + fn strip_headers_lowercases_input() { + let pk = pk_with_strip(r#"["Authorization", "COOKIE", "X-Custom-Header"]"#); + assert_eq!( + pk.strip_headers, + vec!["authorization", "cookie", "x-custom-header"] + ); + } + + #[test] + fn strip_headers_drops_empty_entries() { + // Operators pasting from a comma-split tool may end up with + // stray empty strings. Silently ignored, not fatal. + let pk = pk_with_strip(r#"["", " ", "cookie", ""]"#); + assert_eq!(pk.strip_headers, vec!["cookie"]); + } + + #[test] + fn strip_headers_dedupes_case_insensitively() { + // Customer accidentally added "Cookie" and "cookie" both. + // Dedup post-lowercase. First-occurrence order is preserved + // so the dashboard reads sanely. + let pk = pk_with_strip(r#"["Cookie", "x-trace", "cookie", "X-Trace"]"#); + assert_eq!(pk.strip_headers, vec!["cookie", "x-trace"]); + } } diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 5fee01a4..5827c1f7 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -361,6 +361,17 @@ fn provider_key_schema() -> Value { "error_envelope": { "type": "string" }, "reasoning_field": { "type": "string" } } + }, + // Issue #411 — per-PK passthrough header strip list. + // Optional (defaults applied DP-side via + // `#[serde(default = "default_strip_headers")]`); when + // present, must be an array of strings. Entries are + // normalised (trim/lowercase/dedup/drop-empties) on + // deserialize so this validator doesn't enforce + // formatting beyond the type shape. + "strip_headers": { + "type": "array", + "items": { "type": "string" } } } }) diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index 804ef402..4b8509f2 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -35,6 +35,33 @@ use crate::error::ProxyError; use crate::request_id::new_request_id; use crate::state::ProxyState; +/// Headers that the passthrough endpoint ALWAYS strips before +/// forwarding to upstream, regardless of customer configuration. +/// +/// Two categories: +/// 1. HTTP protocol metadata (`host`, `content-length`) — the +/// outbound HTTP client recomputes these based on the upstream +/// URL + body bytes. +/// 2. RFC 7230 §6.1 hop-by-hop headers — by definition single- +/// hop, never legitimately forwarded. +/// +/// Customer-configurable credential strips (`authorization`, +/// `cookie`, `set-cookie`, `x-api-key`) live on the ProviderKey's +/// `strip_headers` field — defaults set in +/// `aisix_core::default_strip_headers`. Per issue #411. +const ALWAYS_STRIP: &[&str] = &[ + "host", + "content-length", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + /// Provider defaults indexed by provider-prefix string. /// /// NOTE: `"cohere"` and `"jina"` are intentionally absent. #213 @@ -262,6 +289,49 @@ async fn dispatch( // - openai / gemini / deepseek (and any unknown provider that // reuses the OpenAI-compat shape): `Authorization: Bearer …` // - anthropic: `x-api-key` + `anthropic-version` only + // + // Order matters: strip inbound headers FIRST (per ALWAYS_STRIP + + // pk.strip_headers), THEN set the gateway's own auth. If we did + // the reverse, `reqwest::RequestBuilder::header()` would `append` + // a second value to `Authorization` when the strip list doesn't + // include `authorization` (the customer-elected override case); + // the upstream would receive `Authorization: Bearer , Bearer + // ` on the wire, leaking the client's credential + // regardless of intent. Strip-then-set keeps the wire single- + // valued in the default case; in the override case the client's + // value reaches upstream, which is the documented opt-in cost. + // + // Strip rules: + // 1. ALWAYS_STRIP — HTTP protocol metadata + RFC 7230 §6.1 + // hop-by-hop headers. Non-configurable; stripping these + // is required for protocol correctness. + // 2. provider_key.strip_headers — customer-configurable + // strip list (per ProviderKey, default: authorization, + // cookie, set-cookie, x-api-key). See issue #411. + // + // Case-insensitive comparison; build a lowercased HashSet once + // per request to avoid O(N*M) scans on large header lists. + let strip_set: std::collections::HashSet = pk_entry + .value + .strip_headers + .iter() + .map(|s| s.to_ascii_lowercase()) + .chain(ALWAYS_STRIP.iter().map(|s| (*s).to_string())) + .collect(); + + for (name, value) in &incoming_headers { + let n = name.as_str().to_ascii_lowercase(); + if strip_set.contains(&n) { + continue; + } + builder = builder.header(name, value); + } + + // Gateway's own auth — set AFTER the strip loop. This guarantees + // the upstream sees exactly one `Authorization` (or `x-api-key` + // + `anthropic-version`) line in the default case, even when + // the client sent one of those headers — the client's value + // was filtered out in the loop above. if api_key.is_empty() { // Provider key has no secret configured. Nothing to inject — // explicit blank-Authorization rather than fall-through-to- @@ -273,18 +343,6 @@ async fn dispatch( builder = builder.header(header::AUTHORIZATION, format!("Bearer {api_key}")); } - // Forward safe incoming headers (drop hop-by-hop and auth). - for (name, value) in &incoming_headers { - let n = name.as_str().to_lowercase(); - if matches!( - n.as_str(), - "authorization" | "x-api-key" | "host" | "content-length" - ) { - continue; - } - builder = builder.header(name, value); - } - builder = builder.header("x-aisix-request-id", request_id); if !body_bytes.is_empty() { @@ -705,4 +763,350 @@ mod tests { // 422 from upstream is relayed as-is (not remapped to 502). assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } + + // ---- Issue #411: header-strip policy -------------------------- + // + // Inbound headers must be filtered through: + // 1. ALWAYS_STRIP — RFC 7230 §6.1 hop-by-hop + protocol + // metadata. Non-configurable. + // 2. provider_key.strip_headers — per-PK configurable list. + // Defaults to the 4 canonical credentials. + // + // These tests pin the wire contract upstream observes. + + /// Builds a PK with a caller-supplied `strip_headers` value + /// (overriding the serde default of 4 credentials). + fn provider_key_entry_with_strip( + api_base: &str, + strip_headers: &[&str], + ) -> ResourceEntry { + let strip_json = serde_json::to_string(strip_headers).unwrap(); + let json = format!( + r#"{{"display_name":"openai-up","secret":"sk-test","api_base":"{api_base}","provider":"openai","adapter":"openai","strip_headers":{strip_json}}}"# + ); + let pk: aisix_core::ProviderKey = serde_json::from_str(&json).unwrap(); + ResourceEntry::new(PK_ID, pk, 1) + } + + /// Helper: returns all values associated with `header_name` in the + /// upstream's received request. The mock server uses an + /// http::HeaderMap; `get` only returns the first value when a + /// header has multiple. To verify "client credential didn't + /// leak", we must scan all values (including the gateway's own + /// injection) — anything matching the client's input is a leak. + fn upstream_header_values<'a>( + received: &'a wiremock::Request, + header_name: &str, + ) -> Vec<&'a str> { + received + .headers + .get_all(header_name) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect() + } + + #[tokio::test] + async fn default_strip_blocks_client_credentials_leak() { + let upstream = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&upstream) + .await; + + // new_snap() uses provider_key_entry() which doesn't set + // strip_headers → serde default fills in the 4 credentials. + let snap = new_snap(&upstream.uri()); + snap.models.insert(openai_model("gpt-4o")); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models") + // Authorization is BOTH the gateway-auth credential AND + // a credential that must not leak to upstream. We have + // to send the valid gateway key here (so AuthenticatedKey + // succeeds), then assert upstream sees the PK secret + // (`sk-test`) instead of the client value (`sk-caller`). + .header("authorization", "Bearer sk-caller") + // Cookie: client-side leak canary. Unique value lets us + // verify nothing of this string reached upstream. + .header("cookie", "session=CLIENT-COOKIE-LEAK-CANARY") + // X-API-Key: ditto. + .header("x-api-key", "X-API-KEY-LEAK-CANARY") + // Set-Cookie is a response header; clients don't usually + // send it, but the default strip list includes it anyway + // (defense-in-depth against pathological clients). + .header("set-cookie", "leak-set-cookie=1") + // The customer's own trace-correlation header — NOT in + // the strip list, MUST reach upstream. + .header("x-trace-id", "trace-abc") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + let r = &received[0]; + + // Authorization: gateway sets its own (`Bearer sk-test` from + // the PK secret); client's `Bearer sk-caller` MUST be + // stripped before reaching the wire. + let auths = upstream_header_values(r, "authorization"); + assert_eq!( + auths, + vec!["Bearer sk-test"], + "default strip must replace client Authorization with PK secret only (#411); got: {:?}", + auths + ); + + // Cookie / x-api-key / set-cookie: the gateway never sets + // these on the outbound side (for OpenAI), so absence is + // the right signal. + assert!( + upstream_header_values(r, "cookie").is_empty(), + "cookie must not leak by default (#411)" + ); + assert!( + upstream_header_values(r, "x-api-key").is_empty(), + "x-api-key must not leak by default (#411)" + ); + assert!( + upstream_header_values(r, "set-cookie").is_empty(), + "set-cookie must not leak by default (#411)" + ); + + // Non-stripped header DID reach upstream. + assert_eq!( + upstream_header_values(r, "x-trace-id"), + vec!["trace-abc"], + "non-stripped header must pass through" + ); + } + + #[tokio::test] + async fn always_strip_removes_hop_by_hop_regardless_of_config() { + // PK with empty strip_headers (customer disabled all default + // strips). ALWAYS_STRIP still applies — hop-by-hop / protocol + // headers are non-configurable. + let upstream = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&upstream) + .await; + + let snap = AisixSnapshot::new(); + snap.provider_keys + .insert(provider_key_entry_with_strip(&upstream.uri(), &[])); + snap.models.insert(openai_model("gpt-4o")); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models") + .header("authorization", "Bearer sk-caller") + .header("connection", "keep-alive, x-custom-fake") + .header("upgrade", "websocket") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + let h = &received[0].headers; + assert!( + !h.contains_key("connection"), + "connection must always be stripped (RFC 7230 §6.1)" + ); + assert!( + !h.contains_key("upgrade"), + "upgrade must always be stripped (RFC 7230 §6.1)" + ); + } + + #[tokio::test] + async fn empty_strip_list_lets_credentials_through() { + // The dangerous-but-legal "I unchecked all defaults in the + // dashboard" override case. Customer takes the risk; the + // gateway respects the explicit configuration. Documented + // in the dashboard's confirmation flow. + // + // In this case the upstream's Authorization HeaderMap entry + // has BOTH values (client's + gateway's): `Bearer sk-caller, + // Bearer sk-test` on the wire. We assert the client's value + // appears among them — that proves the strip didn't run, the + // override worked. We DON'T assert the wire order; reqwest + // append semantics put gateway's value first since it's added + // after the loop, but the test should be robust to either + // ordering decision. + let upstream = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&upstream) + .await; + + let snap = AisixSnapshot::new(); + snap.provider_keys + .insert(provider_key_entry_with_strip(&upstream.uri(), &[])); + snap.models.insert(openai_model("gpt-4o")); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models") + .header("authorization", "Bearer sk-caller") + .header("cookie", "session=letitthrough") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + let r = &received[0]; + + // Authorization: should have BOTH client's and gateway's + // values present. + let auths = upstream_header_values(r, "authorization"); + assert!( + auths.contains(&"Bearer sk-caller"), + "empty strip_headers must let client Authorization through; got: {:?}", + auths + ); + // Cookie: only client's value; gateway doesn't set cookie. + assert_eq!( + upstream_header_values(r, "cookie"), + vec!["session=letitthrough"], + "empty strip_headers must let cookie through" + ); + } + + #[tokio::test] + async fn custom_strip_list_strips_only_named_headers() { + // Customer overrides defaults — drops `cookie` from the + // strip list (cookie passes through) but adds custom + // `x-internal-trace-id` (gets stripped). + let upstream = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&upstream) + .await; + + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(provider_key_entry_with_strip( + &upstream.uri(), + // No "cookie" — cookie passes through. New "x-internal-trace-id" + // gets stripped. authorization still stripped. + &["authorization", "x-internal-trace-id"], + )); + snap.models.insert(openai_model("gpt-4o")); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models") + .header("authorization", "Bearer sk-caller") + .header("cookie", "tracker=stays") + .header("x-internal-trace-id", "internal-12345") + .header("x-public-trace-id", "public-67890") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + let r = &received[0]; + + // Authorization is in the custom strip list → client's + // value must not appear; gateway's own auth (PK secret) + // IS present. + let auths = upstream_header_values(r, "authorization"); + assert!( + !auths.iter().any(|v| v.contains("sk-caller")), + "authorization in custom strip list → client value must not leak; got: {:?}", + auths + ); + assert!( + auths.contains(&"Bearer sk-test"), + "gateway's own auth still present" + ); + + // Cookie is NOT in custom strip list → client's value + // reaches upstream. + assert_eq!( + upstream_header_values(r, "cookie"), + vec!["tracker=stays"], + "cookie removed from strip list → passes through" + ); + + // x-internal-trace-id is in custom strip list → gone. + assert!( + upstream_header_values(r, "x-internal-trace-id").is_empty(), + "custom-added strip → removed" + ); + + // x-public-trace-id is NOT stripped → reaches upstream. + assert_eq!( + upstream_header_values(r, "x-public-trace-id"), + vec!["public-67890"], + "header not in strip list → passes through" + ); + } + + #[tokio::test] + async fn strip_header_match_is_case_insensitive() { + // Inbound header keys can be ANY case. The strip list is + // lowercased; the comparison must be case-insensitive too. + let upstream = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&upstream) + .await; + + let snap = new_snap(&upstream.uri()); + snap.models.insert(openai_model("gpt-4o")); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models") + // axum will lower-case the keys via http::HeaderName but + // the original-case roundtrip is preserved on some paths; + // covering it explicitly anchors the contract. + .header("Authorization", "Bearer sk-caller") + .header("Cookie", "session=case") + .body(axum::body::Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + let r = &received[0]; + // Even with mixed-case input headers, the lowercased strip + // set matches → client's leaks must not appear. + let auths = upstream_header_values(r, "authorization"); + assert!( + !auths.iter().any(|v| v.contains("sk-caller")), + "case-insensitive strip: client Authorization must not leak" + ); + assert!( + upstream_header_values(r, "cookie").is_empty(), + "case-insensitive strip: cookie must be removed" + ); + } } diff --git a/schemas/resources/provider_key.schema.json b/schemas/resources/provider_key.schema.json index 644f62fe..43f4f2d8 100644 --- a/schemas/resources/provider_key.schema.json +++ b/schemas/resources/provider_key.schema.json @@ -60,6 +60,19 @@ "description": "Upstream provider's API key, stored in plaintext on the standalone path (the etcd channel is mTLS-only — same trust boundary as Guardrail credentials and ObservabilityExporter headers). On the AISIX-Cloud path cp-api decrypts the envelope-encrypted secret at projection time and writes the plaintext here.", "type": "string" }, + "strip_headers": { + "description": "Inbound request headers to strip before forwarding to the upstream provider on the passthrough endpoint (#411).\n\nDefaults (when the field is absent on the wire) to the 4 canonical credential headers: `authorization`, `cookie`, `set-cookie`, `x-api-key`. Customers can: - Remove a default entry → that header reaches upstream (the dashboard warns when removing a default). - Add custom entries → extra headers stripped.\n\nCase-insensitive. Compared lowercased against the inbound header name. Non-configurable headers (`host`, `content-length`, RFC 7230 §6.1 hop-by-hop) are stripped separately by the passthrough handler and cannot be removed via this list.\n\nEntries are normalised on deserialize via `normalize_strip_headers`: trimmed, lowercased, dedup'd, empties dropped. This prevents the \"operator typed `' cookie '`, the strip set has `' cookie '` but the inbound header is `'cookie'` → no match → silent credential leak\" footgun.", + "default": [ + "authorization", + "cookie", + "set-cookie", + "x-api-key" + ], + "type": "array", + "items": { + "type": "string" + } + }, "telemetry_tags": { "description": "Telemetry tags carried alongside the key for metric/log emission. Introduced as a skeleton for issue #302 Phase A. No metric path consumes these tags yet; the field exists so future Phase A sub-PRs can attribute traffic without an on-disk schema break. Old payloads that omit `telemetry_tags` fall back to the `Default` impl via `#[serde(default)]`.", "default": {