diff --git a/crates/tinymemory-integrations/src/config/mod.rs b/crates/tinymemory-integrations/src/config/mod.rs index 39620445..e8ccf69b 100644 --- a/crates/tinymemory-integrations/src/config/mod.rs +++ b/crates/tinymemory-integrations/src/config/mod.rs @@ -72,6 +72,11 @@ pub struct EngineSettings { /// The engine's base URL; `None` uses the engine's default endpoint. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, + /// Fixed headers sent on every request, such as the host's product + /// attribution (`x-sdk-name`). Never a credential: the transport refuses + /// `Authorization` and the other headers it sets itself. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, } #[cfg(test)] diff --git a/crates/tinymemory-integrations/src/cortex/README.md b/crates/tinymemory-integrations/src/cortex/README.md index 582b3732..8464e5bb 100644 --- a/crates/tinymemory-integrations/src/cortex/README.md +++ b/crates/tinymemory-integrations/src/cortex/README.md @@ -203,6 +203,13 @@ These were measured against a live CortexDB by the v1 adapter. The doubles in ## Transport +- A host may fix non-credential headers on every request + (`CortexEngine::with_default_headers`, or `EngineSettings::headers` through + the registry), such as the `x-sdk-name` attribution the TinyHumans backend + expects. Reserved headers (`Authorization`, `Proxy-Authorization`, `Cookie`, + `Host`, `Content-Length`, `Idempotency-Key`, `X-Cortex-Actor`) are refused + with `Error::Config`, and no refusal echoes a value. + - Credentialed cleartext endpoints that are not loopback are refused with `Error::Config`. - The bearer is resolved on every attempt and sent in a header marked diff --git a/crates/tinymemory-integrations/src/cortex/engine/mod.rs b/crates/tinymemory-integrations/src/cortex/engine/mod.rs index f14253ff..88b20c4c 100644 --- a/crates/tinymemory-integrations/src/cortex/engine/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/engine/mod.rs @@ -105,6 +105,28 @@ impl CortexEngine { ) } + /// The same engine, sending `headers` on every request: fixed, + /// non-credential headers such as a host's product attribution + /// (`x-sdk-name`). Later calls replace earlier ones. + /// + /// # Errors + /// + /// [`Error::Config`] for an invalid header name or value, or a header the + /// transport sets itself (`Authorization`, `Idempotency-Key`, the actor + /// header, `Host`, `Cookie`, …). + pub fn with_default_headers( + mut self, + headers: impl IntoIterator, + ) -> Result + where + K: AsRef, + V: AsRef, + { + let map = crate::cortex::transport::default_headers(headers)?; + self.log.client.set_default_headers(map); + Ok(self) + } + /// Which HTTP surface this engine talks to. #[must_use] pub fn wire(&self) -> CortexWire { diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod.rs b/crates/tinymemory-integrations/src/cortex/transport/mod.rs index 6d6d61fd..af685cb7 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod.rs @@ -17,7 +17,7 @@ mod failure; use std::time::Duration; -use reqwest::header::{AUTHORIZATION, HeaderValue}; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; use reqwest::{Method, RequestBuilder, Url}; use serde_json::Value; @@ -59,6 +59,54 @@ pub(crate) struct HttpClient { wire: CortexWire, read_backoff: Duration, actor: actor::ActorCache, + /// Fixed headers the host attaches to every request (see + /// [`default_headers`]). + default_headers: HeaderMap, +} + +/// Headers a host may not fix: the credential, the claim and actor this +/// transport sets itself, and what the HTTP stack owns. +const RESERVED_HEADERS: [&str; 7] = [ + "authorization", + "proxy-authorization", + "cookie", + "host", + "content-length", + "idempotency-key", + actor::ACTOR_HEADER, +]; + +/// The header map for `pairs`: fixed, non-credential headers a host sends +/// on every request, such as its product attribution (`x-sdk-name`). +/// +/// # Errors +/// +/// [`Error::Config`] for a name or value no header may carry, or a reserved +/// name (the credential, `Idempotency-Key`, the actor header, and what the +/// HTTP stack sets). No message carries a value. +pub(crate) fn default_headers(pairs: impl IntoIterator) -> Result +where + K: AsRef, + V: AsRef, +{ + let mut map = HeaderMap::new(); + for (name, value) in pairs { + let name = name.as_ref().trim(); + let parsed = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error::Config(format!("`{name}` is not a valid header name")))?; + if RESERVED_HEADERS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(parsed.as_str())) + { + return Err(Error::Config(format!( + "`{name}` is set by the memory transport and cannot be fixed" + ))); + } + let value = HeaderValue::from_str(value.as_ref().trim()) + .map_err(|_| Error::Config(format!("the `{name}` header value is not valid")))?; + map.insert(parsed, value); + } + Ok(map) } impl std::fmt::Debug for HttpClient { @@ -129,9 +177,15 @@ impl HttpClient { wire, read_backoff: READ_BACKOFF, actor: actor::ActorCache::default(), + default_headers: HeaderMap::new(), }) } + /// Sends `headers` on every request from now on. + pub(crate) fn set_default_headers(&mut self, headers: HeaderMap) { + self.default_headers = headers; + } + /// The wire this client speaks. pub(crate) fn wire(&self) -> CortexWire { self.wire @@ -166,6 +220,7 @@ impl HttpClient { Ok(self .inner .request(method, url) + .headers(self.default_headers.clone()) .header(AUTHORIZATION, header)) } diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs index f46b8377..b2e05824 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs @@ -312,3 +312,40 @@ async fn the_hosted_wire_sends_no_actor() { "the backend names the actor" ); } + +#[test] +fn fixed_headers_refuse_reserved_and_malformed_entries() { + let map = default_headers([("x-sdk-name", "openhuman"), ("X-Product", "desktop")]).unwrap(); + assert_eq!(map.get("x-sdk-name").unwrap(), "openhuman"); + for reserved in [ + "authorization", + "Proxy-Authorization", + "cookie", + "host", + "Idempotency-Key", + "x-cortex-actor", + ] { + assert!( + matches!(default_headers([(reserved, "v")]), Err(Error::Config(_))), + "{reserved} must be refused" + ); + } + assert!(default_headers([("bad name", "v")]).is_err()); + let bad_value = default_headers([("x-sdk-name", "a\r\nX-Injected: 1")]) + .expect_err("a CR/LF value is refused"); + assert!(!bad_value.to_string().contains("Injected")); +} + +#[tokio::test] +async fn fixed_headers_ride_every_request_beside_the_credential() { + let mut c = client("https://example.test"); + c.set_default_headers(default_headers([("x-sdk-name", "openhuman")]).unwrap()); + let request = c + .request(Method::GET, "v1/events") + .await + .unwrap() + .build() + .unwrap(); + assert_eq!(request.headers().get("x-sdk-name").unwrap(), "openhuman"); + assert!(request.headers().get(AUTHORIZATION).unwrap().is_sensitive()); +} diff --git a/crates/tinymemory-integrations/src/registry/mod.rs b/crates/tinymemory-integrations/src/registry/mod.rs index 564d95d3..d12726a7 100644 --- a/crates/tinymemory-integrations/src/registry/mod.rs +++ b/crates/tinymemory-integrations/src/registry/mod.rs @@ -93,7 +93,9 @@ pub fn build_engine( ))); } }; - Ok(Arc::new(CortexEngine::new(wire, endpoint, credential)?)) + let engine = + CortexEngine::new(wire, endpoint, credential)?.with_default_headers(&settings.headers)?; + Ok(Arc::new(engine)) } #[cfg(test)] diff --git a/crates/tinymemory-integrations/src/registry/mod_tests.rs b/crates/tinymemory-integrations/src/registry/mod_tests.rs index 86203f10..70d97f98 100644 --- a/crates/tinymemory-integrations/src/registry/mod_tests.rs +++ b/crates/tinymemory-integrations/src/registry/mod_tests.rs @@ -7,6 +7,7 @@ use super::*; fn settings(endpoint: Option<&str>) -> EngineSettings { EngineSettings { endpoint: endpoint.map(str::to_string), + ..EngineSettings::default() } } @@ -142,3 +143,32 @@ fn credential_debug_never_shows_the_token() { "EngineCredential::None" ); } + +#[test] +fn fixed_headers_are_applied_and_a_credential_header_is_refused() { + let mut with_headers = settings(Some("https://cortex.example.test")); + with_headers + .headers + .insert("x-sdk-name".to_string(), "openhuman".to_string()); + assert!( + build_engine( + CORTEXDB_ENGINE_ID, + &with_headers, + EngineCredential::Static("ctx_key".to_string()) + ) + .is_ok() + ); + + with_headers + .headers + .insert("Authorization".to_string(), "Bearer smuggled".to_string()); + let refused = build_engine( + CORTEXDB_ENGINE_ID, + &with_headers, + EngineCredential::Static("ctx_key".to_string()), + ) + .err() + .expect("a credential header is refused"); + assert!(matches!(refused, Error::Config(_)), "{refused:?}"); + assert!(!refused.to_string().contains("smuggled")); +} diff --git a/docs/integration.md b/docs/integration.md index 92754fdf..fbe6e9c8 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -76,6 +76,14 @@ let config: MemoryConfig = serde_json::from_value(serde_json::json!({ let engine = config.build(EngineCredential::Static(key))?; ``` +A host that must attribute its requests (the TinyHumans backend expects an +`x-sdk-name` header naming the product) fixes them per engine, in +configuration (`"engines": { "tinyhumans": { "headers": { "x-sdk-name": +"my-product" } } }`) or with `CortexEngine::with_default_headers`. They ride +every request; the transport refuses `Authorization`, `Idempotency-Key`, the +actor header and the headers the HTTP stack sets, so the credential stays +the `EngineCredential`'s alone. + | Engine id | Where it runs | Consolidation (belief builds) | | --- | --- | --- | | `cortexdb` | a CortexDB server, `v1/*` routes | on demand: `v1/beliefs/build`, built within the request |