Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.2] - 2026-06-12

### Changed

- Listen sockets set `{send_timeout_close, true}` so a send that hits
`send_timeout` closes the socket instead of leaving it half-dead

### Fixed

- Reject an incomplete HTTP/1.1 request head as `header_too_large` once
the buffered input exceeds `max_header_size` plus an 8 KiB
request-line allowance, bounding both memory and the repeated rescan
of the unparsed tail
- Apply the same `max_header_size` budget to incomplete chunked trailer
sections
- Reject chunk-size lines longer than 1 KiB as `invalid_chunk_size`

## [1.0.1] - 2026-06-09

### Fixed
Expand Down
111 changes: 77 additions & 34 deletions src/nhttp_h1.erl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ When a limit is exceeded, parsing returns `{error, header_too_large}`,
`{error, too_many_headers}`, or `{error, {body_too_large, Size, Max}}`
respectively.

With `max_header_size` set, an incomplete request head is also rejected
as `header_too_large` once the buffered input exceeds `max_header_size`
plus an 8 KiB request-line allowance: a head that never terminates (for
example a header line with no CRLF) cannot grow the caller's buffer
without bound. The same budget bounds chunked trailer sections, and
chunk-size lines longer than 1 KiB are rejected as
`invalid_chunk_size`.

```erlang
Opts = #{max_header_size => 8192, max_headers_count => 100, max_body_size => 1048576},
case nhttp_h1:parse_request(Binary, Opts) of
Expand Down Expand Up @@ -194,6 +202,12 @@ pattern applies to chunked requests.

-opaque chunked_st() :: #chunked_st{}.

%%%-----------------------------------------------------------------------------
%% LOCAL MACROS
%%%-----------------------------------------------------------------------------
-define(REQUEST_LINE_ALLOWANCE, 8192).
-define(MAX_CHUNK_SIZE_LINE, 1024).

%%%-----------------------------------------------------------------------------
%% COMPILED PATTERNS
%%%-----------------------------------------------------------------------------
Expand Down Expand Up @@ -303,13 +317,16 @@ parse_request(<<Bin/binary>>, Opts) ->
OriginalSize = byte_size(Bin),
MaxHeaderSize = maps:get(max_header_size, Opts, infinity),
MaxHeadersCount = maps:get(max_headers_count, Opts, infinity),
maybe
{ok, Method, Path, Version, Rest} ?= parse_request_line(Bin),
{ok, Headers, BodyRest} ?= parse_headers_acc(Rest, [], 0, MaxHeaderSize, MaxHeadersCount),
HeadersConsumed = OriginalSize - byte_size(BodyRest),
Req = build_request(Method, Path, Version, Headers, Opts),
finish_request(Req, BodyRest, Headers, HeadersConsumed, Opts)
end.
Result =
maybe
{ok, Method, Path, Version, Rest} ?= parse_request_line(Bin),
{ok, Headers, BodyRest} ?=
parse_headers_acc(Rest, [], 0, MaxHeaderSize, MaxHeadersCount),
HeadersConsumed = OriginalSize - byte_size(BodyRest),
Req = build_request(Method, Path, Version, Headers, Opts),
finish_request(Req, BodyRest, Headers, HeadersConsumed, Opts)
end,
cap_incomplete_head(Result, OriginalSize, MaxHeaderSize).

-doc """
Feed body bytes for a streaming request whose headers were parsed via
Expand Down Expand Up @@ -390,32 +407,34 @@ parse_request_headers(<<Bin/binary>>, Opts) ->
MaxHeaderSize = maps:get(max_header_size, Opts, infinity),
MaxHeadersCount = maps:get(max_headers_count, Opts, infinity),
MaxBodySize = maps:get(max_body_size, Opts, infinity),
maybe
{ok, Method, Path, Version, Rest} ?= parse_request_line(Bin),
{ok, Headers, BodyRest} ?=
parse_headers_acc(Rest, [], 0, MaxHeaderSize, MaxHeadersCount),
HeadersConsumed = OriginalSize - byte_size(BodyRest),
Req0 = build_request(Method, Path, Version, Headers, Opts),
Req = Req0#{body => streaming},
case detect_body_mode(Headers) of
undefined ->
{ok, Req, none, HeadersConsumed};
{content_length, Len} ->
case check_body_size(Len, MaxBodySize) of
ok -> {ok, Req, {length, Len}, HeadersConsumed};
{error, _} = Err -> Err
end;
chunked ->
St = #chunked_st{
max_header_size = MaxHeaderSize,
max_headers_count = MaxHeadersCount,
max_body_size = MaxBodySize
},
{ok, Req, {chunked, St}, HeadersConsumed};
{error, _} = Err ->
Err
end
end.
Result =
maybe
{ok, Method, Path, Version, Rest} ?= parse_request_line(Bin),
{ok, Headers, BodyRest} ?=
parse_headers_acc(Rest, [], 0, MaxHeaderSize, MaxHeadersCount),
HeadersConsumed = OriginalSize - byte_size(BodyRest),
Req0 = build_request(Method, Path, Version, Headers, Opts),
Req = Req0#{body => streaming},
case detect_body_mode(Headers) of
undefined ->
{ok, Req, none, HeadersConsumed};
{content_length, Len} ->
case check_body_size(Len, MaxBodySize) of
ok -> {ok, Req, {length, Len}, HeadersConsumed};
{error, _} = Err -> Err
end;
chunked ->
St = #chunked_st{
max_header_size = MaxHeaderSize,
max_headers_count = MaxHeadersCount,
max_body_size = MaxBodySize
},
{ok, Req, {chunked, St}, HeadersConsumed};
{error, _} = Err ->
Err
end
end,
cap_incomplete_head(Result, OriginalSize, MaxHeaderSize).

-doc "Parse an HTTP/1.1 response from binary. Returns {ok, Response, BytesConsumed} on success. Use split_at/2 to get the remaining buffer.".
-spec parse_response(binary()) -> parse_result(resp()).
Expand Down Expand Up @@ -669,6 +688,24 @@ build_request(Method, Path, Version, Headers, Opts) ->
}
end.

-doc """
Reject an incomplete request head whose buffered input already exceeds the
header budget. Without this, a head that never terminates (e.g. a header
line with no CRLF) bypasses the per-line limit checks and grows the
caller's buffer without bound, while each new arrival rescans the whole
tail (O(n^2)).
""".
-spec cap_incomplete_head(Result, non_neg_integer(), header_limit()) ->
Result | {error, header_too_large}
when
Result :: term().
cap_incomplete_head({more, _}, BufferedSize, MaxHeaderSize) when
is_integer(MaxHeaderSize), BufferedSize > MaxHeaderSize + ?REQUEST_LINE_ALLOWANCE
->
{error, header_too_large};
cap_incomplete_head(Result, _BufferedSize, _MaxHeaderSize) ->
Result.

-spec check_body_size(non_neg_integer(), header_limit()) ->
ok | {error, {body_too_large, non_neg_integer(), non_neg_integer()}}.
check_body_size(Size, MaxSize) when is_integer(MaxSize), Size > MaxSize ->
Expand Down Expand Up @@ -1117,6 +1154,10 @@ parse_chunked_stream(Bin, Consumed, Acc, #chunked_st{phase = trailers} = St) ->
NewConsumed = Consumed + Used,
FinalAcc = lists:reverse([{fin, Trailers} | Acc]),
{ok, FinalAcc, none, NewConsumed};
{more, _MinBytes} when
is_integer(MaxSize), HSize + byte_size(Rest) > MaxSize
->
{error, header_too_large};
{more, MinBytes} ->
finish_chunked_step(Acc, St, Consumed, MinBytes);
{error, _} = Err ->
Expand Down Expand Up @@ -1956,8 +1997,10 @@ scan_chunk_size_line(Bin, Skip, SizeLen) ->
{ok, Size} -> {ok, Size, Skip + SizeLen + 2};
error -> {error, invalid_chunk_size}
end;
<<_:Pos/binary, _, _/binary>> ->
<<_:Pos/binary, _, _/binary>> when SizeLen =< ?MAX_CHUNK_SIZE_LINE ->
scan_chunk_size_line(Bin, Skip, SizeLen + 1);
<<_:Pos/binary, _, _/binary>> ->
{error, invalid_chunk_size};
_ ->
{more, 1}
end.
Expand Down
2 changes: 1 addition & 1 deletion src/nhttp_lib.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, nhttp_lib, [
{description, "HTTP protocol primitives for Erlang/OTP 27+ (HTTP/1.1, HTTP/2, HTTP/3, QPACK)"},
{vsn, "1.0.1"},
{vsn, "1.0.2"},
{registered, []},
{applications, [
kernel,
Expand Down
1 change: 1 addition & 0 deletions src/nhttp_sock.erl
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ build_tcp_opts(Opts) ->
{backlog, Backlog},
{nodelay, Nodelay},
{send_timeout, SendTimeout},
{send_timeout_close, true},
{buffer, Buffer},
{packet, raw}
].
Expand Down
48 changes: 47 additions & 1 deletion test/nhttp_h1_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,14 @@ groups() ->
duplicate_content_length_same_values_request,
duplicate_content_length_same_values_response,
te_and_content_length_request_rejected,
te_and_content_length_response_rejected
te_and_content_length_response_rejected,
limit_incomplete_head_capped_parse_request,
limit_incomplete_head_capped_parse_request_headers,
limit_incomplete_request_line_capped,
limit_incomplete_head_below_cap_returns_more,
limit_incomplete_head_unbounded_without_limit,
limit_incomplete_trailers_capped,
limit_chunk_size_line_capped
]}
].

Expand Down Expand Up @@ -1108,6 +1115,45 @@ te_and_content_length_response_rejected(_Config) ->
>>,
?assertEqual({error, conflicting_framing}, nhttp_h1:parse_response(Data)).

limit_incomplete_head_capped_parse_request(_Config) ->
Tail = binary:copy(<<"x">>, 16384),
Data = <<"GET / HTTP/1.1\r\nX-Endless: ", Tail/binary>>,
Opts = #{max_header_size => 1024},
?assertEqual({error, header_too_large}, nhttp_h1:parse_request(Data, Opts)).

limit_incomplete_head_capped_parse_request_headers(_Config) ->
Tail = binary:copy(<<"x">>, 16384),
Data = <<"GET / HTTP/1.1\r\nX-Endless: ", Tail/binary>>,
Opts = #{max_header_size => 1024},
?assertEqual({error, header_too_large}, nhttp_h1:parse_request_headers(Data, Opts)).

limit_incomplete_request_line_capped(_Config) ->
Data = <<"GET /", (binary:copy(<<"a">>, 16384))/binary>>,
Opts = #{max_header_size => 1024},
?assertEqual({error, header_too_large}, nhttp_h1:parse_request_headers(Data, Opts)).

limit_incomplete_head_below_cap_returns_more(_Config) ->
Data = <<"GET / HTTP/1.1\r\nX-Pending: ", (binary:copy(<<"x">>, 100))/binary>>,
Opts = #{max_header_size => 1024},
?assertMatch({more, _}, nhttp_h1:parse_request_headers(Data, Opts)).

limit_incomplete_head_unbounded_without_limit(_Config) ->
Data = <<"GET / HTTP/1.1\r\nX-Endless: ", (binary:copy(<<"x">>, 16384))/binary>>,
?assertMatch({more, _}, nhttp_h1:parse_request_headers(Data, #{})).

limit_incomplete_trailers_capped(_Config) ->
Head = <<"POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n">>,
Opts = #{max_header_size => 256},
{ok, _Req, {chunked, St}, _Consumed} = nhttp_h1:parse_request_headers(Head, Opts),
Body = <<"5\r\nhello\r\n0\r\nX-Endless: ", (binary:copy(<<"x">>, 1024))/binary>>,
?assertEqual({error, header_too_large}, nhttp_h1:parse_request_body(Body, {chunked, St})).

limit_chunk_size_line_capped(_Config) ->
Head = <<"POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n">>,
{ok, _Req, {chunked, St}, _Consumed} = nhttp_h1:parse_request_headers(Head, #{}),
Body = <<"5;ext=", (binary:copy(<<"a">>, 4096))/binary>>,
?assertEqual({error, invalid_chunk_size}, nhttp_h1:parse_request_body(Body, {chunked, St})).

%%%-----------------------------------------------------------------------------
%%% STREAMING REQUEST TESTS
%%%-----------------------------------------------------------------------------
Expand Down
11 changes: 10 additions & 1 deletion test/nhttp_sock_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
tcp_connect_buffer_override/1,
tcp_connect_timeout/1,
tcp_handshake_noop/1,
tcp_listen_send_timeout_close/1,
ssl_listen_accept_handshake/1,
ssl_send_recv/1,
ssl_alpn_negotiation/1,
Expand Down Expand Up @@ -75,7 +76,8 @@ groups() ->
tcp_connect_buffer_default,
tcp_connect_buffer_override,
tcp_connect_timeout,
tcp_handshake_noop
tcp_handshake_noop,
tcp_listen_send_timeout_close
]},
{ssl, [sequence], [
ssl_listen_accept_handshake,
Expand Down Expand Up @@ -596,6 +598,13 @@ tcp_connect(_Config) ->
nhttp_sock:close(ListenSock),
ok.

tcp_listen_send_timeout_close(_Config) ->
{ok, ListenSock} = nhttp_sock:listen(#{port => 0, transport => tcp}),
{ok, Opts} = inet:getopts(element(2, ListenSock), [send_timeout_close]),
?assertEqual(true, proplists:get_value(send_timeout_close, Opts)),
nhttp_sock:close(ListenSock),
ok.

tcp_connect_buffer_default(_Config) ->
{ok, ListenSock} = nhttp_sock:listen(#{port => 0, transport => tcp}),
{ok, {_, Port}} = nhttp_sock:sockname(ListenSock),
Expand Down
Loading