Repository: tallarium/reverse_proxy_plug (elixir)

Issue:
Make request header normalization configurable
Hi there!

Request headers are always downcased when sent upstream [here](https://github.com/tallarium/reverse_proxy_plug/blob/master/lib/reverse_proxy_plug.ex#L298).

```elixir
  defp normalize_headers(headers) do
    headers
    |> downcase_headers
    |> remove_hop_by_hop_headers
  end
```

Could the casing be configurable? The upstream we relay requests to through this plug performs case sensitive matching to consume some headers, dropping the ones which have been downcased.

Thanks!

Pull request description:
Optional upstream transformation
fixes #150

since downcasing headers is not something that intermediate hops necessarily need to do.

`String.downcase`'ing again in `remove_hop_by_hop_headers` is strictly unnecessary, but it would be useful to have some separate header utility functions for common operations like this (and optimize it there)

Patch:
diff --git a/README.md b/README.md
index 0ec74cb..a2312e8 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,15 @@ upstream requests with the same `Host` header as in the original request.
 forward("/foo", to: ReverseProxyPlug, upstream: "//example.com", preserve_host_header: true)
 ```
 
+### Normalize headers for upstream request
+
+An upstream request will downcase all request header names by default (`ReverseProxyPlug.downcase_headers/1`)
+
+You can override this behaviour by passing your own `normalize_headers/1`, which can transform
+a list of headers - a list of `{"header", "value"}` tuples - and return them in the form desired.
+For instance, you may want to drop certain headers in the upstream request, beyond the usual hop-by-hop
+headers.
+
 ### Response mode
 
 `ReverseProxyPlug` supports two response modes:
diff --git a/lib/reverse_proxy_plug.ex b/lib/reverse_proxy_plug.ex
index 206c0a0..6860385 100644
--- a/lib/reverse_proxy_plug.ex
+++ b/lib/reverse_proxy_plug.ex
@@ -38,6 +38,7 @@ defmodule ReverseProxyPlug do
     |> Keyword.put_new(:response_mode, :stream)
     |> Keyword.put_new(:stream_headers_mode, :replace)
     |> Keyword.put_new(:buffer_headers_mode, :prepend)
+    |> Keyword.put_new(:normalize_headers, &ReverseProxyPlug.downcase_headers/1)
     |> Keyword.put_new(:status_callbacks, %{})
     |> Keyword.update(:error_callback, nil, fn
       {m, f, a} -> {m, f, a}
@@ -176,7 +177,8 @@ defmodule ReverseProxyPlug do
          opts
        ) do
     headers
-    |> normalize_headers
+    |> opts[:normalize_headers].()
+    |> remove_hop_by_hop_headers
     |> add_resp_headers(conn, opts[:buffer_headers_mode])
     |> Conn.resp(status, body)
   end
@@ -208,7 +210,8 @@ defmodule ReverseProxyPlug do
               else: []
 
           headers
-          |> normalize_headers
+          |> opts[:normalize_headers].()
+          |> remove_hop_by_hop_headers
           |> Enum.reject(fn {header, _} -> header == "content-length" end)
           |> Enum.concat(additional_headers)
           |> add_resp_headers(conn, opts[:stream_headers_mode])
@@ -280,7 +283,8 @@ defmodule ReverseProxyPlug do
 
     headers =
       conn.req_headers
-      |> normalize_headers
+      |> options[:normalize_headers].()
+      |> remove_hop_by_hop_headers
       |> add_x_fwd_for_header(conn)
 
     proxy_req_host =
@@ -313,13 +317,7 @@ defmodule ReverseProxyPlug do
     |> Keyword.put_new(:recv_timeout, :infinity)
   end
 
-  defp normalize_headers(headers) do
-    headers
-    |> downcase_headers
-    |> remove_hop_by_hop_headers
-  end
-
-  defp downcase_headers(headers) do
+  def downcase_headers(headers) do
     headers
     |> Enum.map(fn {header, value} -> {String.downcase(header), value} end)
   end
@@ -336,8 +334,9 @@ defmodule ReverseProxyPlug do
       "upgrade"
     ]
 
+    # We downcase here, in case a custom :normalize_headers function does not downcase headers
     headers
-    |> Enum.reject(fn {header, _} -> Enum.member?(hop_by_hop_headers, header) end)
+    |> Enum.reject(fn {header, _} -> Enum.member?(hop_by_hop_headers, String.downcase(header)) end)
   end
 
   defp add_x_fwd_for_header(headers, conn) do


Test patch:
diff --git a/test/reverse_proxy_plug_test.exs b/test/reverse_proxy_plug_test.exs
index 9bfd1ad..00429ba 100644
--- a/test/reverse_proxy_plug_test.exs
+++ b/test/reverse_proxy_plug_test.exs
@@ -246,6 +246,37 @@ defmodule ReverseProxyPlugTest do
     assert conn.resp_body == "not found"
   end
 
+  test "transforms request headers in a custom manner" do
+    ReverseProxyPlug.HTTPClientMock
+    |> expect(:request, fn %{headers: headers} ->
+      send(self(), {:headers, headers})
+      TestReuse.make_response(%{status_code: 200})
+    end)
+
+    conn(:get, "/")
+    |> put_req_header("x-private-header", "header")
+    |> ReverseProxyPlug.call(
+      ReverseProxyPlug.init(
+        Keyword.merge(@opts,
+          response_mode: :buffer,
+          normalize_headers: fn headers ->
+            headers
+            |> Enum.reject(fn {name, _val} -> name == "x-private-header" end)
+          end
+        )
+      )
+    )
+
+    assert_receive {:headers, transformed_headers}
+
+    transformed_header_names =
+      transformed_headers
+      |> Enum.map(fn {name, _val} -> name end)
+
+    refute "x-private-header" in transformed_header_names,
+           "processed headers with custom function"
+  end
+
   test_stream_and_buffer "adds x-forwarded-for header if not present" do
     %{opts: opts, get_responder: get_responder} = test_reuse_opts
 
diff --git a/test/support/test_reuse.ex b/test/support/test_reuse.ex
index ea86fc1..35e9cc5 100644
--- a/test/support/test_reuse.ex
+++ b/test/support/test_reuse.ex
@@ -52,7 +52,7 @@ defmodule TestReuse do
     end
   end
 
-  defp make_response(%{} = args) do
+  def make_response(%{} = args) do
     %HTTPClient.Response{
       status_code: args[:status_code] || 200,
       headers: args[:headers] || [],