Skip to content

Commit 02e7568

Browse files
committed
feat: expandi custom request header forwarding to match runtime allowlist
_build_request_context in BedrockAgentCoreApp, BedrockCallContextBuilder (A2A), and AGUIApp previously only forwarded Authorization and headers prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-, silently dropping any other custom headers (e.g. X-Api-Key, X-Custom-Signature) even after the runtime service was updated to forward them to the container. Adds RESTRICTED_HEADERS (frozenset) and is_forwardable_header() to models.py implementing the full allowlist rules from the docs: block the restricted header set, x-amz-* (SigV4 reserved), and x-amzn-* (except the legacy AgentCore custom prefix); pass everything else through.
1 parent b64a0d9 commit 02e7568

7 files changed

Lines changed: 326 additions & 59 deletions

File tree

src/bedrock_agentcore/runtime/a2a.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@
1212
from ..config_bundle.baggage import _extract_baggage
1313
from .context import BedrockAgentCoreContext
1414
from .models import (
15+
_AUTHORIZATION_HEADER_LOWER,
1516
ACCESS_TOKEN_HEADER,
1617
AGENTCORE_RUNTIME_URL_ENV,
1718
AUTHORIZATION_HEADER,
1819
BAGGAGE_KEY_EXPERIMENT_ARN,
1920
BAGGAGE_KEY_EXPERIMENT_VARIANT,
20-
CUSTOM_HEADER_PREFIX,
2121
OAUTH2_CALLBACK_URL_HEADER,
2222
REQUEST_ID_HEADER,
2323
SESSION_HEADER,
2424
PingStatus,
25+
is_forwardable_header,
2526
)
2627
from .tracing import _ensure_baggage_processor_registered
2728

@@ -131,12 +132,15 @@ def build(self, request: Any) -> Any:
131132
if oauth2_callback_url:
132133
BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url)
133134

135+
# Collect forwardable request headers.
136+
# Authorization is normalised to a canonical key regardless of wire casing
137+
# (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case).
138+
# All other headers are checked against the runtime header allowlist rules.
134139
request_headers: dict[str, str] = {}
135-
authorization_header = headers.get(AUTHORIZATION_HEADER)
136-
if authorization_header is not None:
137-
request_headers[AUTHORIZATION_HEADER] = authorization_header
138140
for header_name, header_value in headers.items():
139-
if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()):
141+
if header_name.lower() == _AUTHORIZATION_HEADER_LOWER:
142+
request_headers[AUTHORIZATION_HEADER] = header_value
143+
elif is_forwardable_header(header_name):
140144
request_headers[header_name] = header_value
141145
if request_headers:
142146
BedrockAgentCoreContext.set_request_headers(request_headers)

src/bedrock_agentcore/runtime/ag_ui.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,16 @@
2424
from ..config_bundle.baggage import _extract_baggage
2525
from .context import BedrockAgentCoreContext, RequestContext
2626
from .models import (
27+
_AUTHORIZATION_HEADER_LOWER,
2728
ACCESS_TOKEN_HEADER,
2829
AUTHORIZATION_HEADER,
2930
BAGGAGE_KEY_EXPERIMENT_ARN,
3031
BAGGAGE_KEY_EXPERIMENT_VARIANT,
31-
CUSTOM_HEADER_PREFIX,
3232
OAUTH2_CALLBACK_URL_HEADER,
3333
REQUEST_ID_HEADER,
3434
SESSION_HEADER,
3535
PingStatus,
36+
is_forwardable_header,
3637
)
3738
from .tracing import _ensure_baggage_processor_registered
3839

@@ -169,12 +170,15 @@ def _build_request_context(self, request: Request | WebSocket) -> RequestContext
169170
if oauth2_callback_url:
170171
BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url)
171172

173+
# Collect forwardable request headers.
174+
# Authorization is normalised to a canonical key regardless of wire casing
175+
# (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case).
176+
# All other headers are checked against the runtime header allowlist rules.
172177
request_headers: dict[str, str] = {}
173-
authorization_header = headers.get(AUTHORIZATION_HEADER)
174-
if authorization_header is not None:
175-
request_headers[AUTHORIZATION_HEADER] = authorization_header
176178
for header_name, header_value in headers.items():
177-
if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()):
179+
if header_name.lower() == _AUTHORIZATION_HEADER_LOWER:
180+
request_headers[AUTHORIZATION_HEADER] = header_value
181+
elif is_forwardable_header(header_name):
178182
request_headers[header_name] = header_value
179183
if request_headers:
180184
BedrockAgentCoreContext.set_request_headers(request_headers)

