Spoofy Writeup

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-Fors 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.