Skip to content

Commit a5c5b9f

Browse files
committed
fix(core): support custom backend response headers
1 parent dc18114 commit a5c5b9f

File tree

6 files changed

+186
-51
lines changed

6 files changed

+186
-51
lines changed

crates/cf-workers/src/backend.rs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use multistore::backend::ForwardResponse;
1212
use multistore::backend::{build_signer, create_builder, ProxyBackend, RawResponse, StoreBuilder};
1313
use multistore::error::ProxyError;
1414
use multistore::route_handler::ForwardRequest;
15-
use multistore::route_handler::RESPONSE_HEADER_ALLOWLIST;
1615
use multistore::types::BucketConfig;
16+
1717
use object_store::list::PaginatedListStore;
1818
use object_store::signer::Signer;
1919
use std::sync::Arc;
@@ -81,8 +81,7 @@ impl ProxyBackend for WorkerBackend {
8181
let backend_ws: web_sys::Response = worker_resp.into();
8282
let status = backend_ws.status();
8383

84-
// Build filtered response headers using the existing allowlist.
85-
let headers = extract_response_headers(&backend_ws.headers());
84+
let headers = convert_ws_headers(&backend_ws.headers());
8685
let content_length = headers
8786
.get(http::header::CONTENT_LENGTH)
8887
.and_then(|v| v.to_str().ok())
@@ -166,9 +165,8 @@ impl ProxyBackend for WorkerBackend {
166165
.await
167166
.map_err(|e| ProxyError::Internal(format!("failed to read response: {}", e)))?;
168167

169-
// Convert response headers
170168
let ws_response: web_sys::Response = worker_resp.into();
171-
let resp_headers = extract_response_headers(&ws_response.headers());
169+
let resp_headers = convert_ws_headers(&ws_response.headers());
172170

173171
Ok(RawResponse {
174172
status,
@@ -178,15 +176,25 @@ impl ProxyBackend for WorkerBackend {
178176
}
179177
}
180178

181-
/// Extract response headers from a `web_sys::Headers` using an allowlist.
182-
pub fn extract_response_headers(ws_headers: &web_sys::Headers) -> HeaderMap {
183-
let mut resp_headers = HeaderMap::new();
184-
for name in RESPONSE_HEADER_ALLOWLIST {
185-
if let Ok(Some(value)) = ws_headers.get(name) {
186-
if let Ok(parsed) = value.parse() {
187-
resp_headers.insert(*name, parsed);
179+
/// Convert `web_sys::Headers` into an `http::HeaderMap`.
180+
fn convert_ws_headers(ws_headers: &web_sys::Headers) -> HeaderMap {
181+
let mut out = HeaderMap::new();
182+
if let Ok(iter) = js_sys::try_iter(&ws_headers.entries()) {
183+
if let Some(iter) = iter {
184+
for entry in iter.flatten() {
185+
let pair = js_sys::Array::from(&entry);
186+
if let (Some(name), Some(value)) =
187+
(pair.get(0).as_string(), pair.get(1).as_string())
188+
{
189+
if let (Ok(hn), Ok(hv)) = (
190+
name.parse::<http::header::HeaderName>(),
191+
value.parse::<http::header::HeaderValue>(),
192+
) {
193+
out.insert(hn, hv);
194+
}
195+
}
188196
}
189197
}
190198
}
191-
resp_headers
199+
out
192200
}

crates/core/src/proxy.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ const PRESIGNED_URL_TTL: Duration = Duration::from_secs(300);
7777

7878
// Re-export types that were historically defined here for backwards compatibility.
7979
pub use crate::route_handler::{
80-
ForwardRequest, HandlerAction, PendingRequest, ProxyResult, RESPONSE_HEADER_ALLOWLIST,
80+
filter_response_headers, ForwardRequest, HandlerAction, PendingRequest, ProxyResult,
81+
RESPONSE_HEADER_DENYLIST,
8182
};
8283

8384
/// Simplified two-variant result from [`ProxyGateway::handle_request`].
@@ -236,7 +237,10 @@ where
236237
return match action {
237238
HandlerAction::Response(r) => GatewayResponse::Response(r),
238239
HandlerAction::Forward(fwd) => match self.backend.forward(fwd, body).await {
239-
Ok(resp) => GatewayResponse::Forward(resp),
240+
Ok(mut resp) => {
241+
resp.headers = filter_response_headers(&resp.headers);
242+
GatewayResponse::Forward(resp)
243+
}
240244
Err(e) => GatewayResponse::Response(error_response(
241245
&e,
242246
req.path,
@@ -288,7 +292,8 @@ where
288292
(GatewayResponse::Response(r), s, rb, false)
289293
}
290294
HandlerAction::Forward(fwd) => match self.backend.forward(fwd, body).await {
291-
Ok(resp) => {
295+
Ok(mut resp) => {
296+
resp.headers = filter_response_headers(&resp.headers);
292297
let s = resp.status;
293298
let cl = resp.content_length;
294299
(GatewayResponse::Forward(resp), s, cl, true)
@@ -842,7 +847,7 @@ where
842847

843848
Ok(ProxyResult {
844849
status: raw_resp.status,
845-
headers: raw_resp.headers,
850+
headers: filter_response_headers(&raw_resp.headers),
846851
body: ProxyResponseBody::from_bytes(raw_resp.body),
847852
})
848853
}

crates/core/src/route_handler.rs

Lines changed: 145 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,55 @@ pub struct PendingRequest {
110110
pub(crate) request_id: String,
111111
}
112112

113-
/// Headers to forward from backend responses (used by runtimes for Forward responses).
114-
pub const RESPONSE_HEADER_ALLOWLIST: &[&str] = &[
115-
"content-type",
116-
"content-length",
117-
"content-range",
118-
"etag",
119-
"last-modified",
120-
"accept-ranges",
121-
"content-encoding",
122-
"content-disposition",
123-
"cache-control",
124-
"x-amz-request-id",
125-
"x-amz-version-id",
126-
"location",
113+
/// Response headers that must NOT be forwarded to clients.
114+
///
115+
/// Uses a denylist approach: all headers pass through except those that are
116+
/// genuinely dangerous to forward from a reverse proxy. This allows cloud
117+
/// provider metadata (x-amz-meta-*, x-ms-meta-*, x-goog-meta-*) and useful
118+
/// operational headers to flow through without explicit allowlisting.
119+
pub const RESPONSE_HEADER_DENYLIST: &[&str] = &[
120+
// Hop-by-hop (RFC 7230 §6.1)
121+
"transfer-encoding",
122+
"connection",
123+
"keep-alive",
124+
"proxy-connection",
125+
"te",
126+
"trailer",
127+
"upgrade",
128+
// Auth/cookies
129+
"proxy-authenticate",
130+
"proxy-authorization",
131+
"www-authenticate",
132+
"set-cookie",
133+
// Proxy routing
134+
"forwarded",
135+
"x-forwarded-for",
136+
"x-forwarded-proto",
137+
"x-forwarded-host",
138+
"x-forwarded-port",
139+
"via",
140+
// Encryption key material (lets attackers validate guessed keys)
141+
"x-amz-server-side-encryption-customer-key-md5",
142+
"x-amz-server-side-encryption-aws-kms-key-id",
143+
"x-ms-encryption-key-sha256",
144+
"x-goog-encryption-key-sha256",
127145
];
128146

147+
/// Filter a `HeaderMap` by removing headers in the [`RESPONSE_HEADER_DENYLIST`].
148+
///
149+
/// Blocks hop-by-hop, auth/cookie, proxy routing, and encryption key material
150+
/// headers. Everything else (content metadata, cloud provider headers, user
151+
/// metadata) passes through.
152+
pub fn filter_response_headers(source: &http::HeaderMap) -> http::HeaderMap {
153+
let mut out = http::HeaderMap::new();
154+
for (name, value) in source.iter() {
155+
if !RESPONSE_HEADER_DENYLIST.contains(&name.as_str()) {
156+
out.insert(name.clone(), value.clone());
157+
}
158+
}
159+
out
160+
}
161+
129162
/// The future type returned by [`RouteHandler::handle`].
130163
#[cfg(not(target_arch = "wasm32"))]
131164
pub type RouteHandlerFuture<'a> = Pin<Box<dyn Future<Output = Option<ProxyResult>> + Send + 'a>>;
@@ -233,3 +266,101 @@ pub trait RouteHandler: MaybeSend + MaybeSync {
233266
/// to the next handler or the proxy dispatch pipeline.
234267
fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a>;
235268
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn test_blocks_hop_by_hop_headers() {
276+
let mut headers = http::HeaderMap::new();
277+
headers.insert("transfer-encoding", "chunked".parse().unwrap());
278+
headers.insert("connection", "keep-alive".parse().unwrap());
279+
headers.insert("content-type", "text/plain".parse().unwrap());
280+
281+
let filtered = filter_response_headers(&headers);
282+
assert!(filtered.get("transfer-encoding").is_none());
283+
assert!(filtered.get("connection").is_none());
284+
assert!(filtered.get("content-type").is_some());
285+
}
286+
287+
#[test]
288+
fn test_blocks_auth_and_cookie_headers() {
289+
let mut headers = http::HeaderMap::new();
290+
headers.insert("www-authenticate", "Basic".parse().unwrap());
291+
headers.insert("set-cookie", "session=abc".parse().unwrap());
292+
headers.insert("etag", "\"abc\"".parse().unwrap());
293+
294+
let filtered = filter_response_headers(&headers);
295+
assert!(filtered.get("www-authenticate").is_none());
296+
assert!(filtered.get("set-cookie").is_none());
297+
assert!(filtered.get("etag").is_some());
298+
}
299+
300+
#[test]
301+
fn test_blocks_encryption_key_material() {
302+
let mut headers = http::HeaderMap::new();
303+
headers.insert(
304+
"x-amz-server-side-encryption-aws-kms-key-id",
305+
"arn:aws:kms:us-east-1:123456:key/abc".parse().unwrap(),
306+
);
307+
headers.insert(
308+
"x-amz-server-side-encryption-customer-key-md5",
309+
"abc123".parse().unwrap(),
310+
);
311+
headers.insert("x-amz-server-side-encryption", "aws:kms".parse().unwrap());
312+
313+
let filtered = filter_response_headers(&headers);
314+
assert!(filtered
315+
.get("x-amz-server-side-encryption-aws-kms-key-id")
316+
.is_none());
317+
assert!(filtered
318+
.get("x-amz-server-side-encryption-customer-key-md5")
319+
.is_none());
320+
// Encryption method (not key material) should pass through
321+
assert!(filtered.get("x-amz-server-side-encryption").is_some());
322+
}
323+
324+
#[test]
325+
fn test_passes_cloud_metadata_headers() {
326+
let mut headers = http::HeaderMap::new();
327+
headers.insert("x-amz-meta-author", "alice".parse().unwrap());
328+
headers.insert("x-ms-meta-version", "2".parse().unwrap());
329+
headers.insert("x-goog-meta-project", "test".parse().unwrap());
330+
headers.insert("x-amz-storage-class", "STANDARD".parse().unwrap());
331+
headers.insert("x-amz-version-id", "v1".parse().unwrap());
332+
333+
let filtered = filter_response_headers(&headers);
334+
assert_eq!(filtered.len(), 5);
335+
}
336+
337+
#[test]
338+
fn test_passes_standard_content_headers() {
339+
let mut headers = http::HeaderMap::new();
340+
headers.insert("content-type", "application/json".parse().unwrap());
341+
headers.insert("content-length", "1234".parse().unwrap());
342+
headers.insert("content-range", "bytes 0-499/1000".parse().unwrap());
343+
headers.insert("etag", "\"abc\"".parse().unwrap());
344+
headers.insert(
345+
"last-modified",
346+
"Mon, 01 Jan 2024 00:00:00 GMT".parse().unwrap(),
347+
);
348+
headers.insert("accept-ranges", "bytes".parse().unwrap());
349+
headers.insert("cache-control", "max-age=3600".parse().unwrap());
350+
headers.insert("location", "/new".parse().unwrap());
351+
352+
let filtered = filter_response_headers(&headers);
353+
assert_eq!(filtered.len(), 8);
354+
}
355+
356+
#[test]
357+
fn test_blocks_proxy_routing_headers() {
358+
let mut headers = http::HeaderMap::new();
359+
headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
360+
headers.insert("via", "1.1 proxy".parse().unwrap());
361+
headers.insert("forwarded", "for=1.2.3.4".parse().unwrap());
362+
363+
let filtered = filter_response_headers(&headers);
364+
assert!(filtered.is_empty());
365+
}
366+
}

docs/architecture/request-lifecycle.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,13 @@ For lower-level control, `ProxyGateway::handle` returns the raw three-variant `H
156156
> [!WARNING]
157157
> Multipart uploads are only supported for `backend_type = "s3"`. Non-S3 backends should use single PUT requests (object_store handles chunking internally).
158158
159-
## Response Header Forwarding
159+
## Response Header Filtering
160160

161-
The proxy forwards only specific headers from the backend response to the client:
161+
The proxy uses a denylist to strip dangerous headers from backend responses before forwarding to clients. All headers pass through except:
162162

163-
`content-type`, `content-length`, `content-range`, `etag`, `last-modified`, `accept-ranges`, `content-encoding`, `content-disposition`, `cache-control`, `x-amz-request-id`, `x-amz-version-id`, `location`
163+
- **Hop-by-hop** (RFC 7230 §6.1): `transfer-encoding`, `connection`, `keep-alive`, `te`, `trailer`, `upgrade`, `proxy-connection`
164+
- **Auth/cookies**: `proxy-authenticate`, `proxy-authorization`, `www-authenticate`, `set-cookie`
165+
- **Proxy routing**: `forwarded`, `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-host`, `x-forwarded-port`, `via`
166+
- **Encryption key material**: `x-amz-server-side-encryption-customer-key-md5`, `x-amz-server-side-encryption-aws-kms-key-id`, `x-ms-encryption-key-sha256`, `x-goog-encryption-key-sha256`
164167

165-
All other backend headers are filtered out.
168+
This means content headers, cloud provider metadata (e.g. `x-amz-storage-class`), and user metadata from any provider (`x-amz-meta-*`, `x-ms-meta-*`, `x-goog-meta-*`) all flow through to clients automatically.

examples/lambda/src/client.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use lambda_http::Body;
66
use multistore::backend::ForwardResponse;
77
use multistore::backend::{build_signer, create_builder, ProxyBackend, RawResponse};
88
use multistore::error::ProxyError;
9-
use multistore::route_handler::{ForwardRequest, RESPONSE_HEADER_ALLOWLIST};
9+
use multistore::route_handler::ForwardRequest;
1010
use multistore::types::BucketConfig;
1111
use multistore_oidc_provider::{HttpExchange, OidcProviderError};
1212
use object_store::list::PaginatedListStore;
@@ -82,13 +82,7 @@ impl ProxyBackend for LambdaBackend {
8282

8383
let status = backend_resp.status().as_u16();
8484

85-
// Forward allowlisted response headers
86-
let mut headers = http::HeaderMap::new();
87-
for name in RESPONSE_HEADER_ALLOWLIST {
88-
if let Some(v) = backend_resp.headers().get(*name) {
89-
headers.insert(*name, v.clone());
90-
}
91-
}
85+
let headers = backend_resp.headers().clone();
9286

9387
let content_length = backend_resp.content_length();
9488

examples/server/src/client.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use http_body_util::BodyStream;
77
use multistore::backend::ForwardResponse;
88
use multistore::backend::{build_signer, create_builder, ProxyBackend, RawResponse};
99
use multistore::error::ProxyError;
10-
use multistore::route_handler::{ForwardRequest, RESPONSE_HEADER_ALLOWLIST};
10+
use multistore::route_handler::ForwardRequest;
1111
use multistore::types::BucketConfig;
1212
use multistore_oidc_provider::{HttpExchange, OidcProviderError};
1313
use object_store::list::PaginatedListStore;
@@ -80,13 +80,7 @@ impl ProxyBackend for ServerBackend {
8080

8181
let status = backend_resp.status().as_u16();
8282

83-
// Forward allowlisted response headers
84-
let mut headers = HeaderMap::new();
85-
for name in RESPONSE_HEADER_ALLOWLIST {
86-
if let Some(v) = backend_resp.headers().get(*name) {
87-
headers.insert(*name, v.clone());
88-
}
89-
}
83+
let headers = backend_resp.headers().clone();
9084

9185
let content_length = backend_resp.content_length();
9286

0 commit comments

Comments
 (0)