Skip to content

Commit f582b3a

Browse files
committed
fix(inference): allowlist routed request headers
Signed-off-by: John Myers <[email protected]>
1 parent 355d845 commit f582b3a

File tree

12 files changed

+412
-93
lines changed

12 files changed

+412
-93
lines changed

architecture/inference-routing.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,17 @@ Files:
171171
`prepare_backend_request()` (shared by both buffered and streaming paths) rewrites outgoing requests:
172172

173173
1. **Auth injection**: Uses the route's `AuthHeader` -- either `Authorization: Bearer <key>` or a custom header (e.g. `x-api-key: <key>` for Anthropic).
174-
2. **Header stripping**: Removes `authorization`, `x-api-key`, `host`, and any header names that will be set from route defaults.
175-
3. **Default headers**: Applies route-level default headers (e.g. `anthropic-version: 2023-06-01`) unless the client already sent them.
176-
4. **Model rewrite**: Parses the request body as JSON and replaces the `model` field with the route's configured model. Non-JSON bodies are forwarded unchanged.
177-
5. **URL construction**: `build_backend_url()` appends the request path to the route endpoint. If the endpoint already ends with `/v1` and the request path starts with `/v1/`, the duplicate prefix is deduplicated.
174+
2. **Header allowlist**: Keeps only explicitly approved request headers: common inference headers (`content-type`, `accept`, `accept-encoding`, `user-agent`), route-specific passthrough headers (for example `openai-organization`, `x-model-id`, `anthropic-version`, `anthropic-beta`), and any route default header names.
175+
3. **Header stripping**: Removes `authorization`, `x-api-key`, `host`, `content-length`, hop-by-hop headers, and any non-allowlisted request headers.
176+
4. **Default headers**: Applies route-level default headers (e.g. `anthropic-version: 2023-06-01`) unless the client already sent them.
177+
5. **Model rewrite**: Parses the request body as JSON and replaces the `model` field with the route's configured model. Non-JSON bodies are forwarded unchanged.
178+
6. **URL construction**: `build_backend_url()` appends the request path to the route endpoint. If the endpoint already ends with `/v1` and the request path starts with `/v1/`, the duplicate prefix is deduplicated.
178179

179180
### Header sanitization
180181

181-
Before forwarding inference requests, the proxy strips sensitive and hop-by-hop headers from both requests and responses:
182+
Before forwarding inference requests, the router enforces a route-aware request allowlist and strips sensitive/framing headers. Response sanitization remains framing-only:
182183

183-
- **Request**: `authorization`, `x-api-key`, `host`, `content-length`, and hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`).
184+
- **Request**: forwards only common inference headers plus route-specific passthrough headers and route default header names. Always strips `authorization`, `x-api-key`, `host`, `content-length`, unknown headers such as `cookie`, and hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`).
184185
- **Response**: `content-length` and hop-by-hop headers.
185186

186187
### Response streaming

architecture/sandbox.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,9 +839,9 @@ The interception steps:
839839

840840
Pattern matching strips query strings. Exact path comparison is used for most patterns; the `/v1/models/*` pattern matches `/v1/models` itself or any path under `/v1/models/` (e.g., `/v1/models/gpt-4.1`).
841841

842-
4. **Header sanitization**: For matched inference requests, the proxy strips credential headers (`Authorization`, `x-api-key`) and framing/hop-by-hop headers (`host`, `content-length`, `transfer-encoding`, `connection`, etc.). The router rebuilds correct framing for the forwarded body.
842+
4. **Header sanitization**: For matched inference requests, the proxy passes the parsed headers to the router. The router applies a route-aware allowlist before forwarding: common inference headers (`content-type`, `accept`, `accept-encoding`, `user-agent`), provider-specific passthrough headers (for example `openai-organization`, `x-model-id`, `anthropic-version`, `anthropic-beta`), and any route default header names. It always strips client-supplied credential headers (`Authorization`, `x-api-key`) and framing/hop-by-hop headers (`host`, `content-length`, `transfer-encoding`, `connection`, etc.). The router rebuilds correct framing for the forwarded body.
843843

