Repository: wojtekmach/req
Pull request #405 (2024-08-10 07:04:52)

fix: include custom ports in aws_sigv4_* functions
Presigned URLs should remain valid across custom endpoint ports (e.g., 9000 instead of the standard 80 or 443) by including port numbers in the host header.

For related discussions in other repositories, see:

- https://github.com/awslabs/aws-sdk-kotlin/issues/1177
- https://github.com/minio/minio/issues/11743
- https://github.com/aws/aws-sdk-js-v3/pull/3897


Patch:
diff --git a/lib/req/utils.ex b/lib/req/utils.ex
index 9c66331..6481bc5 100644
--- a/lib/req/utils.ex
+++ b/lib/req/utils.ex
@@ -42,6 +42,8 @@ defmodule Req.Utils do
 
     method = method |> Atom.to_string() |> String.upcase()
 
+    headers = canonical_host_header(headers, url)
+
     aws_headers = [
       {"x-amz-content-sha256", body_digest},
       {"x-amz-date", datetime_string}
@@ -154,7 +156,7 @@ defmodule Req.Utils do
         {"X-Amz-SignedHeaders", "host"}
       ])
 
-    canonical_headers = [{"host", URI.parse(url).host}]
+    canonical_headers = canonical_host_header([], url)
 
     signed_headers =
       Enum.map_intersperse(
@@ -202,6 +204,19 @@ defmodule Req.Utils do
     put_in(url.query, canonical_query_string <> "&X-Amz-Signature=#{signature}")
   end
 
+  defp canonical_host_header(headers, %URI{} = url) do
+    {_host_headers, headers} = Enum.split_with(headers, &match?({"host", _value}, &1))
+
+    host_value =
+      if is_nil(url.port) or URI.default_port(url.scheme) == url.port do
+        url.host
+      else
+        "#{url.host}:#{url.port}"
+      end
+
+    [{"host", host_value} | headers]
+  end
+
   def aws_sigv4(
         string_to_sign,
         date_string,


Test patch:
diff --git a/test/req/utils_test.exs b/test/req/utils_test.exs
index 0367909..e0e3a67 100644
--- a/test/req/utils_test.exs
+++ b/test/req/utils_test.exs
@@ -39,6 +39,49 @@ defmodule Req.UtilsTest do
       assert signature1 ==
                Enum.map(signature2, fn {name, value} -> {String.downcase(name), value} end)
     end
+
+    test "custom port" do
+      options = [
+        access_key_id: "dummy-access-key-id",
+        secret_access_key: "dummy-secret-access-key",
+        region: "dummy-region",
+        service: "s3",
+        datetime: ~U[2024-01-01 09:00:00Z],
+        method: :get,
+        url: "https://s3-compatible.com:4433/foo/:bar",
+        headers: [],
+        body: ""
+      ]
+
+      signature1 = Req.Utils.aws_sigv4_headers(options)
+
+      signature2 =
+        Req.Utils.aws_sigv4_headers(
+          Keyword.put(options, :headers, [{"host", "s3-compatible.com"}])
+        )
+
+      signature3 =
+        :aws_signature.sign_v4(
+          Keyword.fetch!(options, :access_key_id),
+          Keyword.fetch!(options, :secret_access_key),
+          Keyword.fetch!(options, :region),
+          Keyword.fetch!(options, :service),
+          Keyword.fetch!(options, :datetime) |> NaiveDateTime.to_erl(),
+          Keyword.fetch!(options, :method) |> Atom.to_string() |> String.upcase(),
+          Keyword.fetch!(options, :url),
+          [{"host", "s3-compatible.com:4433"}],
+          Keyword.fetch!(options, :body),
+          Keyword.take(options, [:body_digest])
+        )
+
+      assert signature1 === signature2
+
+      assert signature1 ==
+               Enum.map(signature3, fn {name, value} -> {String.downcase(name), value} end)
+
+      assert signature2 ==
+               Enum.map(signature3, fn {name, value} -> {String.downcase(name), value} end)
+    end
   end
 
   describe "aws_sigv4_url" do
@@ -68,6 +111,33 @@ defmodule Req.UtilsTest do
 
       assert url1 == url2
     end
+
+    test "custom port" do
+      options = [
+        access_key_id: "dummy-access-key-id",
+        secret_access_key: "dummy-secret-access-key",
+        region: "dummy-region",
+        service: "s3",
+        datetime: ~U[2024-01-01 09:00:00Z],
+        method: :get,
+        url: "https://s3-compatible.com:4433/foo/:bar"
+      ]
+
+      url1 = to_string(Req.Utils.aws_sigv4_url(options))
+
+      url2 =
+        """
+        https://s3-compatible.com:4433/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=860c79d524ea488a96b56d9e687348f108262738a5205f907cc0794f73d23403\
+        """
+
+      assert url1 == url2
+    end
   end
 
   describe "encode_form_multipart" do