Skip to content

Commit ead679d

Browse files
committed
Reject CR/LF/NUL in the CONNECT proxy target host
The CONNECT handshake concatenates the target host into the request line and Host header with no check for control bytes. normalize/2 percent- decodes the host, so a host carrying %0d%0a arrives as raw CRLF and splits the request sent to the proxy. The WebSocket and WebTransport authority builders already guard against this; the proxy handshake did not. Reject CR/LF/NUL in the target host before building any payload.
1 parent 3225e13 commit ead679d

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

‎src/hackney_http_connect.erl‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,23 @@ sockname({Transport, Socket}) ->
195195

196196
%% private functions
197197
do_handshake(Socket, ProxyTransport, Host, Port, Options) ->
198+
%% The target host is concatenated straight into the CONNECT request line
199+
%% and the Host header. A percent-decoded host can carry raw CR/LF/NUL, which
200+
%% would split the request sent to the proxy. Reject it before building any
201+
%% payload, mirroring the WebSocket/WebTransport authority guards.
202+
case host_has_ctl_bytes(Host) of
203+
true ->
204+
{error, invalid_connect_host};
205+
false ->
206+
do_handshake_1(Socket, ProxyTransport, Host, Port, Options)
207+
end.
208+
209+
host_has_ctl_bytes(Host) when is_list(Host) ->
210+
lists:any(fun(C) -> C =:= $\r orelse C =:= $\n orelse C =:= 0 end, Host);
211+
host_has_ctl_bytes(Host) when is_binary(Host) ->
212+
binary:match(Host, [<<"\r">>, <<"\n">>, <<0>>]) =/= nomatch.
213+
214+
do_handshake_1(Socket, ProxyTransport, Host, Port, Options) ->
198215
ProxyUser = proplists:get_value(connect_user, Options),
199216
ProxyPass = proplists:get_value(connect_pass, Options, <<>>),
200217
ProxyPort = proplists:get_value(connect_port, Options),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
%%% CRLF injection guard for the CONNECT proxy handshake.
2+
%%%
3+
%%% The target host is concatenated into the CONNECT request line and Host
4+
%%% header, so a host carrying CR/LF/NUL (e.g. from a percent-decoded URL) must
5+
%%% be rejected before any payload is built and sent to the proxy.
6+
-module(hackney_http_connect_tests).
7+
8+
-include_lib("eunit/include/eunit.hrl").
9+
10+
crlf_host_rejected_test() ->
11+
assert_rejected("victim.example\r\nX-Injected: yes").
12+
13+
lf_host_rejected_test() ->
14+
assert_rejected("victim.example\nX-Injected: yes").
15+
16+
nul_host_rejected_test() ->
17+
assert_rejected("victim.example\0").
18+
19+
%% Point the handshake at a local listener acting as the proxy: the guard must
20+
%% return {error, invalid_connect_host} and no CONNECT bytes may reach it.
21+
assert_rejected(Host) ->
22+
{ok, LSock} = gen_tcp:listen(0, [binary, {active, false},
23+
{reuseaddr, true}, {ip, {127, 0, 0, 1}}]),
24+
{ok, LPort} = inet:port(LSock),
25+
Opts = [{connect_host, Host},
26+
{connect_port, 443},
27+
{connect_transport, hackney_tcp},
28+
{proxy_transport, tcp}],
29+
Result = hackney_http_connect:connect("127.0.0.1", LPort, Opts, 1000),
30+
?assertEqual({error, invalid_connect_host}, Result),
31+
Received = case gen_tcp:accept(LSock, 300) of
32+
{ok, S} ->
33+
R = gen_tcp:recv(S, 0, 100),
34+
gen_tcp:close(S),
35+
R;
36+
{error, timeout} ->
37+
no_connection
38+
end,
39+
gen_tcp:close(LSock),
40+
%% Whatever happened, the proxy must not have seen request bytes.
41+
?assert(Received =:= no_connection orelse Received =:= {error, closed}).

0 commit comments

Comments
 (0)