This is an automated email from the ASF dual-hosted git repository.
nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 80b94960c fix(aws-lambda): build SigV4 canonical query string per spec
(#13520)
80b94960c is described below
commit 80b94960c5d35e0d39846bbd938574cd9d21b1e2
Author: Nic <[email protected]>
AuthorDate: Fri Jun 12 10:49:56 2026 +0800
fix(aws-lambda): build SigV4 canonical query string per spec (#13520)
---
apisix/plugins/aws-lambda.lua | 51 ++++++++++++++--
t/plugin/aws-lambda.t | 138 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+), 6 deletions(-)
diff --git a/apisix/plugins/aws-lambda.lua b/apisix/plugins/aws-lambda.lua
index 9897ffbdb..c119fd950 100644
--- a/apisix/plugins/aws-lambda.lua
+++ b/apisix/plugins/aws-lambda.lua
@@ -21,6 +21,10 @@ local resty_sha256 = require("resty.sha256")
local str_strip = require("pl.stringx").strip
local norm_path = require("pl.path").normpath
local pairs = pairs
+local ipairs = ipairs
+local type = type
+local str_format = string.format
+local str_byte = string.byte
local tab_concat = table.concat
local tab_sort = table.sort
local os = os
@@ -43,6 +47,14 @@ local function sha256(msg)
return hex_encode(digest)
end
+-- URI-encode a string per the AWS SigV4 spec: percent-encode every character
+-- except the RFC3986 unreserved characters A-Z, a-z, 0-9, "-", "_", "." and
"~"
+local function uri_encode(s)
+ return (s:gsub("[^A-Za-z0-9%-_.~]", function(c)
+ return str_format("%%%02X", str_byte(c))
+ end))
+end
+
local function get_signature_key(key, datestamp, region, service)
local kDate = hmac256("AWS4" .. key, datestamp)
local kRegion = hmac256(kDate, region)
@@ -120,17 +132,44 @@ local function request_processor(conf, ctx, params)
end
end
- -- computing canonical query string
- local canonical_qs = {}
- local canonical_qs_i = 0
+ -- computing canonical query string: URI-encode the name and value of
+ -- every pair, then sort the pairs by encoded name and encoded value.
+ -- params.query holds the percent-decoded args: a table value means the
+ -- arg appears multiple times and a true value means an arg without value
+ local query_pairs = {}
+ local query_pairs_i = 0
for k, v in pairs(params.query) do
- canonical_qs_i = canonical_qs_i + 1
- canonical_qs[canonical_qs_i] = ngx.unescape_uri(k) .. "=" ..
ngx.unescape_uri(v)
+ local name = uri_encode(k)
+ if type(v) ~= "table" then
+ v = {v}
+ end
+ for _, value in ipairs(v) do
+ if value == true then
+ value = ""
+ end
+ query_pairs_i = query_pairs_i + 1
+ query_pairs[query_pairs_i] = {name, uri_encode(value)}
+ end
end
- tab_sort(canonical_qs)
+ tab_sort(query_pairs, function(a, b)
+ if a[1] ~= b[1] then
+ return a[1] < b[1]
+ end
+ return a[2] < b[2]
+ end)
+
+ local canonical_qs = {}
+ for i = 1, query_pairs_i do
+ canonical_qs[i] = query_pairs[i][1] .. "=" .. query_pairs[i][2]
+ end
canonical_qs = tab_concat(canonical_qs, "&")
+ -- send exactly the query string that gets signed: lua-resty-http passes
+ -- a string through unmodified, while a table would be re-encoded by
+ -- ngx.encode_args whose output may differ from the signed string
+ params.query = canonical_qs
+
-- computing canonical and signed headers
local canonical_headers, signed_headers = {}, {}
diff --git a/t/plugin/aws-lambda.t b/t/plugin/aws-lambda.t
index 9c072ac12..4a3ea8a6d 100644
--- a/t/plugin/aws-lambda.t
+++ b/t/plugin/aws-lambda.t
@@ -372,3 +372,141 @@ test-api-key
secretkey encrypted: ok
accesskey encrypted: ok
apikey encrypted: ok
+
+
+
+=== TEST 9: IAM v4 signing with encoded, multi-value and valueless query params
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "aws-lambda": {
+ "function_uri":
"http://localhost:8765/generic",
+ "authorization": {
+ "iam": {
+ "accesskey": "KEY1",
+ "secretkey": "KeySecret"
+ }
+ }
+ }
+ },
+ "uri": "/aws"
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ ngx.say("fail")
+ return
+ end
+
+ ngx.say(body)
+
+ -- unsorted query string with a percent-encoded key and value,
+ -- a value that needs encoding, repeated args and a valueless arg
+ local code, _, body = t(
+ "/aws?with%20space=a%2Fb%20c&multi=m2&multi=m1&flag&a=*&a-=x",
+ "GET")
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- inside_lua_block
+-- emulate the AWS server side SigV4 validation: rebuild the canonical
+-- request from the request actually received and recompute the signature
+local hmac = require("resty.hmac")
+local resty_sha256 = require("resty.sha256")
+local hex_encode = require("resty.string").to_hex
+
+local function hmac256(key, msg)
+ return hmac:new(key, hmac.ALGOS.SHA256):final(msg)
+end
+
+local function sha256(msg)
+ local hash = resty_sha256:new()
+ hash:update(msg)
+ return hex_encode(hash:final())
+end
+
+local function uri_encode(s)
+ return (s:gsub("[^A-Za-z0-9%-_.~]", function(c)
+ return string.format("%%%02X", string.byte(c))
+ end))
+end
+
+ngx.say("query: ", ngx.var.args)
+
+local headers = ngx.req.get_headers()
+local credential, signed_headers, signature = headers["authorization"]:match(
+ "^AWS4%-HMAC%-SHA256 Credential=([^,]+), SignedHeaders=([^,]+),
Signature=(%x+)$")
+local datestamp, region, service = credential:match(
+ "/(%d+)/([^/]+)/([^/]+)/aws4_request$")
+
+-- canonical query string: decode every pair received on the wire,
+-- then URI-encode and sort the pairs again
+local query_pairs = {}
+for pair in (ngx.var.args or ""):gmatch("[^&]+") do
+ local eq = pair:find("=", 1, true)
+ local k, v
+ if eq then
+ k, v = pair:sub(1, eq - 1), pair:sub(eq + 1)
+ else
+ k, v = pair, ""
+ end
+ table.insert(query_pairs,
+ {uri_encode(ngx.unescape_uri(k)),
uri_encode(ngx.unescape_uri(v))})
+end
+table.sort(query_pairs, function(a, b)
+ if a[1] ~= b[1] then
+ return a[1] < b[1]
+ end
+ return a[2] < b[2]
+end)
+local canonical_qs = {}
+for i, p in ipairs(query_pairs) do
+ canonical_qs[i] = p[1] .. "=" .. p[2]
+end
+
+local canonical_headers = {}
+local i = 0
+for name in signed_headers:gmatch("[^;]+") do
+ i = i + 1
+ local value = headers[name]:gsub("^%s+", ""):gsub("%s+$", "")
+ canonical_headers[i] = name .. ":" .. value .. "\n"
+end
+
+ngx.req.read_body()
+local canonical_request = ngx.req.get_method() .. "\n"
+ .. ngx.var.request_uri:match("^([^?]*)") .. "\n"
+ .. table.concat(canonical_qs, "&") .. "\n"
+ .. table.concat(canonical_headers) .. "\n"
+ .. signed_headers .. "\n"
+ .. sha256(ngx.req.get_body_data() or "")
+
+local string_to_sign = "AWS4-HMAC-SHA256\n"
+ .. headers["x-amz-date"] .. "\n"
+ .. datestamp .. "/" .. region .. "/" .. service .. "/aws4_request\n"
+ .. sha256(canonical_request)
+
+local sign_key = hmac256("AWS4" .. "KeySecret", datestamp)
+sign_key = hmac256(sign_key, region)
+sign_key = hmac256(sign_key, service)
+sign_key = hmac256(sign_key, "aws4_request")
+local expected = hex_encode(hmac256(sign_key, string_to_sign))
+
+if expected == signature then
+ ngx.say("signature: ok")
+else
+ ngx.say("signature mismatch: got ", signature, ", want ", expected)
+end
+
+--- response_body
+passed
+query: a=%2A&a-=x&flag=&multi=m1&multi=m2&with%20space=a%2Fb%20c
+signature: ok