diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6c573..7718f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/nhttp_h1.erl b/src/nhttp_h1.erl index 48f5ecf..06089b4 100644 --- a/src/nhttp_h1.erl +++ b/src/nhttp_h1.erl @@ -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 @@ -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 %%%----------------------------------------------------------------------------- @@ -303,13 +317,16 @@ parse_request(<>, 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 @@ -390,32 +407,34 @@ parse_request_headers(<>, 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()). @@ -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 -> @@ -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 -> @@ -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. diff --git a/src/nhttp_lib.app.src b/src/nhttp_lib.app.src index 05e6341..74874cd 100644 --- a/src/nhttp_lib.app.src +++ b/src/nhttp_lib.app.src @@ -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, diff --git a/src/nhttp_sock.erl b/src/nhttp_sock.erl index a57585a..a3dd56d 100644 --- a/src/nhttp_sock.erl +++ b/src/nhttp_sock.erl @@ -401,6 +401,7 @@ build_tcp_opts(Opts) -> {backlog, Backlog}, {nodelay, Nodelay}, {send_timeout, SendTimeout}, + {send_timeout_close, true}, {buffer, Buffer}, {packet, raw} ]. diff --git a/test/nhttp_h1_SUITE.erl b/test/nhttp_h1_SUITE.erl index 2b66925..c92b444 100644 --- a/test/nhttp_h1_SUITE.erl +++ b/test/nhttp_h1_SUITE.erl @@ -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 ]} ]. @@ -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 %%%----------------------------------------------------------------------------- diff --git a/test/nhttp_sock_SUITE.erl b/test/nhttp_sock_SUITE.erl index 0f0bbcf..30ae5ae 100644 --- a/test/nhttp_sock_SUITE.erl +++ b/test/nhttp_sock_SUITE.erl @@ -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, @@ -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, @@ -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),