Skip to content

Commit

Permalink
Add RemoteIp.from/2
Browse files Browse the repository at this point in the history
This achieves the essential ask of #9, but goes about it a bit
differently in deference to various factors:

* I don't like RemoteIp.last_forwarded_ip/2 as a public name. It doesn't
  really read well. I opted for RemoteIp.from/2 - you're fetching the
  remote IP _from_ the given headers.

* More thorough documentation, including doctests.

* Given the addition of the :clients option, the code wouldn't have
  merged cleanly anyway.

* The pull request didn't handle option parsing correctly. The keyword
  extraction had been copied from RemoteIp.init/1 into
  RemoteIp.last_forwarded_ip/2, which was still being called by
  RemoteIp.call/2. This is unnecessary code duplication at best, since
  we could just call init/1 to get the extracted options from our public
  "gimme the IP" function.

  But the PR implementation was more subtly wrong than that: it wasn't
  parsing the :proxies option with InetCidr, but it was still appending
  the *raw* reserved addresses. So not only was it doubling up the
  length of the :proxies when RemoteIp was used as a plug (since init/1
  already appends reserved addresses), it was mixing parsed IP ranges
  with strings. If it weren't for the fact that InetCidr.contains?/2
  happens to have a catch-all false clause [1], the tests would have
  failed outright.

  So what if we just appended & parsed those IP ranges in the shared
  last_forwarded_ip/2 function and not in init/1? Also the wrong
  approach, because the pull request still had RemoteIp.call/2 calling
  that function at runtime. By default, init/1 is invoked at compile
  time [2] to pass the processed options into call/2. By pushing option
  parsing (including constructing a MapSet and invoking
  InetCidr.parse/1, which aren't necessarily trivial) out of init/1 and
  into last_forwarded_ip/2, we'd be forcing call/2 to do redundant
  runtime computation on every request through the pipeline.

  All of those issues are settled quite handily by instead adding a
  completely new function, RemoteIp.from/2. It parses the options with
  RemoteIp.init/1 (there's no real way of getting around doing this at
  runtime for such a function), then passes those along to the *private*
  RemoteIp.last_forwarded_ip/2, which now takes in request headers
  instead of a %Plug.Conn{} (which affects all the downstream functions,
  of course). But when RemoteIp is used as a plug, we don't have to
  recompute the options at runtime because (a) call/2 doesn't touch
  from/2 and (b) the call to init/1 is still where all the work is being
  done, and it'll be compiled into the pipeline as per usual.

* Since test/remote_ip_test.exs was overhauled by prior work for the
  :clients option, it was easier to hook into a common helper function
  to make assertions about *every* call we make to RemoteIp.from/2,
  guaranteeing it outputs the same thing as RemoteIp.call/2. The output
  is only the same in these tests because we don't give the %Plug.Conn{}
  struct a default :remote_ip field, so it's just nil. In real life,
  this would be populated by the peer address, which would be a
  different return value from RemoteIp.from/2 (since it doesn't have
  access to the Conn). But in this test suite, it was an easier change
  for the sake of more thorough coverage with less test duplication
  (which is already honestly a bit rampant).

This closes #9.

[1] https://github.com/Cobenian/inet_cidr/blob/ffce709/lib/inet_cidr.ex#L78)
[2] https://hexdocs.pm/plug/Plug.Builder.html#module-options
  • Loading branch information
Alex Vondrak committed Oct 21, 2019
1 parent 06ca198 commit e42a8cc
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 11 deletions.
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ end

Keep in mind the order of plugs in your pipeline and place `RemoteIp` as early as possible. For example, if you were to add `RemoteIp` *after* [the Plug Router](https://github.com/elixir-lang/plug#the-plug-router), your route action's logic would be executed *before* the `remote_ip` actually gets modified - not very useful!

There are 3 options that can be passed in:
You can also use `RemoteIp.from/2` outside of a plug pipeline to extract the remote IP from a list of headers. This is useful if you don't have access to a full `Plug.Conn` struct, such as when [you're only receiving `x_headers` using Phoenix sockets](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-shared-configuration):

```elixir
x_headers = [{"x-forwarded-for", "1.2.3.4"}]
RemoteIp.from(x_headers)
```

## Configuration

There are 3 options that can be passed in to `RemoteIp.init/1` or `RemoteIp.from/2`:

* `:headers` - A list of strings naming the `req_headers` to use when deriving the `remote_ip`. Order does not matter. Defaults to `~w[forwarded x-forwarded-for x-client-ip x-real-ip]`.

Expand Down Expand Up @@ -69,6 +78,17 @@ defmodule MyApp do
end
```

or

```elixir
RemoteIp.from(
x_headers,
headers: ~w[x-foo x-bar x-baz],
proxies: ~w[1.2.0.0/16],
clients: ~w[1.2.3.4/32]
)
```

Note that, due to limitations in the [inet_cidr](https://github.com/Cobenian/inet_cidr) library used to parse them, `:proxies` and `:clients` **must** be written in full CIDR notation, even if specifying just a single IP. So instead of `"127.0.0.1"` and `"a:b::c:d"`, you would use `"127.0.0.1/32"` and `"a:b::c:d/128"`.

## Background
Expand Down Expand Up @@ -126,7 +146,7 @@ There are 2 main tasks this plug has to do:

### Parsing Headers

When `RemoteIp` parses the `Conn`'s `req_headers`, it first selects only the headers specified in the [`:headers` option](#usage). Their relative ordering is maintained, because order matters when there are multiple hops between proxies. Consider this request:
When `RemoteIp` parses the `Conn`'s `req_headers`, it first selects only the headers specified in the [`:headers` option](#configuration). Their relative ordering is maintained, because order matters when there are multiple hops between proxies. Consider this request:

* Client at IP 1.2.3.4 sends an HTTP request to Proxy 1 (no forwarding headers)
* Proxy 1 at IP 1.1.1.1 adds a `Forwarded: for=1.2.3.4` header and forwards to Proxy 2
Expand Down Expand Up @@ -160,7 +180,7 @@ With the list of IPs parsed, `RemoteIp` must then calculate the proper `remote_i
To [prevent IP spoofing](http://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/), IPs are processed right-to-left. You can think of it as working backwards through the chain of hops:

1. The `2.2.2.2 -> 3.3.3.3` hop set `X-Forwarded-For: 1.1.1.1`. Do we trust this header? **Yes**, because `RemoteIp` assumes that there is _at least_ one proxy sitting between your app & the client that sets a forwarding header, meaning that 2.2.2.2 is tacitly a "known" proxy.
2. The `1.1.1.1 -> 2.2.2.2` hop set `Forwarded: for=1.2.3.4`. Do we trust this header? **It depends**, because we would need to configure `RemoteIp` with the [`:proxies` option](#usage) to know that 1.1.1.1 is a proxy. If we didn't, we wouldn't trust the header, and thus should stop here and say the original client was at 1.1.1.1. Otherwise, we should keep working backwards through the hops. Assuming we do...
2. The `1.1.1.1 -> 2.2.2.2` hop set `Forwarded: for=1.2.3.4`. Do we trust this header? **It depends**, because we would need to configure `RemoteIp` with the [`:proxies` option](#configuration) to know that 1.1.1.1 is a proxy. If we didn't, we wouldn't trust the header, and thus should stop here and say the original client was at 1.1.1.1. Otherwise, we should keep working backwards through the hops. Assuming we do...
3. The `1.2.3.4 -> 1.1.1.1` hop set no headers, so we've arrived at the original client address, 1.2.3.4.

Now suppose a client was trying to spoof the IP by setting their own `X-Forwarded-For` header:
Expand All @@ -183,7 +203,7 @@ Not only are known proxies' headers trusted, but also requests forwarded for [lo

These IPs are filtered because they are used internally and are thus generally not the actual client address in production.

However, if (say) your app is only deployed in a [VPN](https://en.wikipedia.org/wiki/Virtual_private_network)/[LAN](https://en.wikipedia.org/wiki/Local_area_network), then your clients might actually have these internal IPs. To prevent loopback/private addresses from being considered proxies, configure them as known clients using the [`:clients` option](#usage).
However, if (say) your app is only deployed in a [VPN](https://en.wikipedia.org/wiki/Virtual_private_network)/[LAN](https://en.wikipedia.org/wiki/Local_area_network), then your clients might actually have these internal IPs. To prevent loopback/private addresses from being considered proxies, configure them as known clients using the [`:clients` option](#configuration).

### :warning: Caveats :warning:

Expand Down
54 changes: 49 additions & 5 deletions lib/remote_ip.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,64 @@ defmodule RemoteIp do
end

def call(conn, {headers, proxies, clients}) do
case last_forwarded_ip(conn, headers, proxies, clients) do
case last_forwarded_ip(conn.req_headers, headers, proxies, clients) do
nil -> conn
ip -> %{conn | remote_ip: ip}
end
end

defp last_forwarded_ip(conn, headers, proxies, clients) do
conn
@doc """
Standalone function to extract the remote IP from a list of headers.
It's possible to get a subset of headers without access to a full `Plug.Conn`
struct. For instance, when [using Phoenix
sockets](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html), your socket's
`connect/3` callback may only be receiving `:x_headers` in the
`connect_info`. Such situations make it inconvenient to use `RemoteIp`
outside of a plug pipeline.
Therefore, this function will fetch the remote IP from a plain list of header
key-value pairs (just as you'd have in the `req_headers` of a `Plug.Conn`).
You may optionally specify the same options as if you were using `RemoteIp`
as a plug: they'll be processed by `RemoteIp.init/1` each time you call this
function.
If a remote IP cannot be parsed from the given headers (e.g., if the list is
empty), this function will return `nil`.
## Examples
iex> RemoteIp.from([{"x-forwarded-for", "1.2.3.4"}])
{1, 2, 3, 4}
iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
...> |> RemoteIp.from(headers: ~w[x-foo])
{1, 2, 3, 4}
iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
...> |> RemoteIp.from(headers: ~w[x-bar])
{2, 3, 4, 5}
iex> [{"x-foo", "1.2.3.4"}, {"x-bar", "2.3.4.5"}]
...> |> RemoteIp.from(headers: ~w[x-baz])
nil
"""

@spec from([{String.t, String.t}], keyword) :: :inet.ip_address | nil

def from(req_headers, opts \\ []) do
{headers, proxies, clients} = init(opts)
last_forwarded_ip(req_headers, headers, proxies, clients)
end

defp last_forwarded_ip(req_headers, headers, proxies, clients) do
req_headers
|> ips_from(headers)
|> last_ip_forwarded_through(proxies, clients)
end

defp ips_from(%Plug.Conn{req_headers: headers}, allowed) do
RemoteIp.Headers.parse(headers, allowed)
defp ips_from(req_headers, headers) do
RemoteIp.Headers.parse(req_headers, headers)
end

defp last_ip_forwarded_through(ips, proxies, clients) do
Expand Down
8 changes: 6 additions & 2 deletions test/remote_ip_test.exs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
defmodule RemoteIpTest do
use ExUnit.Case, async: true
use Plug.Test
doctest RemoteIp

def remote_ip(conn, opts \\ []) do
RemoteIp.call(conn, RemoteIp.init(opts)).remote_ip
def remote_ip(%Plug.Conn{req_headers: head} = conn, opts \\ []) do
call = RemoteIp.call(conn, RemoteIp.init(opts)).remote_ip
from = RemoteIp.from(head, opts)
assert call == from
call
end

test "no forwarding headers" do
Expand Down

0 comments on commit e42a8cc

Please sign in to comment.