src/bedrock_agentcore/runtime/app.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131
from ..config_bundle.client import ConfigBundleClient
3232
from .context import BedrockAgentCoreContext, RequestContext
3333
from .models import (
34+
_AUTHORIZATION_HEADER_LOWER,
3435
ACCESS_TOKEN_HEADER,
3536
AUTHORIZATION_HEADER,
3637
BAGGAGE_KEY_EXPERIMENT_ARN,
3738
BAGGAGE_KEY_EXPERIMENT_VARIANT,
38-
CUSTOM_HEADER_PREFIX,
3939
OAUTH2_CALLBACK_URL_HEADER,
4040
REQUEST_ID_HEADER,
4141
SESSION_HEADER,
@@ -45,6 +45,7 @@
4545
TASK_ACTION_JOB_STATUS,
4646
TASK_ACTION_PING_STATUS,
4747
PingStatus,
48+
is_forwardable_header,
4849
)
4950
from .tracing import _ensure_baggage_processor_registered
5051
from .utils import convert_complex_objects
@@ -415,17 +416,16 @@ def _build_request_context(self, request) -> RequestContext:
415416
if oauth2_callback_url:
416417
BedrockAgentCoreContext.set_oauth2_callback_url(oauth2_callback_url)
417418

418-
# Collect relevant request headers (Authorization + Custom headers)
419+
# Collect forwardable request headers.
420+
# Authorization is normalised to a canonical key regardless of wire casing
421+
# (HTTP/2 always lowercases headers; HTTP/1.1 may preserve mixed case).
422+
# All other headers are checked against the runtime header allowlist rules.
419423
request_headers = {}
420424

421-
# Add Authorization header if present
422-
authorization_header = headers.get(AUTHORIZATION_HEADER)
423-
if authorization_header is not None:
424-
request_headers[AUTHORIZATION_HEADER] = authorization_header
425-
426-
# Add custom headers with the specified prefix
427425
for header_name, header_value in headers.items():
428-
if header_name.lower().startswith(CUSTOM_HEADER_PREFIX.lower()):
426+
if header_name.lower() == _AUTHORIZATION_HEADER_LOWER:
427+
request_headers[AUTHORIZATION_HEADER] = header_value
428+
elif is_forwardable_header(header_name):
429429
request_headers[header_name] = header_value
430430

431431
# Set in context if any headers were found

src/bedrock_agentcore/runtime/models.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,140 @@ class PingStatus(str, Enum):
2222
CUSTOM_HEADER_PREFIX = "X-Amzn-Bedrock-AgentCore-Runtime-Custom-"
2323
AGENTCORE_RUNTIME_URL_ENV = "AGENTCORE_RUNTIME_URL"
2424

