@@ -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" ) ) ]
131164pub 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+ }
0 commit comments