From 94f0d7179fad5e6644eba96909bdf03e92fd82ca Mon Sep 17 00:00:00 2001 From: woody Date: Mon, 16 Mar 2026 14:54:19 +0800 Subject: [PATCH] fix: bedrock canonical URI sig (#3590) --- .github/workflows/wasm-plugin-unit-test.yml | 1 - .../extensions/ai-proxy/provider/bedrock.go | 35 +----- .../provider/bedrock_sigv4_path_test.go | 104 ++++++++++++++++++ 3 files changed, 105 insertions(+), 35 deletions(-) create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/bedrock_sigv4_path_test.go diff --git a/.github/workflows/wasm-plugin-unit-test.yml b/.github/workflows/wasm-plugin-unit-test.yml index c7342e378..b760e56a4 100644 --- a/.github/workflows/wasm-plugin-unit-test.yml +++ b/.github/workflows/wasm-plugin-unit-test.yml @@ -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 diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go index 146d0910c..b5c3a07fc 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock.go @@ -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) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_sigv4_path_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_sigv4_path_test.go new file mode 100644 index 000000000..b782ca15b --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/bedrock_sigv4_path_test.go @@ -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) +}