25+
# Headers that cannot be forwarded to agent code.
26+
# Source: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html
27+
RESTRICTED_HEADERS: frozenset[str] = frozenset(
28+
h.lower()
29+
for h in [
30+
# Authentication & Authorization
31+
"Proxy-Authorization",
32+
"WWW-Authenticate",
33+
# Content Negotiation
34+
"Accept",
35+
"Accept-Charset",
36+
"Accept-Encoding",
37+
"Accept-Language",
38+
"Content-Type",
39+
"Content-Length",
40+
"Content-Encoding",
41+
"Content-Language",
42+
"Content-Location",
43+
"Content-Range",
44+
# Caching
45+
"Cache-Control",
46+
"ETag",
47+
"Expires",
48+
"If-Match",
49+
"If-Modified-Since",
50+
"If-None-Match",
51+
"If-Range",
52+
"If-Unmodified-Since",
53+
"Last-Modified",
54+
"Pragma",
55+
"Vary",
56+
# Connection Management
57+
"Connection",
58+
"Keep-Alive",
59+
"Proxy-Connection",
60+
"Upgrade",
61+
# Request Context
62+
"Host",
63+
"User-Agent",
64+
"Referer",
65+
"From",
66+
# Range / Transfer
67+
"Range",
68+
"Accept-Ranges",
69+
"Transfer-Encoding",
70+
"TE",
71+
"Trailer",
72+
# Server Information
73+
"Server",
74+
"Date",
75+
"Location",
76+
"Retry-After",
77+
# Cookies
78+
"Set-Cookie",
79+
"Cookie",
80+
# Security
81+
"Content-Security-Policy",
82+
"Content-Security-Policy-Report-Only",
83+
"Strict-Transport-Security",
84+
"X-Content-Type-Options",
85+
"X-Frame-Options",
86+
"X-XSS-Protection",
87+
"Referrer-Policy",
88+
"Permissions-Policy",
89+
"Cross-Origin-Embedder-Policy",
90+
"Cross-Origin-Opener-Policy",
91+
"Cross-Origin-Resource-Policy",
92+
# CORS
93+
"Access-Control-Allow-Origin",
94+
"Access-Control-Allow-Methods",
95+
"Access-Control-Allow-Headers",
96+
"Access-Control-Allow-Credentials",
97+
"Access-Control-Expose-Headers",
98+
"Access-Control-Max-Age",
99+
"Access-Control-Request-Method",
100+
"Access-Control-Request-Headers",
101+
"Origin",
102+
# Client Hints
103+
"Accept-CH",
104+
"Accept-CH-Lifetime",
105+
"DPR",
106+
"Width",
107+
"Viewport-Width",
108+
"Downlink",
109+
"ECT",
110+
"RTT",
111+
"Save-Data",
112+
# Experimental / Proposed
113+
"Clear-Site-Data",
114+
"Feature-Policy",
115+
"Expect-CT",
116+
"Public-Key-Pins",
117+
"Public-Key-Pins-Report-Only",
118+
# Proxy
119+
"Via",
120+
"Forwarded",
121+
"X-Forwarded-For",
122+
"X-Forwarded-Host",
123+
"X-Forwarded-Proto",
124+
"X-Real-IP",
125+
"X-Requested-With",
126+
"X-CSRF-Token",
127+
# IP Spoofing / URL Manipulation
128+
"True-Client-IP",
129+
"X-Client-IP",
130+
"X-Cluster-Client-IP",
131+
"X-Originating-IP",
132+
"X-Source-IP",
133+
"X-Original-URL",
134+
"X-Original-Host",
135+
"X-Rewrite-URL",
136+
# CDN / Proxy
137+
"CF-Ray",
138+
"CF-Connecting-IP",
139+
"X-Amz-Cf-Id",
140+
"X-Cache",
141+
"X-Served-By",
142+
# HTTP/2 Pseudo Headers
143+
":method",
144+
":path",
145+
":scheme",
146+
":authority",
147+
":status",
148+
# Server Push
149+
"Link",
150+
# WebSocket
151+
"Sec-WebSocket-Key",
152+
"Sec-WebSocket-Accept",
153+
"Sec-WebSocket-Version",
154+
"Sec-WebSocket-Protocol",
155+
"Sec-WebSocket-Extensions",
156+
]
157+
)
158+
25159
# Baggage keys for routing experiment span attributes
26160
BAGGAGE_KEY_EXPERIMENT_ARN = "aws.agentcore.gateway.routing_experiment_arn"
27161
BAGGAGE_KEY_EXPERIMENT_VARIANT = "aws.agentcore.gateway.routing_experiment_variant_name"
@@ -32,3 +166,26 @@ class PingStatus(str, Enum):
32166
TASK_ACTION_FORCE_HEALTHY = "force_healthy"
33167
TASK_ACTION_FORCE_BUSY = "force_busy"
34168
TASK_ACTION_CLEAR_FORCED_STATUS = "clear_forced_status"
169+
170+
171+
_CUSTOM_HEADER_PREFIX_LOWER = CUSTOM_HEADER_PREFIX.lower()
172+
_AUTHORIZATION_HEADER_LOWER = AUTHORIZATION_HEADER.lower()
173+
174+
175+
def is_forwardable_header(header_name: str) -> bool:
176+
"""Return True if the header may be forwarded to agent code.
177+
178+
Rules (from the AgentCore runtime header allowlist docs):
179+
- Not in the restricted headers list
180+
- Does not start with ``x-amz-`` (reserved for AWS SigV4 signing)
181+
- Does not start with ``x-amzn-`` unless it starts with the legacy
182+
``X-Amzn-Bedrock-AgentCore-Runtime-Custom-`` prefix
183+
"""
184+
lower = header_name.lower()
185+
if lower in RESTRICTED_HEADERS:
186+
return False
187+
if lower.startswith("x-amz-"):
188+
return False
189+
if lower.startswith("x-amzn-") and not lower.startswith(_CUSTOM_HEADER_PREFIX_LOWER):
190+
return False
191+
return True

