Skip to content

Commit 097b458

Browse files
fix(rest): handle empty body in AWS SigV4 signing (#2827)
# Rationale for this change The x-amz-content-sha256 header is required for AWS requests. It provides a hash of the request payload. If there is no payload, you must provide the hash of an empty string. ## Are these changes tested? ## Are there any user-facing changes? --------- Co-authored-by: Fokko Driesprong <fokko@apache.org>
1 parent 832f83d commit 097b458

2 files changed

Lines changed: 72 additions & 2 deletions

File tree

pyiceberg/catalog/rest/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ class IdentifierKind(Enum):
216216
SIGV4 = "rest.sigv4-enabled"
217217
SIGV4_REGION = "rest.signing-region"
218218
SIGV4_SERVICE = "rest.signing-name"
219+
EMPTY_BODY_SHA256: str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
219220
OAUTH2_SERVER_URI = "oauth2-server-uri"
220221
SNAPSHOT_LOADING_MODE = "snapshot-loading-mode"
221222
AUTH = "auth"
@@ -560,7 +561,11 @@ def add_headers(self, request: PreparedRequest, **kwargs: Any) -> None: # pylin
560561
params = dict(parse.parse_qsl(query))
561562

562563
# remove the connection header as it will be updated after signing
563-
del request.headers["connection"]
564+
if "connection" in request.headers:
565+
del request.headers["connection"]
566+
# For empty bodies, explicitly set the content hash header to the SHA256 of an empty string
567+
if not request.body:
568+
request.headers["x-amz-content-sha256"] = EMPTY_BODY_SHA256
564569

565570
aws_request = AWSRequest(
566571
method=request.method, url=url, params=params, data=request.body, headers=dict(request.headers)

tests/catalog/test_rest.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,21 @@
2222
from unittest import mock
2323

2424
import pytest
25+
from requests import Request
26+
from requests.adapters import HTTPAdapter
2527
from requests.exceptions import HTTPError
2628
from requests_mock import Mocker
2729

2830
import pyiceberg
2931
from pyiceberg.catalog import PropertiesUpdateSummary, load_catalog
30-
from pyiceberg.catalog.rest import DEFAULT_ENDPOINTS, OAUTH2_SERVER_URI, SNAPSHOT_LOADING_MODE, Capability, RestCatalog
32+
from pyiceberg.catalog.rest import (
33+
DEFAULT_ENDPOINTS,
34+
EMPTY_BODY_SHA256,
35+
OAUTH2_SERVER_URI,
36+
SNAPSHOT_LOADING_MODE,
37+
Capability,
38+
RestCatalog,
39+
)
3140
from pyiceberg.exceptions import (
3241
AuthorizationExpiredError,
3342
NamespaceAlreadyExistsError,
@@ -451,6 +460,62 @@ def test_list_tables_200_sigv4(rest_mock: Mocker) -> None:
451460
assert rest_mock.called
452461

453462

463+
def test_sigv4_sign_request_without_body(rest_mock: Mocker) -> None:
464+
existing_token = "existing_token"
465+
466+
catalog = RestCatalog(
467+
"rest",
468+
**{
469+
"uri": TEST_URI,
470+
"token": existing_token,
471+
"rest.sigv4-enabled": "true",
472+
"rest.signing-region": "us-west-2",
473+
"client.access-key-id": "id",
474+
"client.secret-access-key": "secret",
475+
},
476+
)
477+
478+
prepared = catalog._session.prepare_request(Request("GET", f"{TEST_URI}v1/config"))
479+
adapter = catalog._session.adapters[catalog.uri]
480+
assert isinstance(adapter, HTTPAdapter)
481+
adapter.add_headers(prepared)
482+
483+
assert prepared.headers["Authorization"].startswith("AWS4-HMAC-SHA256")
484+
assert prepared.headers["Original-Authorization"] == f"Bearer {existing_token}"
485+
assert prepared.headers["x-amz-content-sha256"] == EMPTY_BODY_SHA256
486+
487+
488+
def test_sigv4_sign_request_with_body(rest_mock: Mocker) -> None:
489+
existing_token = "existing_token"
490+
491+
catalog = RestCatalog(
492+
"rest",
493+
**{
494+
"uri": TEST_URI,
495+
"token": existing_token,
496+
"rest.sigv4-enabled": "true",
497+
"rest.signing-region": "us-west-2",
498+
"client.access-key-id": "id",
499+
"client.secret-access-key": "secret",
500+
},
501+
)
502+
503+
prepared = catalog._session.prepare_request(
504+
Request(
505+
"POST",
506+
f"{TEST_URI}v1/namespaces",
507+
data={"namespace": "asdfasd"},
508+
)
509+
)
510+
adapter = catalog._session.adapters[catalog.uri]
511+
assert isinstance(adapter, HTTPAdapter)
512+
adapter.add_headers(prepared)
513+
514+
assert prepared.headers["Authorization"].startswith("AWS4-HMAC-SHA256")
515+
assert prepared.headers["Original-Authorization"] == f"Bearer {existing_token}"
516+
assert prepared.headers.get("x-amz-content-sha256") != EMPTY_BODY_SHA256
517+
518+
454519
def test_list_tables_404(rest_mock: Mocker) -> None:
455520
namespace = "examples"
456521
rest_mock.get(

0 commit comments

Comments
 (0)