diff --git a/src/bedrock_agentcore/runtime/a2a.py b/src/bedrock_agentcore/runtime/a2a.py index 5e64feb7..e12fd5af 100644 --- a/src/bedrock_agentcore/runtime/a2a.py +++ b/src/bedrock_agentcore/runtime/a2a.py @@ -12,16 +12,17 @@ from ..config_bundle.baggage import _extract_baggage from .context import BedrockAgentCoreContext from .models import ( + _AUTHORIZATION_HEADER_LOWER, ACCESS_TOKEN_HEADER, AGENTCORE_RUNTIME_URL_ENV, AUTHORIZATION_HEADER, BAGGAGE_KEY_EXPERIMENT_ARN, BAGGAGE_KEY_EXPERIMENT_VARIANT, - CUSTOM_HEADER_PREFIX, OAUTH2_CALLBACK_URL_HEADER, REQUEST_ID_HEADER, SESSION_HEADER, PingStatus, + is_forwardable_header, ) from .tracing import _ensure_baggage_processor_registered @@ -131,12 +132,15 @@ def build(self, request: Any) -> Any: if oauth2_callback_url: BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url) + # Collect forwardable request headers. + # Authorization is normalised to a canonical key regardless of wire casing + # (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case). + # All other headers are checked against the runtime header allowlist rules. request_headers: dict[str, str] = {} - authorization_header = headers.get(AUTHORIZATION_HEADER) - if authorization_header is not None: - request_headers[AUTHORIZATION_HEADER] = authorization_header for header_name, header_value in headers.items(): - if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()): + if header_name.lower() == _AUTHORIZATION_HEADER_LOWER: + request_headers[AUTHORIZATION_HEADER] = header_value + elif is_forwardable_header(header_name): request_headers[header_name] = header_value if request_headers: BedrockAgentCoreContext.set_request_headers(request_headers) diff --git a/src/bedrock_agentcore/runtime/ag_ui.py b/src/bedrock_agentcore/runtime/ag_ui.py index a7e77be0..cae72c0a 100644 --- a/src/bedrock_agentcore/runtime/ag_ui.py +++ b/src/bedrock_agentcore/runtime/ag_ui.py @@ -24,15 +24,16 @@ from ..config_bundle.baggage import _extract_baggage from .context import BedrockAgentCoreContext, RequestContext from .models import ( + _AUTHORIZATION_HEADER_LOWER, ACCESS_TOKEN_HEADER, AUTHORIZATION_HEADER, BAGGAGE_KEY_EXPERIMENT_ARN, BAGGAGE_KEY_EXPERIMENT_VARIANT, - CUSTOM_HEADER_PREFIX, OAUTH2_CALLBACK_URL_HEADER, REQUEST_ID_HEADER, SESSION_HEADER, PingStatus, + is_forwardable_header, ) from .tracing import _ensure_baggage_processor_registered @@ -169,12 +170,15 @@ def _build_request_context(self, request: Request | WebSocket) -> RequestContext if oauth2_callback_url: BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url) + # Collect forwardable request headers. + # Authorization is normalised to a canonical key regardless of wire casing + # (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case). + # All other headers are checked against the runtime header allowlist rules. request_headers: dict[str, str] = {} - authorization_header = headers.get(AUTHORIZATION_HEADER) - if authorization_header is not None: - request_headers[AUTHORIZATION_HEADER] = authorization_header for header_name, header_value in headers.items(): - if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()): + if header_name.lower() == _AUTHORIZATION_HEADER_LOWER: + request_headers[AUTHORIZATION_HEADER] = header_value + elif is_forwardable_header(header_name): request_headers[header_name] = header_value if request_headers: BedrockAgentCoreContext.set_request_headers(request_headers) diff --git a/src/bedrock_agentcore/runtime/app.py b/src/bedrock_agentcore/runtime/app.py index 22475f01..403f12da 100644 --- a/src/bedrock_agentcore/runtime/app.py +++ b/src/bedrock_agentcore/runtime/app.py @@ -31,11 +31,11 @@ from ..config_bundle.client import ConfigBundleClient from .context import BedrockAgentCoreContext, RequestContext from .models import ( + _AUTHORIZATION_HEADER_LOWER, ACCESS_TOKEN_HEADER, AUTHORIZATION_HEADER, BAGGAGE_KEY_EXPERIMENT_ARN, BAGGAGE_KEY_EXPERIMENT_VARIANT, - CUSTOM_HEADER_PREFIX, OAUTH2_CALLBACK_URL_HEADER, REQUEST_ID_HEADER, SESSION_HEADER, @@ -45,6 +45,7 @@ TASK_ACTION_JOB_STATUS, TASK_ACTION_PING_STATUS, PingStatus, + is_forwardable_header, ) from .tracing import _ensure_baggage_processor_registered from .utils import convert_complex_objects @@ -415,17 +416,16 @@ def _build_request_context(self, request) -> RequestContext: if oauth2_callback_url: BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url) - # Collect relevant request headers (Authorization + Custom headers) + # Collect forwardable request headers. + # Authorization is normalised to a canonical key regardless of wire casing + # (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case). + # All other headers are checked against the runtime header allowlist rules. request_headers = {} - # Add Authorization header if present - authorization_header = headers.get(AUTHORIZATION_HEADER) - if authorization_header is not None: - request_headers[AUTHORIZATION_HEADER] = authorization_header - - # Add custom headers with the specified prefix for header_name, header_value in headers.items(): - if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()): + if header_name.lower() == _AUTHORIZATION_HEADER_LOWER: + request_headers[AUTHORIZATION_HEADER] = header_value + elif is_forwardable_header(header_name): request_headers[header_name] = header_value # Set in context if any headers were found diff --git a/src/bedrock_agentcore/runtime/models.py b/src/bedrock_agentcore/runtime/models.py index b9dab21d..ea5f1e00 100644 --- a/src/bedrock_agentcore/runtime/models.py +++ b/src/bedrock_agentcore/runtime/models.py @@ -22,6 +22,140 @@ class PingStatus(str, Enum): CUSTOM_HEADER_PREFIX = "X-Amzn-Bedrock-AgentCore-Runtime-Custom-" AGENTCORE_RUNTIME_URL_ENV = "AGENTCORE_RUNTIME_URL" +# Headers that cannot be forwarded to agent code. +# Source: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html +RESTRICTED_HEADERS: frozenset[str] = frozenset( + h.lower() + for h in [ + # Authentication & Authorization + "Proxy-Authorization", + "WWW-Authenticate", + # Content Negotiation + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Content-Type", + "Content-Length", + "Content-Encoding", + "Content-Language", + "Content-Location", + "Content-Range", + # Caching + "Cache-Control", + "ETag", + "Expires", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + "Last-Modified", + "Pragma", + "Vary", + # Connection Management + "Connection", + "Keep-Alive", + "Proxy-Connection", + "Upgrade", + # Request Context + "Host", + "User-Agent", + "Referer", + "From", + # Range / Transfer + "Range", + "Accept-Ranges", + "Transfer-Encoding", + "TE", + "Trailer", + # Server Information + "Server", + "Date", + "Location", + "Retry-After", + # Cookies + "Set-Cookie", + "Cookie", + # Security + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Strict-Transport-Security", + "X-Content-Type-Options", + "X-Frame-Options", + "X-XSS-Protection", + "Referrer-Policy", + "Permissions-Policy", + "Cross-Origin-Embedder-Policy", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + # CORS + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + "Origin", + # Client Hints + "Accept-CH", + "Accept-CH-Lifetime", + "DPR", + "Width", + "Viewport-Width", + "Downlink", + "ECT", + "RTT", + "Save-Data", + # Experimental / Proposed + "Clear-Site-Data", + "Feature-Policy", + "Expect-CT", + "Public-Key-Pins", + "Public-Key-Pins-Report-Only", + # Proxy + "Via", + "Forwarded", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Real-IP", + "X-Requested-With", + "X-CSRF-Token", + # IP Spoofing / URL Manipulation + "True-Client-IP", + "X-Client-IP", + "X-Cluster-Client-IP", + "X-Originating-IP", + "X-Source-IP", + "X-Original-URL", + "X-Original-Host", + "X-Rewrite-URL", + # CDN / Proxy + "CF-Ray", + "CF-Connecting-IP", + "X-Amz-Cf-Id", + "X-Cache", + "X-Served-By", + # HTTP/2 Pseudo Headers + ":method", + ":path", + ":scheme", + ":authority", + ":status", + # Server Push + "Link", + # WebSocket + "Sec-WebSocket-Key", + "Sec-WebSocket-Accept", + "Sec-WebSocket-Version", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Extensions", + ] +) + # Baggage keys for routing experiment span attributes BAGGAGE_KEY_EXPERIMENT_ARN = "aws.agentcore.gateway.routing_experiment_arn" BAGGAGE_KEY_EXPERIMENT_VARIANT = "aws.agentcore.gateway.routing_experiment_variant_name" @@ -32,3 +166,26 @@ class PingStatus(str, Enum): TASK_ACTION_FORCE_HEALTHY = "force_healthy" TASK_ACTION_FORCE_BUSY = "force_busy" TASK_ACTION_CLEAR_FORCED_STATUS = "clear_forced_status" + + +_CUSTOM_HEADER_PREFIX_LOWER = CUSTOM_HEADER_PREFIX.lower() +_AUTHORIZATION_HEADER_LOWER = AUTHORIZATION_HEADER.lower() + + +def is_forwardable_header(header_name: str) -> bool: + """Return True if the header may be forwarded to agent code. + + Rules (from the AgentCore runtime header allowlist docs): + - Not in the restricted headers list + - Does not start with ``x-amz-`` (reserved for AWS SigV4 signing) + - Does not start with ``x-amzn-`` unless it starts with the legacy + ``X-Amzn-Bedrock-AgentCore-Runtime-Custom-`` prefix + """ + lower = header_name.lower() + if lower in RESTRICTED_HEADERS: + return False + if lower.startswith("x-amz-"): + return False + if lower.startswith("x-amzn-") and not lower.startswith(_CUSTOM_HEADER_PREFIX_LOWER): + return False + return True diff --git a/tests/bedrock_agentcore/runtime/test_a2a.py b/tests/bedrock_agentcore/runtime/test_a2a.py index b789dd46..c6851b5f 100644 --- a/tests/bedrock_agentcore/runtime/test_a2a.py +++ b/tests/bedrock_agentcore/runtime/test_a2a.py @@ -247,6 +247,39 @@ def _test(): self._run_in_isolated_context(_test) + def test_forwards_non_restricted_custom_headers(self): + """Non-restricted headers (e.g. X-Api-Key) are forwarded; restricted ones are not.""" + + def _test(): + from starlette.requests import Request + + scope = { + "type": "http", + "method": "POST", + "path": "/", + "headers": [ + (b"x-api-key", b"my-key"), + (b"x-custom-signature", b"sha256=abc"), + (b"content-type", b"application/json"), # restricted + (b"x-amz-date", b"20250101T000000Z"), # restricted (x-amz-) + (b"x-amzn-trace-id", b"trace-123"), # restricted (x-amzn-) + ], + "query_string": b"", + } + request = Request(scope) + builder = BedrockCallContextBuilder() + builder.build(request) + + headers = BedrockAgentCoreContext.get_request_headers() + assert headers is not None + assert any(k.lower() == "x-api-key" for k in headers) + assert any(k.lower() == "x-custom-signature" for k in headers) + assert not any(k.lower() == "content-type" for k in headers) + assert not any(k.lower() == "x-amz-date" for k in headers) + assert not any(k.lower() == "x-amzn-trace-id" for k in headers) + + self._run_in_isolated_context(_test) + def test_auto_generates_request_id_when_missing(self): def _test(): from starlette.requests import Request diff --git a/tests/bedrock_agentcore/runtime/test_ag_ui.py b/tests/bedrock_agentcore/runtime/test_ag_ui.py index 6d4287e6..7110a142 100644 --- a/tests/bedrock_agentcore/runtime/test_ag_ui.py +++ b/tests/bedrock_agentcore/runtime/test_ag_ui.py @@ -263,6 +263,39 @@ def _test(): self._run_in_isolated_context(_test) + def test_forwards_non_restricted_custom_headers(self): + """Non-restricted headers (e.g. X-Api-Key) are forwarded; restricted ones are not.""" + + def _test(): + from starlette.requests import Request + + scope = { + "type": "http", + "method": "POST", + "path": "/invocations", + "headers": [ + (b"x-api-key", b"my-key"), + (b"x-custom-signature", b"sha256=abc"), + (b"content-type", b"application/json"), # restricted + (b"x-amz-date", b"20250101T000000Z"), # restricted (x-amz-) + (b"x-amzn-trace-id", b"trace-123"), # restricted (x-amzn-) + ], + "query_string": b"", + } + request = Request(scope) + app = AGUIApp() + app._build_request_context(request) + + headers = BedrockAgentCoreContext.get_request_headers() + assert headers is not None + assert any(k.lower() == "x-api-key" for k in headers) + assert any(k.lower() == "x-custom-signature" for k in headers) + assert not any(k.lower() == "content-type" for k in headers) + assert not any(k.lower() == "x-amz-date" for k in headers) + assert not any(k.lower() == "x-amzn-trace-id" for k in headers) + + self._run_in_isolated_context(_test) + def test_auto_generates_request_id_when_missing(self): def _test(): from starlette.requests import Request diff --git a/tests/bedrock_agentcore/runtime/test_app.py b/tests/bedrock_agentcore/runtime/test_app.py index 483e6936..bba49b25 100644 --- a/tests/bedrock_agentcore/runtime/test_app.py +++ b/tests/bedrock_agentcore/runtime/test_app.py @@ -16,6 +16,7 @@ from bedrock_agentcore.runtime import BedrockAgentCoreApp from bedrock_agentcore.runtime.app import _parse_runtime_arn from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +from bedrock_agentcore.runtime.models import is_forwardable_header class TestBedrockAgentCoreApp: @@ -1830,10 +1831,10 @@ def __init__(self): assert context.request_headers is not None assert context.request_headers["Authorization"] == "Bearer test-auth-token" - assert "Content-Type" not in context.request_headers # Only Auth and Custom headers + assert "Content-Type" not in context.request_headers # restricted header def test_build_request_context_with_custom_headers(self): - """Test _build_request_context extracts custom headers with correct prefix.""" + """Test _build_request_context forwards non-restricted headers and blocks restricted ones.""" app = BedrockAgentCoreApp() class MockRequest: @@ -1841,8 +1842,8 @@ def __init__(self): self.headers = { "X-Amzn-Bedrock-AgentCore-Runtime-Custom-Header1": "value1", "X-Amzn-Bedrock-AgentCore-Runtime-Custom-Header2": "value2", - "X-Other-Header": "should-not-include", - "Content-Type": "application/json", + "X-Other-Header": "should-include", + "Content-Type": "application/json", # restricted } self.state = type("State", (), {})() @@ -1852,11 +1853,11 @@ def __init__(self): assert context.request_headers is not None assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Header1"] == "value1" assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Header2"] == "value2" - assert "X-Other-Header" not in context.request_headers + assert context.request_headers["X-Other-Header"] == "should-include" assert "Content-Type" not in context.request_headers def test_build_request_context_with_both_auth_and_custom_headers(self): - """Test _build_request_context with both Authorization and custom headers.""" + """Test _build_request_context with both Authorization and non-restricted headers.""" app = BedrockAgentCoreApp() class MockRequest: @@ -1865,25 +1866,24 @@ def __init__(self): "Authorization": "Bearer combined-token", "X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserAgent": "test-agent/1.0", "X-Amzn-Bedrock-AgentCore-Runtime-Custom-ClientId": "client-123", - "Content-Type": "application/json", - "X-Other-Header": "ignored", + "Content-Type": "application/json", # restricted + "X-Other-Header": "forwarded", } self.state = type("State", (), {})() mock_request = MockRequest() context = app._build_request_context(mock_request) - expected_headers = { - "Authorization": "Bearer combined-token", - "X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserAgent": "test-agent/1.0", - "X-Amzn-Bedrock-AgentCore-Runtime-Custom-ClientId": "client-123", - } - - assert context.request_headers == expected_headers - assert len(context.request_headers) == 3 + assert context.request_headers is not None + assert context.request_headers["Authorization"] == "Bearer combined-token" + assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-UserAgent"] == "test-agent/1.0" + assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-ClientId"] == "client-123" + assert context.request_headers["X-Other-Header"] == "forwarded" + assert "Content-Type" not in context.request_headers + assert len(context.request_headers) == 4 - def test_build_request_context_with_no_relevant_headers(self): - """Test _build_request_context when no Authorization or custom headers present.""" + def test_build_request_context_with_only_restricted_headers(self): + """Test _build_request_context when only restricted headers are present.""" import contextvars # Run in fresh context to avoid cross-test contamination @@ -1895,9 +1895,9 @@ def test_in_new_context(): class MockRequest: def __init__(self): self.headers = { - "Content-Type": "application/json", - "Accept": "application/json", - "X-Other-Header": "not-relevant", + "Content-Type": "application/json", # restricted + "Accept": "application/json", # restricted + "Cache-Control": "no-cache", # restricted } self.state = type("State", (), {})() @@ -2082,21 +2082,20 @@ def __init__(self): assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Quotes"] == 'value-with-"quotes"' def test_header_prefix_boundary_cases(self): - """Test edge cases for header prefix matching.""" + """Test edge cases for x-amzn- prefix handling.""" app = BedrockAgentCoreApp() class MockRequest: def __init__(self): self.headers = { - # Exact prefix match - should be included + # Allowed x-amzn- exception: AgentCore custom prefix - included "X-Amzn-Bedrock-AgentCore-Runtime-Custom-": "empty-suffix", - # Prefix with additional content - should be included "X-Amzn-Bedrock-AgentCore-Runtime-Custom-LongHeaderName": "long-name", - # Similar but not exact prefix - should NOT be included - "X-Amzn-Bedrock-AgentCore-Runtime-Custo": "not-exact", - "X-Amzn-Bedrock-AgentCore-Runtime-Custom": "missing-dash", - # Prefix as substring - should NOT be included - "PrefixX-Amzn-Bedrock-AgentCore-Runtime-Custom-": "has-prefix", + # x-amzn- without the allowed exception - blocked + "X-Amzn-Bedrock-AgentCore-Runtime-Custo": "blocked", + "X-Amzn-Bedrock-AgentCore-Runtime-Custom": "blocked", + # Does NOT start with x-amzn-, so allowed + "PrefixX-Amzn-Bedrock-AgentCore-Runtime-Custom-": "allowed", } self.state = type("State", (), {})() @@ -2104,16 +2103,14 @@ def __init__(self): context = app._build_request_context(mock_request) assert context.request_headers is not None - # Should include headers with exact prefix match assert "X-Amzn-Bedrock-AgentCore-Runtime-Custom-" in context.request_headers assert "X-Amzn-Bedrock-AgentCore-Runtime-Custom-LongHeaderName" in context.request_headers + assert "PrefixX-Amzn-Bedrock-AgentCore-Runtime-Custom-" in context.request_headers - # Should NOT include headers without exact prefix match assert "X-Amzn-Bedrock-AgentCore-Runtime-Custo" not in context.request_headers assert "X-Amzn-Bedrock-AgentCore-Runtime-Custom" not in context.request_headers - assert "PrefixX-Amzn-Bedrock-AgentCore-Runtime-Custom-" not in context.request_headers - assert len(context.request_headers) == 2 + assert len(context.request_headers) == 3 def test_multiple_authorization_headers_scenario(self): """Test scenario with multiple authorization-like headers.""" @@ -2123,9 +2120,9 @@ class MockRequest: def __init__(self): self.headers = { "Authorization": "Bearer primary-token", - "X-Authorization": "Bearer secondary-token", # Should NOT be included - "Proxy-Authorization": "Bearer proxy-token", # Should NOT be included - "X-Amzn-Bedrock-AgentCore-Runtime-Custom-Auth": "Bearer custom-token", # Should be included + "X-Authorization": "Bearer secondary-token", # not restricted — included + "Proxy-Authorization": "Bearer proxy-token", # restricted — blocked + "X-Amzn-Bedrock-AgentCore-Runtime-Custom-Auth": "Bearer custom-token", } self.state = type("State", (), {})() @@ -2134,12 +2131,10 @@ def __init__(self): assert context.request_headers is not None assert context.request_headers["Authorization"] == "Bearer primary-token" + assert context.request_headers["X-Authorization"] == "Bearer secondary-token" assert context.request_headers["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Auth"] == "Bearer custom-token" - - # Only standard Authorization and custom headers should be included - assert "X-Authorization" not in context.request_headers assert "Proxy-Authorization" not in context.request_headers - assert len(context.request_headers) == 2 + assert len(context.request_headers) == 3 def test_empty_header_values(self): """Test handling of empty header values.""" @@ -3025,3 +3020,44 @@ def test_different_versions_fetched_independently(self, monkeypatch, reset_arn_c app._resolve_bundle_config(ref_v2) assert mock_client.get_configuration_bundle_version.call_count == 2 + + +class TestIsForwardableHeader: + """Unit tests for is_forwardable_header().""" + + def test_generic_custom_headers_are_allowed(self): + assert is_forwardable_header("X-Api-Key") is True + assert is_forwardable_header("X-Custom-Signature") is True + assert is_forwardable_header("X-Trace-Id") is True + + def test_legacy_agentcore_prefix_is_allowed(self): + assert is_forwardable_header("X-Amzn-Bedrock-AgentCore-Runtime-Custom-Foo") is True + assert is_forwardable_header("X-Amzn-Bedrock-AgentCore-Runtime-Custom-") is True + + def test_restricted_headers_are_blocked(self): + assert is_forwardable_header("Content-Type") is False + assert is_forwardable_header("Accept") is False + assert is_forwardable_header("Cookie") is False + assert is_forwardable_header("Proxy-Authorization") is False + assert is_forwardable_header("Host") is False + assert is_forwardable_header("User-Agent") is False + + def test_restricted_headers_are_case_insensitive(self): + assert is_forwardable_header("content-type") is False + assert is_forwardable_header("CONTENT-TYPE") is False + assert is_forwardable_header("Content-type") is False + + def test_x_amz_prefix_is_blocked(self): + assert is_forwardable_header("X-Amz-Date") is False + assert is_forwardable_header("X-Amz-Security-Token") is False + assert is_forwardable_header("x-amz-content-sha256") is False + + def test_x_amzn_prefix_is_blocked(self): + assert is_forwardable_header("X-Amzn-Trace-Id") is False + assert is_forwardable_header("x-amzn-requestid") is False + # x-amzn- without the AgentCore custom prefix exception + assert is_forwardable_header("X-Amzn-Bedrock-AgentCore-Runtime-Session-Id") is False + + def test_authorization_is_allowed(self): + # Authorization is normalized to canonical casing by the loop, but is_forwardable_header must not block it + assert is_forwardable_header("Authorization") is True