tests/bedrock_agentcore/runtime/test_a2a.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,39 @@ def _test():
247247

248248
self._run_in_isolated_context(_test)
249249

250+
def test_forwards_non_restricted_custom_headers(self):
251+
"""Non-restricted headers (e.g. X-Api-Key) are forwarded; restricted ones are not."""
252+
253+
def _test():
254+
from starlette.requests import Request
255+
256+
scope = {
257+
"type": "http",
258+
"method": "POST",
259+
"path": "/",
260+
"headers": [
261+
(b"x-api-key", b"my-key"),
262+
(b"x-custom-signature", b"sha256=abc"),
263+
(b"content-type", b"application/json"), # restricted
264+
(b"x-amz-date", b"20250101T000000Z"), # restricted (x-amz-)
265+
(b"x-amzn-trace-id", b"trace-123"), # restricted (x-amzn-)
266+
],
267+
"query_string": b"",
268+
}
269+
request = Request(scope)
270+
builder = BedrockCallContextBuilder()
271+
builder.build(request)
272+
273+
headers = BedrockAgentCoreContext.get_request_headers()
274+
assert headers is not None
275+
assert any(k.lower() == "x-api-key" for k in headers)
276+
assert any(k.lower() == "x-custom-signature" for k in headers)
277+
assert not any(k.lower() == "content-type" for k in headers)
278+
assert not any(k.lower() == "x-amz-date" for k in headers)
279+
assert not any(k.lower() == "x-amzn-trace-id" for k in headers)
280+
281+
self._run_in_isolated_context(_test)
282+
250283
def test_auto_generates_request_id_when_missing(self):
251284
def _test():
252285
from starlette.requests import Request

tests/bedrock_agentcore/runtime/test_ag_ui.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,39 @@ def _test():
263263

264264
self._run_in_isolated_context(_test)
265265

266+
def test_forwards_non_restricted_custom_headers(self):
267+
"""Non-restricted headers (e.g. X-Api-Key) are forwarded; restricted ones are not."""
268+
269+
def _test():
270+
from starlette.requests import Request
271+
272+
scope = {
273+
"type": "http",
274+
"method": "POST",
275+
"path": "/invocations",
276+
"headers": [
277+
(b"x-api-key", b"my-key"),
278+
(b"x-custom-signature", b"sha256=abc"),
279+
(b"content-type", b"application/json"), # restricted
280+
(b"x-amz-date", b"20250101T000000Z"), # restricted (x-amz-)
281+
(b"x-amzn-trace-id", b"trace-123"), # restricted (x-amzn-)
282+
],
283+
"query_string": b"",
284+
}
285+
request = Request(scope)
286+
app = AGUIApp()
287+
app._build_request_context(request)
288+
289+
headers = BedrockAgentCoreContext.get_request_headers()
290+
assert headers is not None
291+
assert any(k.lower() == "x-api-key" for k in headers)
292+
assert any(k.lower() == "x-custom-signature" for k in headers)
293+
assert not any(k.lower() == "content-type" for k in headers)
294+
assert not any(k.lower() == "x-amz-date" for k in headers)
295+
assert not any(k.lower() == "x-amzn-trace-id" for k in headers)
296+
297+
self._run_in_isolated_context(_test)
298+
266299
def test_auto_generates_request_id_when_missing(self):
267300
def _test():
268301
from starlette.requests import Request

0 commit comments

Comments
 (0)