This is an automated email from the ASF dual-hosted git repository.
kenhuuu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
The following commit(s) were added to refs/heads/master by this push:
new da8a53b3e1 Fix SigV4 request signing across GLVs to sign a minimal
header set CTR
da8a53b3e1 is described below
commit da8a53b3e1ad29733bf9604905a6c256808685a3
Author: Ken Hu <[email protected]>
AuthorDate: Wed Jul 15 18:15:27 2026 -0700
Fix SigV4 request signing across GLVs to sign a minimal header set CTR
The SigV4 auth interceptors signed transport-managed headers
(accept-encoding,
content-type, content-length) that the underlying HTTP stacks add or rewrite
after signing, producing signatures that no longer matched the bytes sent
and
were rejected by SigV4-verifying endpoints. Java, JavaScript and .NET now
sign
only host and the headers the AWS SDK adds itself (x-amz-date, the payload
hash,
and x-amz-security-token for session credentials); Python already did this.
Go
additionally strips the default port (443/80) from the signed and sent Host
and
signs its session token.
Assisted-by: Claude Code:claude-opus-4-8
---
gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 32 ++++---
.../test/Gremlin.Net.UnitTest/Driver/AuthTests.cs | 50 +++++++++++
.../tinkerpop/gremlin/driver/auth/Sigv4.java | 16 ++--
.../tinkerpop/gremlin/driver/auth/Sigv4Test.java | 42 +++++++++-
gremlin-go/driver/auth/auth.go | 32 ++++++-
gremlin-go/driver/auth/auth_test.go | 97 ++++++++++++++++++++++
gremlin-js/gremlin-javascript/lib/driver/auth.ts | 10 ++-
.../gremlin-javascript/test/unit/auth-test.js | 46 ++++++++++
.../main/python/tests/unit/driver/test_sigv4.py | 82 ++++++++++++++++++
9 files changed, 377 insertions(+), 30 deletions(-)
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
index d713dc9473..69c6e1600a 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
@@ -62,8 +62,8 @@ namespace Gremlin.Net.Driver
/// used and the resolved provider is cached on first use (the
provider itself handles
/// credential refresh for expiring credentials like STS).
/// </summary>
- /// <param name="region">The AWS region (e.g. "us-east-1").</param>
- /// <param name="service">The AWS service name (e.g.
"neptune-db").</param>
+ /// <param name="region">The region.</param>
+ /// <param name="service">The service name.</param>
/// <param name="credentials">
/// Optional AWS credentials. When null, the default credential
chain is used.
/// </param>
@@ -125,14 +125,12 @@ namespace Gremlin.Net.Driver
OverrideSigningServiceName =
clientConfig.AuthenticationServiceName,
};
- // Copy headers (skip Host — signer adds it)
- foreach (var header in context.Headers)
- {
- if (!string.Equals(header.Key, "Host",
StringComparison.OrdinalIgnoreCase))
- {
- awsRequest.Headers[header.Key] = header.Value;
- }
- }
+ // Signed header set: host (derived from the endpoint),
x-amz-content-sha256 and
+ // x-amz-date, plus x-amz-security-token for session credentials
(all set below). The
+ // request's own headers (accept, content-type, accept-encoding,
...) are deliberately
+ // NOT copied into the signature: the HTTP client may add or
rewrite transport-managed
+ // headers after this interceptor runs, so a signature covering
them would not match
+ // the bytes sent.
// Copy query parameters
var query = context.Uri.Query;
@@ -154,6 +152,13 @@ namespace Gremlin.Net.Driver
var payloadHash = context.GetPayloadHash();
awsRequest.Headers["x-amz-content-sha256"] = payloadHash;
+ // For temporary credentials, add the session token BEFORE signing
so it is bound into
+ // the signature rather than sent as a weaker present-but-unsigned
header.
+ if (!string.IsNullOrEmpty(credentials.Token))
+ {
+ awsRequest.Headers["X-Amz-Security-Token"] = credentials.Token;
+ }
+
// Sign the request
signer.Sign(awsRequest, clientConfig, new RequestMetrics(),
credentials);
@@ -171,10 +176,11 @@ namespace Gremlin.Net.Driver
}
context.Headers["x-amz-content-sha256"] = payloadHash;
- // Add session token if temporary credentials
- if (!string.IsNullOrEmpty(credentials.Token))
+ // Copy back the session token that was signed above (for
temporary credentials), so
+ // the header on the wire matches exactly what the signature
covered.
+ if (awsRequest.Headers.ContainsKey("X-Amz-Security-Token"))
{
- context.Headers["X-Amz-Security-Token"] = credentials.Token;
+ context.Headers["X-Amz-Security-Token"] =
awsRequest.Headers["X-Amz-Security-Token"];
}
}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
index 76c9910d7f..c001c17313 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
@@ -241,5 +241,55 @@ namespace Gremlin.Net.UnitTest.Driver
Assert.Contains("byte[]", ex.Message);
}
+
+ // Only host and the headers the AWS SDK adds itself are signed.
Transport-managed headers
+ // (accept-encoding, content-type, ...) are never signed, and the
session token is signed
+ // only when session credentials are used.
+ private static HttpRequestContext
CreateSigv4ContextWithTransportHeaders()
+ {
+ return new HttpRequestContext("POST", new
Uri("https://example.com:8182/gremlin"),
+ new Dictionary<string, string>
+ {
+ { "Accept", "application/vnd.graphbinary-v4.0" },
+ { "Content-Type", "application/json" },
+ { "Accept-Encoding", "deflate" },
+ { "User-Agent", "gremlin-dotnet-test" },
+ },
+ Encoding.UTF8.GetBytes("{\"gremlin\":\"g.V().count()\"}"));
+ }
+
+ private static string SignedHeaders(HttpRequestContext context)
+ {
+ var authorization = context.Headers["Authorization"];
+ const string marker = "SignedHeaders=";
+ var start = authorization.IndexOf(marker,
StringComparison.Ordinal) + marker.Length;
+ var end = authorization.IndexOf(',', start);
+ return end < 0 ? authorization.Substring(start) :
authorization.Substring(start, end - start);
+ }
+
+ [Fact]
+ public async Task
SigV4AuthShouldSignOnlyMinimalHeaderSetForBasicCredentials()
+ {
+ var interceptor = Auth.Sigv4("region-1", "example-service",
TestBasicCredentials);
+ var context = CreateSigv4ContextWithTransportHeaders();
+
+ await interceptor(context);
+
+ // The .NET SDK signer includes x-amz-content-sha256 in the signed
set; that is its
+ // natural behavior and is intentionally left as-is.
+ Assert.Equal("host;x-amz-content-sha256;x-amz-date",
SignedHeaders(context));
+ }
+
+ [Fact]
+ public async Task
SigV4AuthShouldSignSecurityTokenForSessionCredentials()
+ {
+ var interceptor = Auth.Sigv4("region-1", "example-service",
TestSessionCredentials);
+ var context = CreateSigv4ContextWithTransportHeaders();
+
+ await interceptor(context);
+
+
Assert.Equal("host;x-amz-content-sha256;x-amz-date;x-amz-security-token",
SignedHeaders(context));
+ Assert.Equal("MOCK_TOKEN",
context.Headers["X-Amz-Security-Token"]);
+ }
}
}
diff --git
a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java
b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java
index a19e75d749..2dd786a575 100644
---
a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java
+++
b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java
@@ -137,18 +137,14 @@ public class Sigv4 implements Auth {
checkNotNull(request.getUri(), "The request URI must not be null");
checkNotNull(request.getMethod(), "The request method must not be
null");
- // convert the headers to the internal API format
- final Map<String, String> headers = request.headers();
+ // Sign an empty header set: the signer adds (and signs) the Host,
X-Amz-Date and
+ // X-Amz-Content-Sha256 headers itself (plus X-Amz-Security-Token for
session
+ // credentials). The request's own headers (accept, content-type,
accept-encoding,
+ // user-agent, ...) are deliberately NOT signed: transport-managed
headers may be added or
+ // rewritten after this interceptor runs, so a signature covering them
would no longer
+ // match the bytes actually sent.
final Map<String, List<String>> headersInternal = new HashMap<>();
- // we don't want to add the Host header as the Signer always adds the
host header.
- for (Map.Entry<String, String> header : headers.entrySet()) {
- // Skip adding the Host header as the signing process will add one.
- if (!header.getKey().equalsIgnoreCase(HttpRequest.Headers.HOST)) {
- headersInternal.put(header.getKey(),
Collections.singletonList(header.getValue()));
- }
- }
-
// convert the parameters to the internal API format
final URI uri = request.getUri();
final Map<String, List<String>> parametersInternal =
extractParametersFromQueryString(uri.getQuery());
diff --git
a/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java
b/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java
index a72bcd0946..1ccbe3ab4c 100644
---
a/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java
+++
b/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java
@@ -49,7 +49,7 @@ import static
software.amazon.awssdk.http.auth.aws.internal.signer.util.SignerCo
import static
software.amazon.awssdk.http.auth.aws.internal.signer.util.SignerConstant.X_AMZ_SECURITY_TOKEN;
public class Sigv4Test {
- private static final String REGION = "us-west-2";
+ private static final String REGION = "region-1";
private static final String SERVICE_NAME = "service-name";
private static final String HOST = "localhost";
private static final String URI_WITH_QUERY_PARAMS = "http://" + HOST +
":8182?a=1&b=2";
@@ -142,4 +142,44 @@ public class Sigv4Test {
containsString("/" + REGION +
"/service-name/aws4_request"),
containsString("Signature=")));
}
+
+ @Test
+ public void shouldSignOnlyMinimalHeaderSetForBasicCredentials() throws
Exception {
+
when(credentialsProvider.resolveCredentials()).thenReturn(AwsBasicCredentials.create(KEY,
SECRET));
+ final HttpRequest httpRequest = createRequestWithTransportHeaders();
+ sigv4.intercept(httpRequest);
+
+ // Only host and the headers the AWS SDK adds itself are signed.
Transport-managed headers
+ // (accept-encoding, content-type, ...) are never signed, or the
signature would not match
+ // what the server reconstructs.
+ assertEquals("host;x-amz-content-sha256;x-amz-date",
signedHeaders(httpRequest));
+ }
+
+ @Test
+ public void shouldSignSecurityTokenForSessionCredentials() throws
Exception {
+ when(credentialsProvider.resolveCredentials())
+ .thenReturn(AwsSessionCredentials.create(KEY, SECRET,
"session-token"));
+ final HttpRequest httpRequest = createRequestWithTransportHeaders();
+ sigv4.intercept(httpRequest);
+
+
assertEquals("host;x-amz-content-sha256;x-amz-date;x-amz-security-token",
signedHeaders(httpRequest));
+ }
+
+ private HttpRequest createRequestWithTransportHeaders() throws Exception {
+ final byte[] body = "{\"gremlin\":\"g.V().count()\"}".getBytes();
+ final HttpRequest httpRequest = new HttpRequest(new HashMap<>(), body,
new URI(URI_WITH_QUERY_PARAMS));
+ // Seed transport-managed / content headers that must NOT end up in
SignedHeaders.
+ httpRequest.headers().put("Accept",
"application/vnd.graphbinary-v4.0");
+ httpRequest.headers().put("Content-Type", "application/json");
+ httpRequest.headers().put("Accept-Encoding", "deflate");
+ httpRequest.headers().put("User-Agent", "gremlin-java-test");
+ return httpRequest;
+ }
+
+ private static String signedHeaders(final HttpRequest httpRequest) {
+ final String authorization = httpRequest.headers().get(AUTHORIZATION);
+ final int start = authorization.indexOf("SignedHeaders=") +
"SignedHeaders=".length();
+ final int end = authorization.indexOf(',', start);
+ return authorization.substring(start, end < 0 ? authorization.length()
: end);
+ }
}
diff --git a/gremlin-go/driver/auth/auth.go b/gremlin-go/driver/auth/auth.go
index d24330179d..15e16e0094 100644
--- a/gremlin-go/driver/auth/auth.go
+++ b/gremlin-go/driver/auth/auth.go
@@ -26,6 +26,8 @@ import (
"context"
"encoding/base64"
"fmt"
+ "net/http"
+ "strings"
"sync"
"time"
@@ -98,17 +100,39 @@ func SigV4WithCredentials(region, service string,
credentialsProvider aws.Creden
return err
}
- stdReq, err := req.ToStdRequest()
+ // Signed header set: host, x-amz-date, x-amz-content-sha256,
and (for session
+ // credentials) x-amz-security-token. The request's own headers
(accept, content-type,
+ // ...) are deliberately NOT signed: the HTTP client manages
transport headers such as
+ // Content-Length and Accept-Encoding after this interceptor
runs, so a signature covering
+ // them would not match the bytes actually sent.
+
+ // Strip the default port (443 for https, 80 for http) from the
host so the signed
+ // canonical Host and the Host header sent on the wire both
omit it, matching what a
+ // spec-compliant verifier reconstructs (a bare host). Mutating
req.URL here is safe: the
+ // driver parses a fresh URL per request, and this also fixes
the Host sent on the wire.
+ // Trim only the trailing ":port" so IPv6 literals keep their
brackets (using
+ // URL.Hostname() would drop them, yielding an unparseable host
like "::1").
+ port := req.URL.Port()
+ if (req.URL.Scheme == "https" && port == "443") ||
(req.URL.Scheme == "http" && port == "80") {
+ req.URL.Host = strings.TrimSuffix(req.URL.Host,
":"+port)
+ }
+
+ payloadHash := req.PayloadHash()
+ stdReq, err := http.NewRequest(req.Method, req.URL.String(),
nil)
if err != nil {
return err
}
- stdReq.Body = nil // Body is handled separately via payload hash
+ stdReq.Host = req.URL.Host
+ // Set the payload hash header BEFORE signing so it is part of
the signed set:
+ // aws-sdk-go-v2's SignHTTP takes the hash as a parameter but
does not add the header
+ // itself, and a present-but-unsigned header is rejected by the
server.
+ stdReq.Header.Set("X-Amz-Content-Sha256", payloadHash)
- if err := signer.SignHTTP(ctx, creds, stdReq,
req.PayloadHash(), service, region, time.Now()); err != nil {
+ if err := signer.SignHTTP(ctx, creds, stdReq, payloadHash,
service, region, time.Now()); err != nil {
return err
}
- // Copy signed headers back to HttpRequest
+ // Copy the SigV4 output headers back onto the request.
for k, v := range stdReq.Header {
req.Headers[k] = v
}
diff --git a/gremlin-go/driver/auth/auth_test.go
b/gremlin-go/driver/auth/auth_test.go
index 26779d7262..6a44aa3993 100644
--- a/gremlin-go/driver/auth/auth_test.go
+++ b/gremlin-go/driver/auth/auth_test.go
@@ -22,6 +22,7 @@ package auth
import (
"context"
"encoding/base64"
+ "net/url"
"strings"
"testing"
@@ -140,3 +141,99 @@ func TestSigV4(t *testing.T) {
assert.Contains(t, req.Headers.Get("Authorization"),
"AWS4-HMAC-SHA256")
})
}
+
+// signedHeadersFromAuth extracts the SignedHeaders list from an Authorization
header value.
+func signedHeadersFromAuth(authHeader string) string {
+ const marker = "SignedHeaders="
+ idx := strings.Index(authHeader, marker)
+ if idx < 0 {
+ return ""
+ }
+ rest := authHeader[idx+len(marker):]
+ if end := strings.Index(rest, ","); end >= 0 {
+ return rest[:end]
+ }
+ return rest
+}
+
+// TestSigV4SignedHeaders pins the signed header set: only host and the
headers the AWS SDK adds
+// itself are signed, transport-managed headers such as accept-encoding are
never signed even when
+// present on the request, and the session token is signed only when session
credentials are used.
+func TestSigV4SignedHeaders(t *testing.T) {
+ // A default-port (443) https URL so the test also covers host:port
stripping: the signed Host
+ // must be the bare hostname, matching what a spec-compliant verifier
reconstructs.
+ newRequest := func() *gremlingo.HttpRequest {
+ req, err := gremlingo.NewHttpRequest("POST",
"https://example.com:443/gremlin")
+ assert.NoError(t, err)
+ // Seed transport-managed and content headers that must NOT end
up signed.
+ req.Headers.Set("Accept", graphBinaryMimeType)
+ req.Headers.Set("Content-Type", "application/json")
+ req.Headers.Set("Accept-Encoding", "deflate")
+ req.Headers.Set("User-Agent", "gremlin-go-test")
+ req.Body = []byte(`{"gremlin":"g.V().count()"}`)
+ return req
+ }
+
+ t.Run("basic credentials sign only host, date, content-sha256", func(t
*testing.T) {
+ req := newRequest()
+ provider := &mockCredentialsProvider{accessKey: "MOCK_ID",
secretKey: "MOCK_KEY"}
+ err := SigV4WithCredentials("region-1", "example-service",
provider)(req)
+ assert.NoError(t, err)
+
+ signedHeaders :=
signedHeadersFromAuth(req.Headers.Get("Authorization"))
+ // aws-sdk-go-v2 leaves x-amz-content-sha256 in the signed set
(see auth.go); that is the
+ // SDK's natural behavior and is intentionally left as-is.
+ assert.Equal(t, "host;x-amz-content-sha256;x-amz-date",
signedHeaders)
+ assert.NotContains(t, signedHeaders, "accept-encoding")
+ assert.NotContains(t, signedHeaders, "content-type")
+ assert.NotContains(t, signedHeaders, "x-amz-security-token")
+
+ // The signed (and sent) Host must omit the default :443 port.
+ assert.Equal(t, "example.com", req.URL.Host)
+ })
+
+ t.Run("session credentials also sign the security token", func(t
*testing.T) {
+ req := newRequest()
+ provider := &mockCredentialsProvider{accessKey: "MOCK_ID",
secretKey: "MOCK_KEY", sessionToken: "MOCK_TOKEN"}
+ err := SigV4WithCredentials("region-1", "example-service",
provider)(req)
+ assert.NoError(t, err)
+
+ signedHeaders :=
signedHeadersFromAuth(req.Headers.Get("Authorization"))
+ assert.Equal(t,
"host;x-amz-content-sha256;x-amz-date;x-amz-security-token", signedHeaders)
+ assert.Equal(t, "MOCK_TOKEN",
req.Headers.Get("X-Amz-Security-Token"))
+ })
+}
+
+// TestSigV4HostPortStripping verifies that the default port is removed from
the host for signing
+// while non-default ports are preserved, and that IPv6 literals keep their
brackets (stripping the
+// port must not corrupt the host into an unparseable form).
+func TestSigV4HostPortStripping(t *testing.T) {
+ cases := []struct {
+ url string
+ wantHost string
+ }{
+ {"https://example.com:443/gremlin", "example.com"},
+ {"http://example.com:80/gremlin", "example.com"},
+ {"https://example.com:8182/gremlin", "example.com:8182"},
+ {"https://example.com/gremlin", "example.com"},
+ {"https://[::1]:443/gremlin", "[::1]"},
+ {"https://[2001:db8::1]:443/gremlin", "[2001:db8::1]"},
+ {"https://[::1]:8182/gremlin", "[::1]:8182"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.url, func(t *testing.T) {
+ req, err := gremlingo.NewHttpRequest("POST", tc.url)
+ assert.NoError(t, err)
+ req.Body = []byte(`{"gremlin":"g.V()"}`)
+ provider := &mockCredentialsProvider{accessKey:
"MOCK_ID", secretKey: "MOCK_KEY"}
+
+ err = SigV4WithCredentials("region-1",
"example-service", provider)(req)
+ assert.NoError(t, err)
+ assert.Equal(t, tc.wantHost, req.URL.Host)
+ // The mutated URL must remain parseable (an IPv6 host
that lost its brackets would
+ // fail to re-parse, breaking the wire request built
from req.URL.String()).
+ _, parseErr := url.Parse(req.URL.String())
+ assert.NoError(t, parseErr)
+ })
+ }
+}
diff --git a/gremlin-js/gremlin-javascript/lib/driver/auth.ts
b/gremlin-js/gremlin-javascript/lib/driver/auth.ts
index 4d14f7c4f3..f5b32739dc 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/auth.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/auth.ts
@@ -65,6 +65,11 @@ export function sigv4(region: string, service: string,
credentialsProvider?: Aws
}
const url = new URL(request.url);
+ // Sign only the Host header; the signer itself adds (and signs)
x-amz-date, the payload hash,
+ // and x-amz-security-token for session credentials. The request's own
headers (accept,
+ // content-type, ...) are deliberately NOT fed into the signature: the
HTTP stack may add or
+ // rewrite transport-managed headers such as accept-encoding after this
interceptor runs, so a
+ // signature covering them would no longer match the bytes actually sent.
const signed = await signer.sign({
method: request.method,
protocol: url.protocol,
@@ -72,12 +77,13 @@ export function sigv4(region: string, service: string,
credentialsProvider?: Aws
port: url.port ? Number(url.port) : undefined,
path: url.pathname + url.search,
headers: {
- ...request.headers,
host: url.host,
},
body: request.body,
});
- request.headers = signed.headers;
+ // Merge the signed auth headers onto the request; the request's other
headers (accept,
+ // content-type, ...) still reach the wire, they are simply not part of
the signature.
+ request.headers = { ...request.headers, ...signed.headers };
};
}
diff --git a/gremlin-js/gremlin-javascript/test/unit/auth-test.js
b/gremlin-js/gremlin-javascript/test/unit/auth-test.js
index e35fb1f8f4..2ad4e30510 100644
--- a/gremlin-js/gremlin-javascript/test/unit/auth-test.js
+++ b/gremlin-js/gremlin-javascript/test/unit/auth-test.js
@@ -115,5 +115,51 @@ describe('auth', function () {
// Signing adds at least authorization and x-amz-date on top of the
originals
assert.ok(Object.keys(request.headers).length >= preSignKeys.length + 2);
});
+
+ // Extracts the SignedHeaders list from an Authorization header value.
+ function signedHeaders(authHeader) {
+ const marker = 'SignedHeaders=';
+ const start = authHeader.indexOf(marker) + marker.length;
+ const end = authHeader.indexOf(',', start);
+ return end < 0 ? authHeader.substring(start) :
authHeader.substring(start, end);
+ }
+
+ // Only host and the headers the AWS SDK adds itself are signed.
Transport-managed headers
+ // (accept-encoding, content-type, ...) are never signed, and the session
token is signed only
+ // when session credentials are used.
+ function createRequestWithTransportHeaders() {
+ return new HttpRequest('POST', 'https://example.com:8182/gremlin', {
+ 'accept': 'application/vnd.graphbinary-v4.0',
+ 'content-type': 'application/json',
+ 'accept-encoding': 'deflate',
+ 'user-agent': 'gremlin-js-test',
+ }, Buffer.from('{"gremlin":"g.V().count()"}'));
+ }
+
+ it('should sign only the minimal header set for basic credentials', async
function () {
+ const request = createRequestWithTransportHeaders();
+ const interceptor = sigv4('region-1', 'example-service', mockProvider);
+ await interceptor(request);
+
+ // smithy's SignatureV4 includes x-amz-content-sha256 in the signed set;
that is its
+ // natural behavior and is intentionally left as-is.
+ assert.strictEqual(signedHeaders(request.headers['authorization']),
+ 'host;x-amz-content-sha256;x-amz-date');
+ });
+
+ it('should sign the security token for session credentials', async
function () {
+ const providerWithToken = () => ({
+ accessKeyId: 'MOCK_ACCESS_KEY',
+ secretAccessKey: 'MOCK_SECRET_KEY',
+ sessionToken: 'MOCK_SESSION_TOKEN',
+ });
+ const request = createRequestWithTransportHeaders();
+ const interceptor = sigv4('region-1', 'example-service',
providerWithToken);
+ await interceptor(request);
+
+ assert.strictEqual(signedHeaders(request.headers['authorization']),
+ 'host;x-amz-content-sha256;x-amz-date;x-amz-security-token');
+ assert.strictEqual(request.headers['x-amz-security-token'],
'MOCK_SESSION_TOKEN');
+ });
});
});
diff --git a/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py
b/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py
new file mode 100644
index 0000000000..39eddf7426
--- /dev/null
+++ b/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py
@@ -0,0 +1,82 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+"""SigV4 SignedHeaders invariants.
+
+Only host and the headers the AWS SDK adds itself are signed;
transport-managed headers
+(accept-encoding, content-type, ...) are never signed, or the signature would
not match what
+the server reconstructs. The session token is signed only when session
credentials are used.
+
+Unlike the other GLVs, botocore's plain SigV4Auth does not add an
``x-amz-content-sha256``
+header, so Python's SignedHeaders is ``host;x-amz-date`` (the body hash is
still bound into the
+signature via the canonical request's mandatory payload-hash line). This is
the SDK's natural
+behavior and is intentionally left as-is.
+"""
+from botocore.credentials import Credentials
+
+from gremlin_python.driver.auth import sigv4
+from gremlin_python.driver.http_request import HttpRequest
+
+ACCESS_KEY = "foo"
+SECRET_KEY = "bar"
+
+
+def _signed_headers(request):
+ lower = {k.lower(): v for k, v in request.headers.items()}
+ authorization = lower["authorization"]
+ marker = "SignedHeaders="
+ start = authorization.index(marker) + len(marker)
+ end = authorization.find(",", start)
+ return authorization[start:] if end < 0 else authorization[start:end]
+
+
+def _make_request():
+ # A default-port (443) https URL; seed transport-managed / content headers
that must NOT be
+ # signed.
+ return HttpRequest(
+ method="POST",
+ url="https://example.com:443/gremlin",
+ headers={
+ "accept": "application/vnd.graphbinary-v4.0",
+ "content-type": "application/json",
+ "accept-encoding": "deflate",
+ "user-agent": "gremlin-python-test",
+ },
+ body=b'{"gremlin":"g.V().count()"}',
+ )
+
+
+class TestSigV4SignedHeaders:
+
+ def test_basic_credentials_sign_only_host_and_date(self):
+ creds = Credentials(access_key=ACCESS_KEY, secret_key=SECRET_KEY,
token=None)
+ request = _make_request()
+
+ sigv4("region-1", "example-service", credentials=creds)(request)
+
+ assert _signed_headers(request) == "host;x-amz-date"
+
+ def test_session_credentials_also_sign_the_security_token(self):
+ creds = Credentials(access_key=ACCESS_KEY, secret_key=SECRET_KEY,
token="MOCK_TOKEN")
+ request = _make_request()
+
+ sigv4("region-1", "example-service", credentials=creds)(request)
+
+ assert _signed_headers(request) ==
"host;x-amz-date;x-amz-security-token"
+ lower = {k.lower(): v for k, v in request.headers.items()}
+ assert lower["x-amz-security-token"] == "MOCK_TOKEN"