Spoofy was solvable with duplicate headers and I'm sure you can find many writeups with a solution, but I want to explain why it works, since the vuln was easily guessable without appreciation for the origin.
Heroku uses their own erlang HTTP Proxy vegur (https://github.com/heroku/vegur).
In vegur_proxy_middleware.erl
, they append the connecting ip to X-Forwarded-For
using vegur_utils:add_or_append_header
(https://github.com/heroku/vegur/blob/26cf07b6d7f12841e529cd2a9fc354a70927a6be/src/vegur_proxy_middleware.erl#L236)
{Headers2, Req3} = vegur_utils:add_or_append_header(<<"x-forwarded-for">>, inet:ntoa(PeerAddress), Headers1, Req2),
add_or_append_header
is defined as such: (https://github.com/heroku/vegur/blob/26cf07b6d7f12841e529cd2a9fc354a70927a6be/src/vegur_utils.erl#L116-L129)
-spec add_or_append_header(Key, Value, Headers, Req) ->
{Headers, Req} when
Key :: iodata(),
Value :: iodata(),
Headers :: [{iodata(), iodata()}]|[],
Req :: cowboyku_req:req().
add_or_append_header(Key, Val, Headers, Req) ->
case cowboyku_req:header(Key, Req) of
{undefined, Req2} ->
{Headers ++ [{Key, Val}], Req2};
{CurrentVal, Req2} ->
{lists:keyreplace(Key, 1, Headers, {Key, [CurrentVal, ", ", Val]}),
Req2}
end.
Erlang (and elixir) likes to use key-value pair lists instead of maps for whatever reason, and most of the stdlib functions just operate on the first matching key. This is an issue, since key-value pair lists don't enforce that keys can't be duplicates.
cowboyku_req:header
is defined as such: (https://github.com/heroku/cowboyku/blob/master/src/cowboyku_req.erl#L372-L378)
-spec header(binary(), Req, Default)
-> {binary() | Default, Req} when Req::req(), Default::any().
header(Name, Req, Default) ->
case lists:keyfind(Name, 1, Req#http_req.headers) of
{Name, Value} -> {Value, Req};
false -> {Default, Req}
end.
It finds the first header with the name specified. The add_or_append_header
function only finds the first X-Forwarded-For
and only appends the ip to the first X-Forwarded-For
. Any X-Forwarded-For
s afterwards are left untouched. Thus, when you send
X-Forwarded-For: a
X-Forwarded-For: b
Heroku transforms it into
X-Forwarded-For: a, connectingip
X-Forwarded-For: b
As per RFC 2616,
Multiple message-header fields with the same field-name MAY be
present in a message if and only if the entire field-value for that
header field is defined as a comma-separated list [i.e., #(values)].
It MUST be possible to combine the multiple header fields into one
"field-name: field-value" pair, without changing the semantics of the
message, by appending each subsequent field-value to the first, each
separated by a comma.
Thus, Flask and many other HTTP servers will concatenate duplicate header names with a comma, leaving the header parsed as:
a, connectingip,b
So, if
X-Forwarded-For: 1.3.3.7
X-Forwarded-For: x, 1.3.3.7
is sent, Heroku transforms it into
X-Forwarded-For: 1.3.3.7, connectingip
X-Forwarded-For: x, 1.3.3.7
which will be parsed as
1.3.3.7, connectingip,x, 1.3.3.7
which has the same first and last ip with a value of 1.3.3.7
.