Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ INTEGRATION_core_TESTS = \
tests/integration/test_handlers.py \
tests/integration/test_concurrent_operations.py \
tests/integration/test_per_key_encryption.py \
tests/integration/test_presigned_put_e2e.py \
tests/integration/test_redis_coordination.py
INTEGRATION_multipart_TESTS = \
tests/integration/test_upload_part_copy.py \
Expand Down
27 changes: 18 additions & 9 deletions s3proxy/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,20 @@ async def _release_after_stream(iterator, reserved: int):
await concurrency.release_memory(reserved)


def _needs_body_for_signature(headers: dict[str, str]) -> bool:
"""Body is needed only when x-amz-content-sha256 is absent.
def _is_presigned_request(query: dict[str, list[str]]) -> bool:
"""Presigned URLs sign UNSIGNED-PAYLOAD; body is not needed for verification."""
return "X-Amz-Signature" in query or "Signature" in query


def _needs_body_for_signature(headers: dict[str, str], query: dict[str, list[str]]) -> bool:
"""Body is needed only when x-amz-content-sha256 is absent (header auth).

The verifier uses that header as the payload hash verbatim and only rehashes
the body as a fallback when it is missing. Buffering it otherwise just pins
the whole part in memory.
the body as a fallback when it is missing. Presigned URLs always use
UNSIGNED-PAYLOAD in the canonical request, so the body is never required.
"""
if _is_presigned_request(query):
return False
return headers.get("x-amz-content-sha256", "") == ""


Expand All @@ -75,9 +82,11 @@ def _parse_content_length(headers: dict[str, str]) -> int:
return 0


def _defer_signature_for_body(headers: dict[str, str], content_length: int) -> bool:
"""Large bodies without x-amz-content-sha256 are hashed while streaming."""
return _needs_body_for_signature(headers) and content_length > crypto.MAX_BUFFER_SIZE
def _defer_signature_for_body(
headers: dict[str, str], content_length: int, query: dict[str, list[str]]
) -> bool:
"""Large header-auth bodies without x-amz-content-sha256 are hashed while streaming."""
return _needs_body_for_signature(headers, query) and content_length > crypto.MAX_BUFFER_SIZE


def _signature_path(request: Request) -> str:
Expand Down Expand Up @@ -222,10 +231,10 @@ async def _handle_proxy_request_impl(

content_length = _parse_content_length(headers)
defer_sig = request.method in ("PUT", "POST") and _defer_signature_for_body(
headers, content_length
headers, content_length, query
)

needs_body = request.method in ("PUT", "POST") and _needs_body_for_signature(headers)
needs_body = request.method in ("PUT", "POST") and _needs_body_for_signature(headers, query)
body = b""
if needs_body and not defer_sig:
body = await request.body()
Expand Down
2 changes: 2 additions & 0 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ services:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
tmpfs:
- /data:size=4g

s3proxy:
profiles: ["oom"]
Expand Down
19 changes: 12 additions & 7 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ def _find_free_port() -> int:


@contextlib.contextmanager
def minio_backend() -> Generator[str]:
"""Yield a MinIO HTTP endpoint, starting a throwaway container if needed."""
for port in (9000, 19000, 19001):
endpoint = f"http://localhost:{port}"
if _is_minio(endpoint):
yield endpoint
return
def minio_backend(*, isolated: bool = False) -> Generator[str]:
"""Yield a MinIO HTTP endpoint, starting a throwaway container if needed.

When *isolated* is True, always start a dedicated container (and remove it on
exit). Use for heavy e2e shards so tests do not fill the shared compose MinIO.
"""
if not isolated:
for port in (9000, 19000, 19001):
endpoint = f"http://localhost:{port}"
if _is_minio(endpoint):
yield endpoint
return

port = 19000
while port < 19100:
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/passthrough_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ def verify_session(
log_path = log_path or Path(tempfile.gettempdir()) / f"s3proxy-passthrough-verify-{port}.log"
bucket = f"verify-{uuid.uuid4().hex[:8]}"

with minio_backend() as minio_host, open(log_path, "w") as log_file:
with minio_backend(isolated=True) as minio_host, open(log_path, "w") as log_file:
env = os.environ.copy()
env.update(
{
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_copy_per_part_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _find_free_port() -> int:
def metrics_server():
port = _find_free_port()
with (
minio_backend() as minio_host,
minio_backend(isolated=True) as minio_host,
run_s3proxy(
port,
log_output=False,
Expand Down
Loading