844-
5. **Local routing**: Matched requests are executed by calling `Router::proxy_with_candidates_streaming()`, passing the detected protocol, HTTP method, path, sanitized headers, body, and the cached `ResolvedRoute` list from `InferenceContext`. The router selects the first route whose `protocols` list contains the source protocol (see [Inference Routing -- Response streaming](inference-routing.md#response-streaming) for details). When forwarding to the backend, the router rewrites the request: the route's `api_key` replaces the `Authorization` header, the `Host` header is set to the backend endpoint, and the `"model"` field in the JSON request body is replaced with the route's configured `model` value. If the request body is not valid JSON or does not contain a `"model"` key, the body is forwarded unchanged.
844+
5. **Local routing**: Matched requests are executed by calling `Router::proxy_with_candidates_streaming()`, passing the detected protocol, HTTP method, path, original parsed headers, body, and the cached `ResolvedRoute` list from `InferenceContext`. The router selects the first route whose `protocols` list contains the source protocol (see [Inference Routing -- Response streaming](inference-routing.md#response-streaming) for details). When forwarding to the backend, the router rewrites the request: the route's `api_key` replaces the client auth header, the `Host` header is set to the backend endpoint, only allowlisted request headers survive, and the `"model"` field in the JSON request body is replaced with the route's configured `model` value. If the request body is not valid JSON or does not contain a `"model"` key, the body is forwarded unchanged.
845845

846846
6. **Response handling (streaming)**:
847847
- On success: response headers are sent back to the client immediately as an HTTP/1.1 response with `Transfer-Encoding: chunked`, using `format_http_response_header()`. Framing/hop-by-hop headers are stripped from the upstream response. Body chunks are then forwarded incrementally as they arrive from the backend via `StreamingProxyResponse::next_chunk()`, each wrapped in HTTP chunked encoding by `format_chunk()`. The stream is terminated with a `0\r\n\r\n` chunk terminator. This ensures time-to-first-byte reflects the backend's first token latency rather than the full generation time.

crates/openshell-core/src/inference.rs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ pub enum AuthHeader {
2828
///
2929
/// This is the single source of truth for provider-specific inference knowledge:
3030
/// default endpoint, supported protocols, credential key lookup order, auth
31-
/// header style, and default headers.
31+
/// header style, default headers, and allowed client-supplied passthrough
32+
/// headers.
3233
///
3334
/// This is separate from [`openshell_providers::ProviderPlugin`] which handles
3435
/// credential *discovery* (scanning env vars). `InferenceProviderProfile` handles
@@ -45,6 +46,10 @@ pub struct InferenceProviderProfile {
4546
pub auth: AuthHeader,
4647
/// Default headers injected on every outgoing request.
4748
pub default_headers: &'static [(&'static str, &'static str)],
49+
/// Client-supplied headers that may be forwarded to the upstream backend.
50+
///
51+
/// Header names must be lowercase and must not include auth headers.
52+
pub passthrough_headers: &'static [&'static str],
4853
}
4954

5055
const OPENAI_PROTOCOLS: &[&str] = &[
@@ -64,6 +69,7 @@ static OPENAI_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
6469
base_url_config_keys: &["OPENAI_BASE_URL"],
6570
auth: AuthHeader::Bearer,
6671
default_headers: &[],
72+
passthrough_headers: &["openai-organization", "x-model-id"],
6773
};
6874

6975
static ANTHROPIC_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
@@ -74,6 +80,7 @@ static ANTHROPIC_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
7480
base_url_config_keys: &["ANTHROPIC_BASE_URL"],
7581
auth: AuthHeader::Custom("x-api-key"),
7682
default_headers: &[("anthropic-version", "2023-06-01")],
83+
passthrough_headers: &["anthropic-version", "anthropic-beta"],
7784
};
7885

7986
static NVIDIA_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
@@ -84,6 +91,7 @@ static NVIDIA_PROFILE: InferenceProviderProfile = InferenceProviderProfile {
8491
base_url_config_keys: &["NVIDIA_BASE_URL"],
8592
auth: AuthHeader::Bearer,
8693
default_headers: &[],
94+
passthrough_headers: &["x-model-id"],
8795
};
8896

8997
/// Look up the inference provider profile for a given provider type.
@@ -105,16 +113,32 @@ pub fn profile_for(provider_type: &str) -> Option<&'static InferenceProviderProf
105113
/// need the auth/header information (e.g. the sandbox bundle-to-route
106114
/// conversion).
107115
pub fn auth_for_provider_type(provider_type: &str) -> (AuthHeader, Vec<(String, String)>) {
116+
let (auth, headers, _) = route_headers_for_provider_type(provider_type);
117+
(auth, headers)
118+
}
119+
120+
/// Derive routing header policy for a provider type string.
121+
///
122+
/// Returns the auth injection mode, route-level default headers, and the
123+
/// allowed client-supplied passthrough headers for `inference.local`.
124+
pub fn route_headers_for_provider_type(
125+
provider_type: &str,
126+
) -> (AuthHeader, Vec<(String, String)>, Vec<String>) {
108127
match profile_for(provider_type) {
109128
Some(profile) => {
110129
let headers = profile
111130
.default_headers
112131
.iter()
113132
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
114133
.collect();
115-
(profile.auth.clone(), headers)
134+
let passthrough_headers = profile
135+
.passthrough_headers
136+
.iter()
137+
.map(|name| (*name).to_string())
138+
.collect();
139+
(profile.auth.clone(), headers, passthrough_headers)
116140
}
117-
None => (AuthHeader::Bearer, Vec::new()),
141+
None => (AuthHeader::Bearer, Vec::new(), Vec::new()),
118142
}
119143
}
120144

@@ -193,6 +217,32 @@ mod tests {
193217
assert!(headers.iter().any(|(k, _)| k == "anthropic-version"));
194218
}
195219

220+
#[test]
221+
fn route_headers_for_openai_include_passthrough_headers() {
222+
let (_, _, passthrough_headers) = route_headers_for_provider_type("openai");
223+
assert!(
224+
passthrough_headers
225+
.iter()
226+
.any(|name| name == "openai-organization")
227+
);
228+
assert!(passthrough_headers.iter().any(|name| name == "x-model-id"));
229+
}
230+
231+
#[test]
232+
fn route_headers_for_anthropic_include_passthrough_headers() {
233+
let (_, _, passthrough_headers) = route_headers_for_provider_type("anthropic");
234+
assert!(
235+
passthrough_headers
236+
.iter()
237+
.any(|name| name == "anthropic-version")
238+
);
239+
assert!(
240+
passthrough_headers
241+
.iter()
242+
.any(|name| name == "anthropic-beta")
243+
);
244+
}
245+
196246
#[test]
197247
fn auth_for_openai_uses_bearer() {
198248
let (auth, headers) = auth_for_provider_type("openai");
@@ -206,4 +256,12 @@ mod tests {
206256
assert_eq!(auth, AuthHeader::Bearer);
207257
assert!(headers.is_empty());
208258
}
259+
260+
#[test]
261+
fn route_headers_for_unknown_are_empty() {
262+
let (auth, headers, passthrough_headers) = route_headers_for_provider_type("unknown");
263+
assert_eq!(auth, AuthHeader::Bearer);
264+
assert!(headers.is_empty());
265+
assert!(passthrough_headers.is_empty());
266+
}
209267
}

crates/openshell-router/src/backend.rs

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use crate::RouterError;
55
use crate::config::{AuthHeader, ResolvedRoute};
66
use crate::mock;
7+
use std::collections::HashSet;
78

89
#[derive(Debug, Clone, PartialEq, Eq)]
910
pub struct ValidatedEndpoint {
@@ -62,6 +63,9 @@ enum StreamingBody {
6263
Buffered(Option<bytes::Bytes>),
6364
}
6465

66+
const COMMON_INFERENCE_REQUEST_HEADERS: [&str; 4] =
67+
["content-type", "accept", "accept-encoding", "user-agent"];
68+
6569
impl StreamingProxyResponse {
6670
/// Create from a fully-buffered [`ProxyResponse`] (for mock routes).
6771
pub fn from_buffered(resp: ProxyResponse) -> Self {
@@ -83,7 +87,64 @@ impl StreamingProxyResponse {
8387
}
8488
}
8589

86-
/// Build an HTTP request to the backend configured in `route`.
90+
fn sanitize_request_headers(
91+
route: &ResolvedRoute,
92+
headers: &[(String, String)],
93+
) -> Vec<(String, String)> {
94+
let mut allowed = HashSet::new();
95+
allowed.extend(
96+
COMMON_INFERENCE_REQUEST_HEADERS
97+
.iter()
98+
.map(|name| (*name).to_string()),
99+
);
100+
allowed.extend(
101+
route
102+
.passthrough_headers
103+
.iter()
104+
.map(|name| name.to_ascii_lowercase()),
105+
);
106+
allowed.extend(
107+
route
108+
.default_headers
109+
.iter()
110+
.map(|(name, _)| name.to_ascii_lowercase()),
111+
);
112+
113+
headers
114+
.iter()
115+
.filter_map(|(name, value)| {
116+
let name_lc = name.to_ascii_lowercase();
117+
if should_strip_request_header(&name_lc) || !allowed.contains(&name_lc) {
118+
return None;
119+
}
120+
Some((name.clone(), value.clone()))
121+
})
122+
.collect()
123+
}
124+
125+
fn should_strip_request_header(name: &str) -> bool {
126+
matches!(
127+
name,
128+
"authorization" | "x-api-key" | "host" | "content-length"
129+
) || is_hop_by_hop_header(name)
130+
}
131+
132+
fn is_hop_by_hop_header(name: &str) -> bool {
133+
matches!(
134+
name,
135+
"connection"
136+
| "keep-alive"
137+
| "proxy-authenticate"
138+
| "proxy-authorization"
139+
| "proxy-connection"
140+
| "te"
141+
| "trailer"
142+
| "transfer-encoding"
143+
| "upgrade"
144+
)
145+
}
146+
147+
/// Build and send an HTTP request to the backend configured in `route`.
87148
///
88149
/// Returns the prepared [`reqwest::RequestBuilder`] with auth, headers, model
89150
/// rewrite, and body applied. The caller decides whether to apply a total
@@ -97,6 +158,7 @@ fn prepare_backend_request(
97158
body: bytes::Bytes,
98159
) -> Result<(reqwest::RequestBuilder, String), RouterError> {
99160
let url = build_backend_url(&route.endpoint, path);
161+
let headers = sanitize_request_headers(route, &headers);
100162

101163
let reqwest_method: reqwest::Method = method
102164
.parse()
@@ -113,17 +175,7 @@ fn prepare_backend_request(
113175
builder = builder.header(*header_name, &route.api_key);
114176
}
115177
}
116-
117-
// Strip auth and host headers — auth is re-injected above from the route
118-
// config, and host must match the upstream.
119-
let strip_headers: [&str; 3] = ["authorization", "x-api-key", "host"];
120-
121-
// Forward non-sensitive headers.
122-
for (name, value) in headers {
123-
let name_lc = name.to_ascii_lowercase();
124-
if strip_headers.contains(&name_lc.as_str()) {
125-
continue;
126-
}
178+
for (name, value) in &headers {
127179
builder = builder.header(name.as_str(), value.as_str());
128180
}
129181

@@ -510,10 +562,95 @@ mod tests {
510562
protocols: protocols.iter().map(|p| (*p).to_string()).collect(),
511563
auth,
512564
default_headers: vec![("anthropic-version".to_string(), "2023-06-01".to_string())],
565+
passthrough_headers: vec![
566+
"anthropic-version".to_string(),
567+
"anthropic-beta".to_string(),
568+
],
513569
timeout: crate::config::DEFAULT_ROUTE_TIMEOUT,
514570
}
515571
}
516572

573+
#[test]
574+
fn sanitize_request_headers_drops_unknown_sensitive_headers() {
575+
let route = ResolvedRoute {
576+
name: "inference.local".to_string(),
577+
endpoint: "https://api.example.com/v1".to_string(),
578+
model: "test-model".to_string(),
579+
api_key: "sk-test".to_string(),
580+
protocols: vec!["openai_chat_completions".to_string()],
581+
auth: AuthHeader::Bearer,
582+
default_headers: Vec::new(),
583+
passthrough_headers: vec!["openai-organization".to_string()],
584+
timeout: crate::config::DEFAULT_ROUTE_TIMEOUT,
585+
};
586+
587+
let kept = super::sanitize_request_headers(
588+
&route,
589+
&[
590+
("content-type".to_string(), "application/json".to_string()),
591+
("authorization".to_string(), "Bearer client".to_string()),
592+
("cookie".to_string(), "session=1".to_string()),
593+
("x-amz-security-token".to_string(), "token".to_string()),
594+
("openai-organization".to_string(), "org_123".to_string()),
595+
],
596+
);
597+
598+
assert!(
599+
kept.iter()
600+
.any(|(name, _)| name.eq_ignore_ascii_case("content-type"))
601+
);
602+
assert!(
603+
kept.iter()
604+
.any(|(name, _)| name.eq_ignore_ascii_case("openai-organization"))
605+
);
606+
assert!(
607+
kept.iter()
608+
.all(|(name, _)| !name.eq_ignore_ascii_case("authorization"))
609+
);
610+
assert!(
611+
kept.iter()
612+
.all(|(name, _)| !name.eq_ignore_ascii_case("cookie"))
613+
);
614+
assert!(
615+
kept.iter()
616+
.all(|(name, _)| !name.eq_ignore_ascii_case("x-amz-security-token"))
617+
);
618+
}
619+
620+
#[test]
621+
fn sanitize_request_headers_preserves_allowed_provider_headers() {
622+
let route = test_route(
623+
"https://api.anthropic.com/v1",
624+
&["anthropic_messages"],
625+
AuthHeader::Custom("x-api-key"),
626+
);
627+
628+
let kept = super::sanitize_request_headers(
629+
&route,
630+
&[
631+
("anthropic-version".to_string(), "2024-10-22".to_string()),
632+
(
633+
"anthropic-beta".to_string(),
634+
"tool-use-2024-10-22".to_string(),
635+
),
636+
("x-api-key".to_string(), "client-key".to_string()),
637+
],
638+
);
639+
640+
assert!(kept.iter().any(
641+
|(name, value)| name.eq_ignore_ascii_case("anthropic-version") && value == "2024-10-22"
642+
));
643+
assert!(
644+
kept.iter()
645+
.any(|(name, value)| name.eq_ignore_ascii_case("anthropic-beta")
646+
&& value == "tool-use-2024-10-22")
647+
);
648+
assert!(
649+
kept.iter()
650+
.all(|(name, _)| !name.eq_ignore_ascii_case("x-api-key"))
651+
);
652+
}
653+
517654
#[tokio::test]
518655
async fn verify_backend_endpoint_uses_route_auth_and_shape() {
519656
let mock_server = MockServer::start().await;

0 commit comments

Comments
 (0)