From 7e309a59ddc4ba55e5c4f30634ec975398c9b120 Mon Sep 17 00:00:00 2001 From: Caleigh Runge-Hottman Date: Thu, 20 Aug 2026 15:09:37 -0400 Subject: [PATCH] Fix fakefront S3 compatibility for moto [RHELDST-44306] Two changes to fakefront's S3 session for compatibility with spec-compliant S3 implementations (e.g. moto): 1. Sign requests with AWS SigV4. Per the S3 API spec, response override query parameters (e.g. response-content-type) require authenticated requests; unsigned requests return 403. This adds SigV4 signing using the same credentials already configured via environment variables. 2. Stream S3 response bytes without transparent decompression. The requests library auto-decompresses Content-Encoding: gzip bodies via urllib3, but fakefront passes the original S3 headers (including Content-Encoding: gzip) through to the client unchanged. This mismatch causes ContentDecodingError on the client side. Using raw.stream(decode_content=False) preserves the original bytes, keeping headers and body consistent. Both changes are backwards-compatible with LocalStack, which accepts signed requests and does not set Content-Encoding: gzip. --- support/fakefront/wsgi.py | 43 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/support/fakefront/wsgi.py b/support/fakefront/wsgi.py index 7095f737..563e1131 100644 --- a/support/fakefront/wsgi.py +++ b/support/fakefront/wsgi.py @@ -1,7 +1,11 @@ import logging import os from typing import Any, Callable, Dict, Iterable, List, Tuple +from urllib.parse import urlparse +import botocore.auth +import botocore.awsrequest +import botocore.credentials import requests from exodus_lambda.functions.origin_request import OriginRequest @@ -13,6 +17,40 @@ BUCKET_URL = os.environ["EXODUS_FAKEFRONT_BUCKET_URL"] + +class BotocoreAuth(requests.auth.AuthBase): + """Sign requests using AWS Signature V4 via botocore. + + This is needed because S3 requires authenticated requests when using + response override query parameters (e.g. response-content-type). + """ + + def __init__(self): + self.credentials = botocore.credentials.Credentials( + access_key=os.environ.get("AWS_ACCESS_KEY_ID", "fake-key-id"), + secret_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "fake-key"), + token=os.environ.get("AWS_SESSION_TOKEN", "fake-token"), + ) + self.signer = botocore.auth.SigV4Auth( + self.credentials, + "s3", + os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), + ) + + def __call__(self, r): + url = urlparse(r.url) + headers = {k: v for k, v in r.headers.items()} + headers["Host"] = url.hostname + aws_request = botocore.awsrequest.AWSRequest( + method=r.method, + url=r.url, + headers=headers, + ) + self.signer.add_auth(aws_request) + r.headers.update(dict(aws_request.headers)) + return r + + # Type hints for the wsgi start_response callable. StartResponseHeaders = List[Tuple[str, str]] StartResponse = Callable[[str, StartResponseHeaders], None] @@ -34,6 +72,7 @@ def __init__(self): self.origin_request = OriginRequest() self.origin_response = OriginResponse() self.s3_session = requests.Session() + self.s3_session.auth = BotocoreAuth() def __call__( self, environ: Dict[str, Any], start_response: StartResponse @@ -80,7 +119,9 @@ def __call__( start_response( origin_response_out.wsgi_status, origin_response_out.wsgi_headers ) - return origin_response_out.wsgi_body or s3_response.iter_content() + return origin_response_out.wsgi_body or s3_response.raw.stream( + amt=65536, decode_content=False + ) def do_s3_request(self, origin_request_out: LambdaOutput): """Do a request to S3 bucket based on the value returned from