Repository: wojtekmach/req
Pull request #374 (2024-06-22 06:32:14)

CanonicalURI encoding issue in aws_sigv4
When using Req and the `aws_sigv4` option, AWS Bedrock endpoints that contain a `:` in the path (e.g. `https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke`) fail signature verification. Model names without `:` are able to pass signature verification and run, but any with a colon fail signature verification.

```
The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.
```

I tracked it down to a path encoding issue inside `aws_sigv4_headers`. According to the [docs](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html), the path should be mostly URI encoded. With the changes in this PR signature verification works for paths containing a `:`.

Note/warning: I have only tested these changes on this specific use case (AWS Bedrock and `Req.post(json: ...)` which uses the `aws_sigv4_headers` function in req).


Patch:
diff --git a/lib/req.ex b/lib/req.ex
index 66fa51e..d2b1194 100644
--- a/lib/req.ex
+++ b/lib/req.ex
@@ -1180,7 +1180,7 @@ defmodule Req do
       ** (Req.TransportError) connection refused
   """
   @doc type: :request
-  @spec run(request :: url() | keyword() | Req.Request.t(), options :: keyword()) ::
+  @spec run!(request :: url() | keyword() | Req.Request.t(), options :: keyword()) ::
           {Req.Request.t(), Req.Response.t()}
   def run!(request, options \\ []) do
     case run(request, options) do
diff --git a/lib/req/utils.ex b/lib/req/utils.ex
index aa35d4c..1590ae9 100644
--- a/lib/req/utils.ex
+++ b/lib/req/utils.ex
@@ -58,9 +58,11 @@ defmodule Req.Utils do
     canonical_headers =
       Enum.map_intersperse(canonical_headers, "\n", fn {name, value} -> [name, ":", value] end)
 
+    path = URI.encode(url.path || "/", &(&1 == ?/ or URI.char_unreserved?(&1)))
+
     canonical_request = """
     #{method}
-    #{url.path || "/"}
+    #{path}
     #{url.query || ""}
     #{canonical_headers}
 
@@ -137,6 +139,8 @@ defmodule Req.Utils do
         &String.downcase(elem(&1, 0), :ascii)
       )
 
+    path = URI.encode(url.path || "/", &(&1 == ?/ or URI.char_unreserved?(&1)))
+
     true = url.query in [nil, ""]
 
     method = method |> Atom.to_string() |> String.upcase()
@@ -146,7 +150,7 @@ defmodule Req.Utils do
 
     canonical_request = """
     #{method}
-    #{url.path || "/"}
+    #{path}
     #{canonical_query_string}
     #{canonical_headers}
 


Test patch:
diff --git a/test/req/utils_test.exs b/test/req/utils_test.exs
index 1749998..7287bab 100644
--- a/test/req/utils_test.exs
+++ b/test/req/utils_test.exs
@@ -15,7 +15,7 @@ defmodule Req.UtilsTest do
         service: "s3",
         datetime: ~U[2024-01-01 09:00:00Z],
         method: :get,
-        url: "https://s3",
+        url: "https://s3/foo/:bar",
         headers: [{"host", "s3"}],
         body: ""
       ]
@@ -50,20 +50,20 @@ defmodule Req.UtilsTest do
         service: "s3",
         datetime: ~U[2024-01-01 09:00:00Z],
         method: :get,
-        url: "https://s3"
+        url: "https://s3/foo/:bar"
       ]
 
       url1 = to_string(Req.Utils.aws_sigv4_url(options))
 
       url2 =
         """
-        https://s3?\
+        https://s3/foo/:bar?\
         X-Amz-Algorithm=AWS4-HMAC-SHA256\
         &X-Amz-Credential=dummy-access-key-id%2F20240101%2Fdummy-region%2Fs3%2Faws4_request\
         &X-Amz-Date=20240101T090000Z\
         &X-Amz-Expires=86400\
         &X-Amz-SignedHeaders=host\
-        &X-Amz-Signature=684b112675beaf7f858dbf650cc12c5aa3d0eeb15fa4038ea809149f3c6476e3\
+        &X-Amz-Signature=7fd16f0749b0902acde5a3d8933315006f2993b279b995cad880165ff4be75ff\
         """
 
       assert url1 == url2