Skip to content

Commit ea6f871

Browse files
committed
fix(aiohttp): Preserve SigV4-signed propagation headers
aiobotocore sends botocore's already-signed requests through aiohttp. The Boto3 integration adds sentry-trace and baggage before signing, so both end up named in SignedHeaders, and the aiohttp client integration then rewrote them on the way out, appending to baggage. The sent value no longer matched the signed one, so the service rejected the request with SignatureDoesNotMatch. Against Cloudflare R2 this failed every signed call for as long as a transaction was active, while calls made outside one kept working, so it presented as intermittent and credential-shaped. stdlib already skips propagation headers covered by SignedHeaders, which is why synchronous boto3 is unaffected. Apply the same rule on the aiohttp path, reading the signed names from the Authorization header, or from the query string when the request is a presigned URL, as stdlib does for both. Headers that are not signed are still added, so propagation is unchanged. Fixes #7426
1 parent 42aa532 commit ea6f871

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

‎sentry_sdk/integrations/aiohttp.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
should_propagate_trace,
4040
)
4141
from sentry_sdk.utils import (
42+
_get_aws_sigv4_signed_headers_from_authorization_header,
43+
_get_aws_sigv4_signed_headers_from_url_query_string,
4244
CONTEXTVARS_ERROR_MESSAGE,
4345
HAS_REAL_CONTEXTVARS,
4446
SENSITIVE_DATA_SUBSTITUTE,
@@ -464,12 +466,48 @@ async def on_request_start(
464466
span = legacy_span
465467

466468
if should_propagate_trace(client, str(params.url)):
469+
# A request already signed with AWS SigV4 — aiobotocore routes
470+
# botocore's signed requests through aiohttp — covers whichever
471+
# propagation headers `SignedHeaders` names. Rewriting one of those
472+
# here, after signing, makes the sent value differ from the signed
473+
# one, and the service rejects the request with
474+
# `SignatureDoesNotMatch`. `stdlib` skips signed headers for the
475+
# same reason; do the same on this path.
476+
signed_headers: "Set[str]" = set()
477+
with capture_internal_exceptions():
478+
authorization = params.headers.get("Authorization")
479+
if authorization:
480+
if isinstance(authorization, bytes):
481+
authorization = authorization.decode("latin-1")
482+
signed_headers = (
483+
_get_aws_sigv4_signed_headers_from_authorization_header(
484+
authorization
485+
)
486+
)
487+
else:
488+
# A presigned URL carries its signed header names in the
489+
# query string instead of an Authorization header.
490+
signed_headers = (
491+
_get_aws_sigv4_signed_headers_from_url_query_string(
492+
str(params.url)
493+
)
494+
)
495+
467496
for (
468497
key,
469498
value,
470499
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
471500
span=span
472501
):
502+
if key.lower() in signed_headers:
503+
logger.debug(
504+
"[Tracing] Not adding `{key}` header to outgoing request "
505+
"to {url}: it is covered by the AWS SigV4 signature.".format(
506+
key=key, url=params.url
507+
)
508+
)
509+
continue
510+
473511
logger.debug(
474512
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
475513
key=key, value=value, url=params.url

‎tests/integrations/aiohttp/test_aiohttp.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2374,3 +2374,122 @@ async def hello(request):
23742374
assert event["request"]["env"] == {"REMOTE_ADDR": "127.0.0.1"}
23752375
else:
23762376
assert "env" not in event["request"]
2377+
2378+
2379+
@pytest.mark.asyncio
2380+
async def test_outgoing_trace_headers_leave_sigv4_signed_headers_alone(
2381+
sentry_init, aiohttp_raw_server, aiohttp_client
2382+
):
2383+
"""Regression test for #7426.
2384+
2385+
aiobotocore sends botocore's already-signed requests through aiohttp. When
2386+
`SignedHeaders` covers `sentry-trace` or `baggage`, rewriting either one
2387+
here changes the value the service verifies the signature against, and the
2388+
request is rejected with `SignatureDoesNotMatch`. Signed propagation
2389+
headers must be left exactly as botocore set them.
2390+
"""
2391+
sentry_init(
2392+
integrations=[AioHttpIntegration()],
2393+
traces_sample_rate=1.0,
2394+
)
2395+
2396+
async def handler(request):
2397+
return web.Response(text="OK")
2398+
2399+
raw_server = await aiohttp_raw_server(handler)
2400+
2401+
signed_baggage = "sentry-trace_id=aaaa,sentry-sample_rate=1.0"
2402+
signed_sentry_trace = "0123456789012345678901234567890-1234567890123456-1"
2403+
authorization = (
2404+
"AWS4-HMAC-SHA256 Credential=key/20260908/auto/s3/aws4_request, "
2405+
"SignedHeaders=baggage;host;sentry-trace;x-amz-date, "
2406+
"Signature=deadbeef"
2407+
)
2408+
2409+
with start_transaction(name="aws-call", op="greeting.sniff"):
2410+
client = await aiohttp_client(raw_server)
2411+
resp = await client.get(
2412+
"/",
2413+
headers={
2414+
"Authorization": authorization,
2415+
"baggage": signed_baggage,
2416+
"sentry-trace": signed_sentry_trace,
2417+
},
2418+
)
2419+
2420+
# Both headers are covered by the signature, so neither may be touched.
2421+
assert resp.request_info.headers["baggage"] == signed_baggage
2422+
assert resp.request_info.headers["sentry-trace"] == signed_sentry_trace
2423+
2424+
2425+
@pytest.mark.asyncio
2426+
async def test_outgoing_trace_headers_added_when_not_signed(
2427+
sentry_init, aiohttp_raw_server, aiohttp_client
2428+
):
2429+
"""An unsigned propagation header is still added, even alongside SigV4.
2430+
2431+
Only the headers named in `SignedHeaders` are off limits; skipping every
2432+
header on a signed request would silently drop propagation.
2433+
"""
2434+
sentry_init(
2435+
integrations=[AioHttpIntegration()],
2436+
traces_sample_rate=1.0,
2437+
)
2438+
2439+
async def handler(request):
2440+
return web.Response(text="OK")
2441+
2442+
raw_server = await aiohttp_raw_server(handler)
2443+
2444+
signed_sentry_trace = "0123456789012345678901234567890-1234567890123456-1"
2445+
# `baggage` is deliberately absent from SignedHeaders.
2446+
authorization = (
2447+
"AWS4-HMAC-SHA256 Credential=key/20260908/auto/s3/aws4_request, "
2448+
"SignedHeaders=host;sentry-trace;x-amz-date, "
2449+
"Signature=deadbeef"
2450+
)
2451+
2452+
with start_transaction(name="aws-call", op="greeting.sniff"):
2453+
client = await aiohttp_client(raw_server)
2454+
resp = await client.get(
2455+
"/",
2456+
headers={
2457+
"Authorization": authorization,
2458+
"sentry-trace": signed_sentry_trace,
2459+
},
2460+
)
2461+
2462+
assert resp.request_info.headers["sentry-trace"] == signed_sentry_trace
2463+
assert "baggage" in resp.request_info.headers
2464+
2465+
@pytest.mark.asyncio
2466+
async def test_outgoing_trace_headers_leave_presigned_url_signed_headers_alone(
2467+
sentry_init, aiohttp_raw_server, aiohttp_client
2468+
):
2469+
"""A presigned URL names its signed headers in the query string.
2470+
2471+
There is no Authorization header to read in that case, so the signed names
2472+
have to come from `X-Amz-SignedHeaders`.
2473+
"""
2474+
sentry_init(
2475+
integrations=[AioHttpIntegration()],
2476+
traces_sample_rate=1.0,
2477+
)
2478+
2479+
async def handler(request):
2480+
return web.Response(text="OK")
2481+
2482+
raw_server = await aiohttp_raw_server(handler)
2483+
2484+
signed_baggage = "sentry-trace_id=aaaa,sentry-sample_rate=1.0"
2485+
2486+
with start_transaction(name="aws-call", op="greeting.sniff"):
2487+
client = await aiohttp_client(raw_server)
2488+
resp = await client.get(
2489+
"/?X-Amz-Algorithm=AWS4-HMAC-SHA256"
2490+
"&X-Amz-SignedHeaders=baggage%3Bhost"
2491+
"&X-Amz-Signature=deadbeef",
2492+
headers={"baggage": signed_baggage},
2493+
)
2494+
2495+
assert resp.request_info.headers["baggage"] == signed_baggage

0 commit comments

Comments
 (0)