fix: bedrock canonical URI sig (#3590)

This commit is contained in:
woody
2026-03-16 14:54:19 +08:00
committed by GitHub
parent f1e305844e
commit 94f0d7179f
3 changed files with 105 additions and 35 deletions

View File

@@ -207,7 +207,6 @@ jobs:
- name: Install required tools
run: |
go install github.com/wadey/gocovmerge@latest
sudo apt-get update && sudo apt-get install -y bc
- name: Download all test results

View File

@@ -1372,44 +1372,11 @@ func encodeSigV4Path(path string) string {
if seg == "" {
continue
}
// Normalize to "single-encoded" form:
// - raw ":" -> %3A
// - already encoded "%3A" -> still %3A (not %253A)
decoded, err := url.PathUnescape(seg)
if err == nil {
segments[i] = sigV4EscapePathSegment(decoded)
} else {
// If segment has invalid escape sequence, fall back to escaping raw segment.
segments[i] = sigV4EscapePathSegment(seg)
}
segments[i] = url.PathEscape(seg)
}
return strings.Join(segments, "/")
}
func sigV4EscapePathSegment(segment string) string {
const upperHex = "0123456789ABCDEF"
var b strings.Builder
b.Grow(len(segment) * 3)
for i := 0; i < len(segment); i++ {
c := segment[i]
if isSigV4Unreserved(c) {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(upperHex[c>>4])
b.WriteByte(upperHex[c&0x0F])
}
return b.String()
}
func isSigV4Unreserved(c byte) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~'
}
func getSignatureKey(key, dateStamp, region, service string) []byte {
kDate := hmacSha256([]byte("AWS4"+key), dateStamp)
kRegion := hmacSha256(kDate, region)

View File

@@ -0,0 +1,104 @@
package provider
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestEncodeSigV4Path(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{
name: "raw model id keeps colon",
path: "/model/global.amazon.nova-2-lite-v1:0/converse-stream",
want: "/model/global.amazon.nova-2-lite-v1:0/converse-stream",
},
{
name: "pre-encoded model id escapes percent to avoid mismatch",
path: "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream",
want: "/model/global.amazon.nova-2-lite-v1%253A0/converse-stream",
},
{
name: "raw inference profile arn keeps colon and slash delimiters",
path: "/model/arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.anthropic.claude-sonnet-4-20250514-v1:0/converse",
want: "/model/arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.anthropic.claude-sonnet-4-20250514-v1:0/converse",
},
{
name: "encoded inference profile arn preserves escaped slash as double-escaped percent",
path: "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.anthropic.claude-sonnet-4-20250514-v1%3A0/converse",
want: "/model/arn%253Aaws%253Abedrock%253Aus-east-1%253A123456789012%253Ainference-profile%252Fglobal.anthropic.claude-sonnet-4-20250514-v1%253A0/converse",
},
{
name: "query string is stripped before canonical encoding",
path: "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream?trace=1&foo=bar",
want: "/model/global.amazon.nova-2-lite-v1%253A0/converse-stream",
},
{
name: "invalid percent sequence falls back to escaped percent",
path: "/model/abc%ZZxyz/converse",
want: "/model/abc%25ZZxyz/converse",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, encodeSigV4Path(tt.path))
})
}
}
func TestOverwriteRequestPathHeaderPreservesSingleEncodedRequestPath(t *testing.T) {
p := &bedrockProvider{}
plainModel := "arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.amazon.nova-2-lite-v1:0"
preEncodedModel := url.QueryEscape(plainModel)
t.Run("plain model is encoded once", func(t *testing.T) {
headers := http.Header{}
p.overwriteRequestPathHeader(headers, bedrockChatCompletionPath, plainModel)
assert.Equal(t, "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.amazon.nova-2-lite-v1%3A0/converse", headers.Get(":path"))
})
t.Run("pre-encoded model is not double encoded", func(t *testing.T) {
headers := http.Header{}
p.overwriteRequestPathHeader(headers, bedrockChatCompletionPath, preEncodedModel)
assert.Equal(t, "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.amazon.nova-2-lite-v1%3A0/converse", headers.Get(":path"))
})
}
func TestGenerateSignatureIgnoresQueryStringInCanonicalURI(t *testing.T) {
p := &bedrockProvider{
config: ProviderConfig{
awsRegion: "ap-northeast-3",
awsSecretKey: "test-secret",
},
}
body := []byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)
pathWithoutQuery := "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream"
pathWithQuery := pathWithoutQuery + "?trace=1&foo=bar"
sigWithoutQuery := p.generateSignature(pathWithoutQuery, "20260312T142942Z", "20260312", body)
sigWithQuery := p.generateSignature(pathWithQuery, "20260312T142942Z", "20260312", body)
assert.Equal(t, sigWithoutQuery, sigWithQuery)
}
func TestGenerateSignatureDiffersForRawAndPreEncodedModelPath(t *testing.T) {
p := &bedrockProvider{
config: ProviderConfig{
awsRegion: "ap-northeast-3",
awsSecretKey: "test-secret",
},
}
body := []byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)
rawPath := "/model/global.amazon.nova-2-lite-v1:0/converse-stream"
preEncodedPath := "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream"
rawSignature := p.generateSignature(rawPath, "20260312T142942Z", "20260312", body)
preEncodedSignature := p.generateSignature(preEncodedPath, "20260312T142942Z", "20260312", body)
assert.NotEqual(t, rawSignature, preEncodedSignature)
}