From 57a09532a3367f6dfd66dc8e4690206dbc8987a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Rubinos=20Rodr=C3=ADguez?= Date: Thu, 22 Oct 2026 13:22:49 +0200 Subject: [PATCH] fix(h2): enforce the connection preface and RFC initial peer settings --- CHANGELOG.md | 20 +++ src/nhttp_ascii.hrl | 2 + src/nhttp_h1.erl | 4 +- src/nhttp_h2.erl | 116 ++++++++++-------- src/nhttp_h2.hrl | 15 +++ src/nhttp_h2_frame.erl | 47 +++---- src/nhttp_h3.erl | 3 +- src/nhttp_headers.erl | 4 +- src/nhttp_qpack.hrl | 8 ++ src/nhttp_qpack_decoder.erl | 4 +- src/nhttp_qpack_dynamic_table.erl | 9 +- src/nhttp_qpack_encoder.erl | 6 +- src/nhttp_ws.erl | 37 +++--- src/nhttp_ws.hrl | 10 ++ src/nhttp_ws_frame.erl | 41 +++---- test/compliance/nhttp_h2_rfc9113_SUITE.erl | 135 ++++++++++++++++++--- test/nhttp_h2_SUITE.erl | 83 +++++++------ test/nhttp_props_SUITE.erl | 10 +- test/property_test/nhttp_h2_props.erl | 120 +++++++++++++++--- 19 files changed, 462 insertions(+), 212 deletions(-) create mode 100644 src/nhttp_h2.hrl create mode 100644 src/nhttp_qpack.hrl create mode 100644 src/nhttp_ws.hrl diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea8b1a..ac0d8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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). +## [Unreleased] + +### Fixed + +- `nhttp_h2:recv/2` answers a connection preface out of place with a + PROTOCOL_ERROR connection error. A second preface on a server and any + preface on a client raised `function_clause`. +- `nhttp_h2:recv/2` requires a SETTINGS frame without ACK as the first + frame of the peer preface, after the client magic on a server and from + the start on a client (RFC 9113 Section 3.4). Any other frame is a + PROTOCOL_ERROR connection error. The connection leaves the preface on + that SETTINGS frame only. +- `nhttp_h2:peer_settings/1` starts at the RFC 9113 Section 6.5.2 initial + values. `max_concurrent_streams` and `max_header_list_size` are + `infinity` until the peer sends them. Without + SETTINGS_MAX_CONCURRENT_STREAMS from the peer, `open_stream/1` refused + the 101st stream. The default send queue bound is a quarter of the value that the peer + sent, and `infinity` before that value arrives. `max_send_buffer` still + bounds the octets. + ## [2.0.0] ### Added diff --git a/src/nhttp_ascii.hrl b/src/nhttp_ascii.hrl index 3aa9a2d..f0750b1 100644 --- a/src/nhttp_ascii.hrl +++ b/src/nhttp_ascii.hrl @@ -13,3 +13,5 @@ -define(NHTTP_FIELD_VALUE_BAD_BYTES, [<> || C <- lists:seq(16#00, 16#1F), C =/= $\t] ++ [<<16#7F>>] ). + +-define(NHTTP_VALUE_WORD_SCAN_MIN, 112). diff --git a/src/nhttp_h1.erl b/src/nhttp_h1.erl index 58487c7..7f05d09 100644 --- a/src/nhttp_h1.erl +++ b/src/nhttp_h1.erl @@ -270,8 +270,6 @@ encoder never repairs the value and never strips a byte from it. -define(REQUEST_LINE_ALLOWANCE, 8192). -define(MAX_CHUNK_SIZE_LINE, 1024). --define(VALUE_WORD_SCAN_MIN, 112). - %%%----------------------------------------------------------------------------- %% COMPILED PATTERNS %%%----------------------------------------------------------------------------- @@ -1529,7 +1527,7 @@ validate_field_name(Name, NamePat) -> end. -spec validate_field_value(binary(), binary:cp()) -> ok | {error, encode_error()}. -validate_field_value(Value, ValuePat) when byte_size(Value) < ?VALUE_WORD_SCAN_MIN -> +validate_field_value(Value, ValuePat) when byte_size(Value) < ?NHTTP_VALUE_WORD_SCAN_MIN -> case binary:match(Value, ValuePat) of nomatch -> ok; _ -> {error, {invalid_field_value, Value}} diff --git a/src/nhttp_h2.erl b/src/nhttp_h2.erl index 94f4eca..98b34c7 100644 --- a/src/nhttp_h2.erl +++ b/src/nhttp_h2.erl @@ -52,6 +52,7 @@ queue without restoring credit. A queued `fin` keeps the stream `open` until its frame goes out. """. +-include("nhttp_h2.hrl"). -include("nhttp_msg.hrl"). %%%----------------------------------------------------------------------------- @@ -182,8 +183,8 @@ CONTINUATION frames that carry the larger field section. header_table_size => non_neg_integer(), enable_push => boolean(), max_concurrent_streams => pos_integer() | infinity, - initial_window_size => 1..16#7fffffff, - max_frame_size => 16#4000..16#ffffff, + initial_window_size => 1..?H2_MAX_WINDOW_SIZE, + max_frame_size => ?H2_MIN_MAX_FRAME_SIZE..?H2_MAX_MAX_FRAME_SIZE, max_header_list_size => pos_integer() | infinity, max_continuation_frames => pos_integer() | infinity, enable_connect_protocol => boolean(), @@ -274,21 +275,14 @@ stream with octets in the send queue. Nothing changed in either case. %%%----------------------------------------------------------------------------- %% INTERNAL CONSTANTS %%%----------------------------------------------------------------------------- --define(H2_DEFAULT_INITIAL_WINDOW_SIZE, 65535). --define(H2_MAX_WINDOW_SIZE, 16#7fffffff). -define(H2_MAX_STREAM_ID, 16#7fffffff). --define(H2_PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). --define(H2_PREFACE_LEN, 24). +-define(H2_INITIAL_HEADER_TABLE_SIZE, 4096). +-define(H2_INITIAL_WINDOW_SIZE, 65535). -define(H2_HEAP_BINARY_LIMIT, 64). -define(H2_REMAINDER_RETENTION_RATIO, 4). -define(H2_DEFAULT_MAX_SEND_BUFFER, 131072). -define(H2_LOCAL_RESET_MEMORY, 1024). - -%%%----------------------------------------------------------------------------- -%% LOCAL MACROS (RFC 9113 SECTION 6.5.2) -%%%----------------------------------------------------------------------------- --define(H2_DEFAULT_HEADER_TABLE_SIZE, 4096). --define(H2_DEFAULT_MAX_FRAME_SIZE, 16384). +-define(H2_DEFAULT_MAX_CONCURRENT_STREAMS, 100). -define(H2_DEFAULT_MAX_HEADER_LIST_SIZE, 16384). -define(H2_CONTINUATION_HEADROOM, 8). @@ -321,7 +315,7 @@ stream with octets in the send queue. Nothing changed in either case. -record(h2_conn, { role :: role(), - state = preface :: preface | open | closing | closed, + state = preface :: magic | preface | open | closing | closed, local_settings :: settings(), peer_settings :: settings(), settings_acked = false :: boolean(), @@ -331,8 +325,8 @@ stream with octets in the send queue. Nothing changed in either case. active_stream_count = 0 :: non_neg_integer(), peer_streams_opened = 0 :: non_neg_integer(), peer_streams_reset = 0 :: non_neg_integer(), - send_window = ?H2_DEFAULT_INITIAL_WINDOW_SIZE :: integer(), - recv_window = ?H2_DEFAULT_INITIAL_WINDOW_SIZE :: integer(), + send_window = ?H2_INITIAL_WINDOW_SIZE :: integer(), + recv_window = ?H2_INITIAL_WINDOW_SIZE :: integer(), hpack_enc :: nhttp_hpack:state(), hpack_dec :: nhttp_hpack:state(), continuation_stream = undefined :: undefined | nhttp_lib:stream_id(), @@ -363,7 +357,7 @@ new(Role) -> -spec new(role(), settings()) -> conn(). new(Role, LocalSettings) -> MergedSettings = maps:merge(default_settings(), LocalSettings), - HeaderTableSize = maps:get(header_table_size, MergedSettings, ?H2_DEFAULT_HEADER_TABLE_SIZE), + HeaderTableSize = maps:get(header_table_size, MergedSettings, ?H2_INITIAL_HEADER_TABLE_SIZE), IndexPolicy = maps:get(hpack_index_policy, MergedSettings, #{}), MaxSendBuffer = maps:get(max_send_buffer, MergedSettings, ?H2_DEFAULT_MAX_SEND_BUFFER), MaxQueuedStreams = maps:get(max_queued_streams, MergedSettings, undefined), @@ -371,9 +365,9 @@ new(Role, LocalSettings) -> {ok, HpackDec} = nhttp_hpack:new(HeaderTableSize), #h2_conn{ role = Role, - state = preface, + state = initial_state(Role), local_settings = MergedSettings, - peer_settings = default_settings(), + peer_settings = initial_peer_settings(), next_stream_id = initial_stream_id(Role), hpack_enc = HpackEnc, hpack_dec = HpackDec, @@ -416,17 +410,10 @@ The four-tuple arm carries frames for the caller to write: SETTINGS ACK, PING ACK and RST_STREAM, then the DATA frames the send queue emitted. """. -spec recv(conn(), binary()) -> recv_result(). -recv(#h2_conn{role = server, state = preface, buffer = <<>>} = Conn, Data) -> - maybe - ok ?= validate_preface_buffer(Data), - recv_loop(Conn, Data, [], []) - end; -recv(#h2_conn{role = server, state = preface, buffer = Buffer} = Conn, Data) -> - Buffered = <>, - maybe - ok ?= validate_preface_buffer(Buffered), - recv_loop(Conn#h2_conn{buffer = <<>>}, Buffered, [], []) - end; +recv(#h2_conn{state = magic, buffer = <<>>} = Conn, Data) -> + recv_magic(Conn, Data); +recv(#h2_conn{state = magic, buffer = Buffer} = Conn, Data) -> + recv_magic(Conn#h2_conn{buffer = <<>>}, <>); recv(#h2_conn{buffer = <<>>} = Conn, Data) -> recv_loop(Conn, Data, [], []); recv(#h2_conn{buffer = Buffer} = Conn, Data) -> @@ -607,13 +594,13 @@ open_stream( case check_peer_max_streams(ActiveCount, PeerSettings) of ok -> InitialWindow = maps:get( - initial_window_size, PeerSettings, ?H2_DEFAULT_INITIAL_WINDOW_SIZE + initial_window_size, PeerSettings, ?H2_INITIAL_WINDOW_SIZE ), Stream = #h2_stream{ id = StreamId, state = idle, send_window = InitialWindow, - recv_window = ?H2_DEFAULT_INITIAL_WINDOW_SIZE + recv_window = ?H2_INITIAL_WINDOW_SIZE }, NewConn = Conn#h2_conn{ next_stream_id = StreamId + 2, @@ -785,7 +772,7 @@ check_stream_concurrency(#h2_conn{ active_stream_count = Count, local_settings = Settings }) -> - MaxStreams = maps:get(max_concurrent_streams, Settings, 100), + MaxStreams = maps:get(max_concurrent_streams, Settings, ?H2_DEFAULT_MAX_CONCURRENT_STREAMS), case MaxStreams of infinity -> ok; Max when Count >= Max -> {error, at_limit}; @@ -1011,10 +998,10 @@ validate_header_sequence(_IsTrailers, _EndStream, _StreamId) -> -spec default_settings() -> settings(). default_settings() -> #{ - header_table_size => ?H2_DEFAULT_HEADER_TABLE_SIZE, - initial_window_size => ?H2_DEFAULT_INITIAL_WINDOW_SIZE, - max_frame_size => ?H2_DEFAULT_MAX_FRAME_SIZE, - max_concurrent_streams => 100, + header_table_size => ?H2_INITIAL_HEADER_TABLE_SIZE, + initial_window_size => ?H2_INITIAL_WINDOW_SIZE, + max_frame_size => ?H2_INITIAL_MAX_FRAME_SIZE, + max_concurrent_streams => ?H2_DEFAULT_MAX_CONCURRENT_STREAMS, max_header_list_size => ?H2_DEFAULT_MAX_HEADER_LIST_SIZE }. @@ -1024,7 +1011,7 @@ derived_continuation_bound(Settings) -> infinity -> infinity; MaxList -> - MaxFrame = maps:get(max_frame_size, Settings, ?H2_DEFAULT_MAX_FRAME_SIZE), + MaxFrame = maps:get(max_frame_size, Settings, ?H2_INITIAL_MAX_FRAME_SIZE), (MaxList + MaxFrame - 1) div MaxFrame + ?H2_CONTINUATION_HEADROOM end. @@ -1057,7 +1044,7 @@ do_send_data( Frames ) -> EffectiveWindow = min(ConnWindow, StreamWindow), - MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_DEFAULT_MAX_FRAME_SIZE), + MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_INITIAL_MAX_FRAME_SIZE), MaxSend = min(EffectiveWindow, MaxFrameSize), case DataSize =< MaxSend of true when EffectiveWindow > 0 -> @@ -1326,7 +1313,7 @@ queued_streams_bound(#h2_conn{max_queued_streams = Bound}) -> drain_send_q(#h2_conn{send_window = ConnWindow} = Conn) when ConnWindow =< 0 -> {Conn, [], []}; drain_send_q(#h2_conn{send_q = #send_q{order = Order}, peer_settings = PeerSettings} = Conn) -> - MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_DEFAULT_MAX_FRAME_SIZE), + MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_INITIAL_MAX_FRAME_SIZE), round_robin(Conn, MaxFrameSize, Order, queue:new(), queue:new(), [], #{}). -spec round_robin( @@ -1514,7 +1501,7 @@ do_send_headers( EndStream ) -> {ok, HeaderBlock, NewHpackEnc} = nhttp_hpack:encode(Headers, HpackEnc), - MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_DEFAULT_MAX_FRAME_SIZE), + MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_INITIAL_MAX_FRAME_SIZE), {ok, Frame} = nhttp_h2_frame:headers_with_continuation( StreamId, EndStream, HeaderBlock, MaxFrameSize ), @@ -1536,13 +1523,13 @@ get_or_create_stream(#h2_conn{streams = Streams, peer_settings = PeerSettings}, case maps:get(StreamId, Streams, undefined) of undefined -> InitialWindow = maps:get( - initial_window_size, PeerSettings, ?H2_DEFAULT_INITIAL_WINDOW_SIZE + initial_window_size, PeerSettings, ?H2_INITIAL_WINDOW_SIZE ), #h2_stream{ id = StreamId, state = idle, send_window = InitialWindow, - recv_window = ?H2_DEFAULT_INITIAL_WINDOW_SIZE + recv_window = ?H2_INITIAL_WINDOW_SIZE }; Stream -> Stream @@ -1561,6 +1548,20 @@ hpack_decode_opts(#h2_conn{local_settings = Settings}) -> Max -> #{max_list_size => Max} end. +-spec initial_peer_settings() -> settings(). +initial_peer_settings() -> + #{ + header_table_size => ?H2_INITIAL_HEADER_TABLE_SIZE, + initial_window_size => ?H2_INITIAL_WINDOW_SIZE, + max_frame_size => ?H2_INITIAL_MAX_FRAME_SIZE, + max_concurrent_streams => infinity, + max_header_list_size => infinity + }. + +-spec initial_state(role()) -> magic | preface. +initial_state(server) -> magic; +initial_state(client) -> preface. + -spec initial_stream_id(role()) -> nhttp_lib:stream_id(). initial_stream_id(client) -> 1; initial_stream_id(server) -> 2. @@ -1575,8 +1576,7 @@ invalid_field_reason(invalid_header_value) -> -spec invalid_preface_error() -> {error, nhttp_h2_frame:decode_error()}. invalid_preface_error() -> - {error, - {connection_error, protocol_error, <<"Invalid connection preface (RFC 9113 Section 3.4)">>}}. + {error, {connection_error, protocol_error, ?H2_INVALID_PREFACE}}. -spec is_active_state(stream_state()) -> boolean(). is_active_state(open) -> true; @@ -1742,12 +1742,14 @@ process_frame(#h2_conn{continuation_stream = ExpectedId}, Frame) when {error, {connection_error, protocol_error, <<"Received non-CONTINUATION frame while expecting CONTINUATION (RFC 9113 Section 4.3)">>}}; -process_frame(#h2_conn{role = server, state = preface} = Conn, preface) -> - {ok, Conn#h2_conn{state = open}, [], []}; -process_frame(#h2_conn{role = server, state = preface}, _Frame) -> +process_frame(#h2_conn{state = preface} = Conn, {settings, Settings}) -> + process_settings(Conn, Settings); +process_frame(#h2_conn{state = preface}, _Frame) -> {error, {connection_error, protocol_error, - <<"Client must send connection preface first (RFC 9113 Section 3.4)">>}}; + <<"Peer preface does not start with SETTINGS (RFC 9113 Section 3.4)">>}}; +process_frame(#h2_conn{}, preface) -> + invalid_preface_error(); process_frame( #h2_conn{role = server, streams = Streams, last_peer_stream_id = LastPeer} = Conn, {rst_stream, StreamId, _ErrorCode} @@ -1932,7 +1934,7 @@ process_settings(#h2_conn{peer_settings = OldSettings, streams = Streams} = Conn 0; NewInitialWindow -> NewInitialWindow - - maps:get(initial_window_size, OldSettings, ?H2_DEFAULT_INITIAL_WINDOW_SIZE) + maps:get(initial_window_size, OldSettings, ?H2_INITIAL_WINDOW_SIZE) end, maybe {ok, NewStreams} ?= adjust_stream_windows(Streams, Delta), @@ -1984,7 +1986,7 @@ rapid_reset_error() -> -spec recv_loop(conn(), binary(), [[event()]], iodata()) -> recv_result(). recv_loop(Conn, Data, EventsAcc, ToSend) -> MaxFrameSize = maps:get( - max_frame_size, Conn#h2_conn.local_settings, ?H2_DEFAULT_MAX_FRAME_SIZE + max_frame_size, Conn#h2_conn.local_settings, ?H2_INITIAL_MAX_FRAME_SIZE ), case nhttp_h2_frame:decode(Data, MaxFrameSize) of {ok, Frame, Consumed} -> @@ -2027,6 +2029,15 @@ finish_recv(Conn, Events, ToSend) -> {ok, Events ++ Sent, NewConn, [ToSend, Frames]} end. +-spec recv_magic(conn(), binary()) -> recv_result(). +recv_magic(Conn, <>) -> + recv_loop(Conn#h2_conn{state = preface}, Rest, [], []); +recv_magic(Conn, Data) -> + maybe + ok ?= validate_preface_buffer(Data), + {ok, [], Conn#h2_conn{buffer = Data}} + end. + -spec recv_result(conn(), [event()], iodata()) -> recv_result(). recv_result(Conn, Events, ToSend) -> case iolist_size(ToSend) of @@ -2146,7 +2157,7 @@ validate_peer_stream_id(#h2_conn{role = client, last_peer_stream_id = LastPeer}, -spec validate_preface_buffer(binary()) -> ok | {error, nhttp_h2_frame:decode_error()}. validate_preface_buffer(Data) -> PrefixLen = min(byte_size(Data), ?H2_PREFACE_LEN), - <> = ?H2_PREFACE, + <> = <>, case Data of <> -> ok; _ -> invalid_preface_error() @@ -2188,7 +2199,6 @@ validate_recv_flow(_ConnWindow, StreamWindow, StreamId) when StreamWindow < 0 -> validate_recv_flow(_ConnWindow, _StreamWindow, _StreamId) -> ok. --define(CONNECTION_HEADERS_SET, ?NHTTP_MSG_CONNECTION_HEADERS_SET). -spec build_headers_event( role(), boolean(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin(), conn() ) -> @@ -2272,7 +2282,7 @@ validate_response_headers([{<<":status">>, _} | _], #{has_status := true}) -> validate_response_headers([{<<$:, _/binary>>, _} | _], _) -> {error, protocol_error}; validate_response_headers([{Name, Value} | Rest], State) -> - case maps:is_key(Name, ?CONNECTION_HEADERS_SET) of + case maps:is_key(Name, ?NHTTP_MSG_CONNECTION_HEADERS_SET) of true -> {error, protocol_error}; false -> diff --git a/src/nhttp_h2.hrl b/src/nhttp_h2.hrl new file mode 100644 index 0000000..c23c447 --- /dev/null +++ b/src/nhttp_h2.hrl @@ -0,0 +1,15 @@ +%%%----------------------------------------------------------------------------- +%%% HTTP/2 wire constants that nhttp_h2 and nhttp_h2_frame both read. +%%% +%%% RFC 9113 values only. Library policy stays in nhttp_h2.erl. The preface +%%% is a string so that `<>` is a pattern and +%%% `<>` is the value. +%%%----------------------------------------------------------------------------- + +-define(H2_PREFACE, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"). +-define(H2_PREFACE_LEN, byte_size(<>)). +-define(H2_INVALID_PREFACE, <<"Invalid connection preface (RFC 9113 Section 3.4)">>). +-define(H2_MAX_WINDOW_SIZE, 16#7fffffff). +-define(H2_MIN_MAX_FRAME_SIZE, 16#4000). +-define(H2_MAX_MAX_FRAME_SIZE, 16#ffffff). +-define(H2_INITIAL_MAX_FRAME_SIZE, 16384). diff --git a/src/nhttp_h2_frame.erl b/src/nhttp_h2_frame.erl index 757a82f..0c4bc8e 100644 --- a/src/nhttp_h2_frame.erl +++ b/src/nhttp_h2_frame.erl @@ -23,6 +23,8 @@ ok = ssl:send(Socket, Frame). ``` """. +-include("nhttp_h2.hrl"). + %%%----------------------------------------------------------------------------- %% INLINE DIRECTIVES (PERFORMANCE OPTIMIZATION) %%%----------------------------------------------------------------------------- @@ -150,43 +152,25 @@ ok = ssl:send(Socket, Frame). -define(SETTINGS_MAX_HEADER_LIST_SIZE, 16#06). -define(SETTINGS_ENABLE_CONNECT_PROTOCOL, 16#08). -%%%----------------------------------------------------------------------------- -%% DEFAULT VALUES (RFC 9113 SECTION 6.5.2) -%%%----------------------------------------------------------------------------- --define(DEFAULT_MAX_FRAME_SIZE, 16384). - -%%%----------------------------------------------------------------------------- -%% LIMITS (RFC 9113) -%%%----------------------------------------------------------------------------- --define(MIN_MAX_FRAME_SIZE, 16#4000). --define(MAX_FRAME_SIZE_LIMIT, 16#ffffff). --define(MAX_WINDOW_SIZE, 16#7fffffff). - %%%----------------------------------------------------------------------------- %% FRAME STRUCTURE %%%----------------------------------------------------------------------------- -define(FRAME_HEADER_SIZE, 9). -%%%----------------------------------------------------------------------------- -%% CONNECTION PREFACE (RFC 9113 SECTION 3.4) -%%%----------------------------------------------------------------------------- --define(PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). --define(PREFACE_LEN, 24). - %%%----------------------------------------------------------------------------- %% DECODING %%%----------------------------------------------------------------------------- -doc "Decode a single frame from binary data. Returns {ok, Frame, BytesConsumed} where BytesConsumed is the number of bytes consumed from the input. Use split_at/2 to get the remaining buffer. Uses default max frame size (16384 bytes).". -spec decode(Data :: binary()) -> decode_result(). decode(<>) -> - decode(Data, ?DEFAULT_MAX_FRAME_SIZE). + decode(Data, ?H2_INITIAL_MAX_FRAME_SIZE). -doc "Decode a single frame with custom max frame size. The max frame size should come from peer's SETTINGS_MAX_FRAME_SIZE.". -spec decode(Data :: binary(), MaxFrameSize :: pos_integer()) -> decode_result(). -decode(<<"PRI ", _/binary>> = Data, _MaxFrameSize) when byte_size(Data) >= ?PREFACE_LEN -> +decode(<<"PRI ", _/binary>> = Data, _MaxFrameSize) when byte_size(Data) >= ?H2_PREFACE_LEN -> decode_frame(Data, 0); decode(<<"PRI ", _/binary>> = Data, _MaxFrameSize) -> - {more, ?PREFACE_LEN - byte_size(Data)}; + {more, ?H2_PREFACE_LEN - byte_size(Data)}; decode(<>, _MaxFrameSize) when byte_size(Data) < ?FRAME_HEADER_SIZE -> {more, ?FRAME_HEADER_SIZE - byte_size(Data)}; decode(<>, MaxFrameSize) when Len > MaxFrameSize -> @@ -201,7 +185,7 @@ decode(<>, _MaxFrameSize) -> -doc "Decode all complete frames from binary data. Returns {ok, Frames, BytesConsumed} with list of decoded frames.". -spec decode_all(binary()) -> decode_all_result(). decode_all(<>) -> - decode_all_loop(Data, ?DEFAULT_MAX_FRAME_SIZE, 0, []). + decode_all_loop(Data, ?H2_INITIAL_MAX_FRAME_SIZE, 0, []). -doc "Decode settings payload (for use in connection layer).". -spec decode_settings_payload(binary()) -> {ok, nhttp_h2:settings()} | {error, decode_error()}. @@ -325,14 +309,14 @@ settings_ack() -> -doc "Encode a connection-level WINDOW_UPDATE frame.". -spec window_update(Increment :: pos_integer()) -> {ok, binary()}. -window_update(Increment) when Increment > 0, Increment =< ?MAX_WINDOW_SIZE -> +window_update(Increment) when Increment > 0, Increment =< ?H2_MAX_WINDOW_SIZE -> {ok, <<4:24, ?FRAME_WINDOW_UPDATE:8, 0:8, 0:32, 0:1, Increment:31>>}. -doc "Encode a stream-level WINDOW_UPDATE frame.". -spec window_update(StreamId, Increment) -> {ok, binary()} when StreamId :: nhttp_lib:stream_id(), Increment :: pos_integer(). -window_update(StreamId, Increment) when Increment > 0, Increment =< ?MAX_WINDOW_SIZE -> +window_update(StreamId, Increment) when Increment > 0, Increment =< ?H2_MAX_WINDOW_SIZE -> {ok, <<4:24, ?FRAME_WINDOW_UPDATE:8, 0:8, 0:1, StreamId:31, 0:1, Increment:31>>}. %%%----------------------------------------------------------------------------- @@ -359,7 +343,7 @@ headers_with_continuation(StreamId, EndStream, HeaderBlock, MaxFrameSize) -> -doc "HTTP/2 connection preface magic string.". -spec preface() -> {ok, binary()}. preface() -> - {ok, ?PREFACE}. + {ok, <>}. -doc "Split buffer at position, returning the remainder.". -spec split_at(binary(), non_neg_integer()) -> binary(). @@ -412,11 +396,10 @@ decode_all_loop(<>, MaxFrameSize, TotalConsumed, Acc) -> end. -spec decode_frame(binary(), non_neg_integer()) -> decode_result(). -decode_frame(<<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", _/binary>>, Skip) -> - {ok, preface, Skip + ?PREFACE_LEN}; +decode_frame(<>, Skip) -> + {ok, preface, Skip + ?H2_PREFACE_LEN}; decode_frame(<<"PRI ", _/binary>>, _Skip) -> - {error, - {connection_error, protocol_error, <<"Invalid connection preface (RFC 9113 Section 3.4)">>}}; + {error, {connection_error, protocol_error, ?H2_INVALID_PREFACE}}; decode_frame(<<_Len:24, ?FRAME_DATA:8, _:8, _:1, 0:31, _/binary>>, _Skip) -> {error, {connection_error, protocol_error, @@ -749,7 +732,7 @@ decode_settings_payload(<>, _Settings) decode_settings_payload(<>, Settings) -> decode_settings_payload(Rest, Settings#{max_concurrent_streams => Value}); decode_settings_payload(<>, _Settings) when - Value > ?MAX_WINDOW_SIZE + Value > ?H2_MAX_WINDOW_SIZE -> {error, {connection_error, flow_control_error, @@ -757,13 +740,13 @@ decode_settings_payload(<> decode_settings_payload(<>, Settings) -> decode_settings_payload(Rest, Settings#{initial_window_size => Value}); decode_settings_payload(<>, _Settings) when - Value < ?MIN_MAX_FRAME_SIZE + Value < ?H2_MIN_MAX_FRAME_SIZE -> {error, {connection_error, protocol_error, <<"SETTINGS_MAX_FRAME_SIZE below minimum (RFC 9113 Section 6.5.2)">>}}; decode_settings_payload(<>, _Settings) when - Value > ?MAX_FRAME_SIZE_LIMIT + Value > ?H2_MAX_MAX_FRAME_SIZE -> {error, {connection_error, protocol_error, diff --git a/src/nhttp_h3.erl b/src/nhttp_h3.erl index 15f98e0..519d663 100644 --- a/src/nhttp_h3.erl +++ b/src/nhttp_h3.erl @@ -954,7 +954,6 @@ validate_section(server, Headers, false, Settings) -> validate_section(client, Headers, false, _Settings) -> validate_response_headers(Headers). --define(H3_CONNECTION_HEADERS_SET, ?NHTTP_MSG_CONNECTION_HEADERS_SET). -spec build_initial_headers_event( role(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin(), conn() ) -> {ok, event()} | {error, h3_error()}. @@ -1076,7 +1075,7 @@ validate_response_headers_loop([{<<":status">>, _} | _], #{has_status := true}) validate_response_headers_loop([{<<$:, _/binary>>, _} | _], _) -> {error, <<"Invalid pseudo-header in response">>}; validate_response_headers_loop([{Name, _Value} | Rest], State) -> - case maps:is_key(Name, ?H3_CONNECTION_HEADERS_SET) of + case maps:is_key(Name, ?NHTTP_MSG_CONNECTION_HEADERS_SET) of true -> {error, <<"Forbidden connection-specific header">>}; false -> diff --git a/src/nhttp_headers.erl b/src/nhttp_headers.erl index c17ada3..8641661 100644 --- a/src/nhttp_headers.erl +++ b/src/nhttp_headers.erl @@ -66,8 +66,6 @@ lowercasing of `lower_field_name/1`. -type field_value_error() :: invalid_field_value_char | field_value_edge_whitespace. --define(VALUE_WORD_SCAN_MIN, 112). - -define(W_LOW7, 16#7F7F7F7F7F7F7F). -define(W_TAB7, 16#09090909090909). -define(W_SUB32, 16#60606060606060). @@ -485,7 +483,7 @@ value_edges(Value) -> end. -spec value_octets(binary()) -> ok | {error, field_value_error()}. -value_octets(Value) when byte_size(Value) < ?VALUE_WORD_SCAN_MIN -> +value_octets(Value) when byte_size(Value) < ?NHTTP_VALUE_WORD_SCAN_MIN -> case binary:match(Value, persistent_term:get(?PT_FIELD_VALUE_BAD)) of nomatch -> ok; _ -> {error, invalid_field_value_char} diff --git a/src/nhttp_qpack.hrl b/src/nhttp_qpack.hrl new file mode 100644 index 0000000..6951a24 --- /dev/null +++ b/src/nhttp_qpack.hrl @@ -0,0 +1,8 @@ +%%%----------------------------------------------------------------------------- +%%% QPACK constants that the dynamic table, the encoder and the decoder share. +%%% +%%% An entry of the dynamic table counts 32 octets on top of its name and +%%% value (RFC 9204 Section 3.2.1). +%%%----------------------------------------------------------------------------- + +-define(QPACK_ENTRY_OVERHEAD, 32). diff --git a/src/nhttp_qpack_decoder.erl b/src/nhttp_qpack_decoder.erl index bf3ca23..cfad582 100644 --- a/src/nhttp_qpack_decoder.erl +++ b/src/nhttp_qpack_decoder.erl @@ -26,6 +26,8 @@ failure a connection error. An invalid field never enters the dynamic table, so a name or a value that arrives as a table index needs no second read. """. +-include("nhttp_qpack.hrl"). + -compile({inline, [check_field/2, check_value/2, tag/2]}). %%%----------------------------------------------------------------------------- @@ -167,7 +169,7 @@ new(Config) -> {ok, #qpack_dec{ dynamic_table = Table, max_table_capacity = MaxCap, - max_entries = MaxCap div 32, + max_entries = MaxCap div ?QPACK_ENTRY_OVERHEAD, max_blocked_streams = MaxBlocked, known_sending_count = 0, blocked_streams = #{}, diff --git a/src/nhttp_qpack_dynamic_table.erl b/src/nhttp_qpack_dynamic_table.erl index 077307e..d3e20d1 100644 --- a/src/nhttp_qpack_dynamic_table.erl +++ b/src/nhttp_qpack_dynamic_table.erl @@ -11,6 +11,8 @@ Lookups are constant time in both directions: forward from an absolute index to an entry, and reverse from a name and value to an absolute index. """. +-include("nhttp_qpack.hrl"). + %%%----------------------------------------------------------------------------- %% EXPORTS %%%----------------------------------------------------------------------------- @@ -36,11 +38,6 @@ index to an entry, and reverse from a name and value to an absolute index. %%%----------------------------------------------------------------------------- -type entry() :: {binary(), binary()}. -%%%----------------------------------------------------------------------------- -%% CONSTANTS -%%%----------------------------------------------------------------------------- --define(ENTRY_OVERHEAD, 32). - %%%----------------------------------------------------------------------------- %% RECORDS %%%----------------------------------------------------------------------------- @@ -203,7 +200,7 @@ insert_count(#table{insert_count = IC}) -> IC. -doc false. -spec entry_size(binary(), binary()) -> pos_integer(). entry_size(Name, Value) -> - byte_size(Name) + byte_size(Value) + ?ENTRY_OVERHEAD. + byte_size(Name) + byte_size(Value) + ?QPACK_ENTRY_OVERHEAD. -doc false. -spec evict_to_fit(t()) -> t(). diff --git a/src/nhttp_qpack_encoder.erl b/src/nhttp_qpack_encoder.erl index d9154f5..5318640 100644 --- a/src/nhttp_qpack_encoder.erl +++ b/src/nhttp_qpack_encoder.erl @@ -45,6 +45,8 @@ DecoderData = ..., ``` """. +-include("nhttp_qpack.hrl"). + %%%----------------------------------------------------------------------------- %% EXPORTS %%%----------------------------------------------------------------------------- @@ -175,7 +177,7 @@ new(Config) -> dynamic_table = Table, max_table_capacity = MaxCap, configured_max_capacity = ConfiguredCap, - max_entries = MaxCap div 32, + max_entries = MaxCap div ?QPACK_ENTRY_OVERHEAD, max_blocked_streams = MaxBlocked, configured_max_blocked = ConfiguredBlocked, known_received_count = 0, @@ -207,7 +209,7 @@ reconcile_peer_limits(PeerMaxCap, PeerMaxBlocked, State) -> EffectiveBlocked = min(State#qpack_enc.configured_max_blocked, PeerMaxBlocked), State#qpack_enc{ max_table_capacity = EffectiveCap, - max_entries = PeerMaxCap div 32, + max_entries = PeerMaxCap div ?QPACK_ENTRY_OVERHEAD, max_blocked_streams = EffectiveBlocked, pending_capacity = EffectiveCap > 0 }. diff --git a/src/nhttp_ws.erl b/src/nhttp_ws.erl index 62bbad6..5e96457 100644 --- a/src/nhttp_ws.erl +++ b/src/nhttp_ws.erl @@ -17,6 +17,8 @@ The pure per-frame primitives (`encode/1,2`, `decode/1`, `decode_unmasked/1`, for backwards compatibility. """. +-include("nhttp_ws.hrl"). + -compile({inline, [{next_frame, 2}, {refuse, 2}, {whole_frame, 6}]}). %%%----------------------------------------------------------------------------- @@ -204,13 +206,8 @@ for backwards compatibility. -define(SEND_TIMEOUT_MS, 5000). -define(WS_DEFAULT_MAX_MESSAGE_SIZE, 16777216). -define(WS_MAX_HEADER_SIZE, 14). --define(OP_CONTINUATION, 0). --define(OP_TEXT, 1). --define(OP_BINARY, 2). --define(OP_CLOSE, 8). --define(OP_PING, 9). --define(OP_PONG, 10). --define(IS_CONTROL(Op), (Op =:= ?OP_CLOSE orelse Op =:= ?OP_PING orelse Op =:= ?OP_PONG)). +-define(WS_OP_CONTINUATION, 0). +-define(IS_CONTROL(Op), (Op =:= ?WS_OP_CLOSE orelse Op =:= ?WS_OP_PING orelse Op =:= ?WS_OP_PONG)). %%%----------------------------------------------------------------------------- %% INTERNAL RECORDS @@ -738,7 +735,7 @@ the same test to the same sizes. -spec check_continuation_size(0..15, non_neg_integer(), ws_decoder()) -> ok | {error, message_too_large}. check_continuation_size( - ?OP_CONTINUATION, Len, #ws_decoder{frag_opcode = FragOp, frag_acc_size = AccSize} = Dec + ?WS_OP_CONTINUATION, Len, #ws_decoder{frag_opcode = FragOp, frag_acc_size = AccSize} = Dec ) when FragOp =/= undefined -> check_message_size(AccSize + Len, Dec); check_continuation_size(_Opcode, _Len, _Dec) -> @@ -854,7 +851,7 @@ partial_carry(#ws_decoder{partial_carry = Carry}) -> Carry. -spec process_fragment(0 | 1, 0..15, binary(), binary(), ws_decoder()) -> stateful_decode_result(). process_fragment(1, Opcode, Payload, Rest, #ws_decoder{frag_opcode = undefined} = Dec) when - Opcode =:= ?OP_TEXT; Opcode =:= ?OP_BINARY + Opcode =:= ?WS_OP_TEXT; Opcode =:= ?WS_OP_BINARY -> maybe ok ?= check_message_size(byte_size(Payload), Dec), @@ -877,7 +874,7 @@ process_fragment(1, Opcode, Payload, Rest, #ws_decoder{frag_opcode = undefined} Err end; process_fragment(0, Opcode, Payload, Rest, #ws_decoder{frag_opcode = undefined} = Dec) when - Opcode =:= ?OP_TEXT; Opcode =:= ?OP_BINARY + Opcode =:= ?WS_OP_TEXT; Opcode =:= ?WS_OP_BINARY -> Size = byte_size(Payload), maybe @@ -900,7 +897,7 @@ process_fragment( end; process_fragment( 0, - ?OP_CONTINUATION, + ?WS_OP_CONTINUATION, Payload, Rest, #ws_decoder{frag_opcode = FragOp, frag_acc = Acc, frag_acc_size = AccSize} = Dec @@ -916,7 +913,7 @@ process_fragment( end; process_fragment( 1, - ?OP_CONTINUATION, + ?WS_OP_CONTINUATION, Payload, Rest, #ws_decoder{frag_opcode = FragOp, frag_acc = Acc, frag_acc_size = AccSize} = Dec @@ -972,17 +969,17 @@ scan_partial_text(New, Needed, #ws_decoder{partial_scanned = Scanned} = Dec) -> scan_run(<<>>, Payload) -> nhttp_ws_frame:scan_utf8(Payload); scan_run(Carry, Payload) -> nhttp_ws_frame:scan_utf8(Carry, Payload). --spec scan_text(?OP_TEXT | ?OP_BINARY, binary(), ws_decoder()) -> +-spec scan_text(?WS_OP_TEXT | ?WS_OP_BINARY, binary(), ws_decoder()) -> {ok, Carry :: binary()} | {error, invalid_utf8}. -scan_text(?OP_BINARY, _Payload, _Dec) -> +scan_text(?WS_OP_BINARY, _Payload, _Dec) -> {ok, <<>>}; -scan_text(?OP_TEXT, Payload, #ws_decoder{partial_scanned = Scanned} = Dec) -> +scan_text(?WS_OP_TEXT, Payload, #ws_decoder{partial_scanned = Scanned} = Dec) -> Tail = binary:part(Payload, Scanned, byte_size(Payload) - Scanned), scan_run(partial_carry(Dec), Tail). -spec scans_as_text(0..15, ws_decoder()) -> boolean(). -scans_as_text(?OP_TEXT, #ws_decoder{frag_opcode = undefined}) -> true; -scans_as_text(?OP_CONTINUATION, #ws_decoder{frag_opcode = ?OP_TEXT}) -> true; +scans_as_text(?WS_OP_TEXT, #ws_decoder{frag_opcode = undefined}) -> true; +scans_as_text(?WS_OP_CONTINUATION, #ws_decoder{frag_opcode = ?WS_OP_TEXT}) -> true; scans_as_text(_Opcode, _Dec) -> false. -spec start_frame(#ws_partial{}, binary(), ws_decoder(), [ws_message()]) -> feed_result(). @@ -1010,9 +1007,9 @@ take_piece(Piece, #ws_partial{mask_key = MaskKey, got = Got, chunks = Chunks} = }} end. --spec text_or_binary_message(?OP_TEXT | ?OP_BINARY, binary()) -> ws_message(). -text_or_binary_message(?OP_TEXT, Payload) -> {text, Payload}; -text_or_binary_message(?OP_BINARY, Payload) -> {binary, Payload}. +-spec text_or_binary_message(?WS_OP_TEXT | ?WS_OP_BINARY, binary()) -> ws_message(). +text_or_binary_message(?WS_OP_TEXT, Payload) -> {text, Payload}; +text_or_binary_message(?WS_OP_BINARY, Payload) -> {binary, Payload}. -spec validate_connection_header(nhttp_lib:headers()) -> ok | {error, invalid_connection}. validate_connection_header(Headers) -> diff --git a/src/nhttp_ws.hrl b/src/nhttp_ws.hrl new file mode 100644 index 0000000..d36b0ae --- /dev/null +++ b/src/nhttp_ws.hrl @@ -0,0 +1,10 @@ +%%%----------------------------------------------------------------------------- +%%% WebSocket opcodes that nhttp_ws and nhttp_ws_frame both read +%%% (RFC 6455 Section 5.2). +%%%----------------------------------------------------------------------------- + +-define(WS_OP_TEXT, 1). +-define(WS_OP_BINARY, 2). +-define(WS_OP_CLOSE, 8). +-define(WS_OP_PING, 9). +-define(WS_OP_PONG, 10). diff --git a/src/nhttp_ws_frame.erl b/src/nhttp_ws_frame.erl index ab07027..3186b0b 100644 --- a/src/nhttp_ws_frame.erl +++ b/src/nhttp_ws_frame.erl @@ -28,6 +28,8 @@ length legal for every control frame and §5.5.1 uses it for the close code and reason. """. +-include("nhttp_ws.hrl"). + -compile( {inline, [ {decode_complete, 6}, @@ -139,11 +141,6 @@ code and reason. %%%----------------------------------------------------------------------------- -define(MAX_CONTROL_PAYLOAD, 125). -define(MAX_CLOSE_REASON, (?MAX_CONTROL_PAYLOAD - 2)). --define(OP_TEXT, 1). --define(OP_BINARY, 2). --define(OP_CLOSE, 8). --define(OP_PING, 9). --define(OP_PONG, 10). %%%----------------------------------------------------------------------------- %% ENCODE @@ -272,29 +269,29 @@ Close payloads are validated against the reserved-code ranges of RFC 6455 §7.4.1 and the reason is checked as UTF-8. """. -spec opcode_to_complete_message(0..15, binary()) -> {ok, ws_message()} | {error, term()}. -opcode_to_complete_message(?OP_TEXT, Data) -> +opcode_to_complete_message(?WS_OP_TEXT, Data) -> case is_valid_utf8(Data) of true -> {ok, {text, Data}}; false -> {error, invalid_utf8} end; -opcode_to_complete_message(?OP_BINARY, Data) -> +opcode_to_complete_message(?WS_OP_BINARY, Data) -> {ok, {binary, Data}}; -opcode_to_complete_message(?OP_CLOSE, <<>>) -> +opcode_to_complete_message(?WS_OP_CLOSE, <<>>) -> {ok, close}; -opcode_to_complete_message(?OP_CLOSE, <<_:8>>) -> +opcode_to_complete_message(?WS_OP_CLOSE, <<_:8>>) -> {error, invalid_close_payload}; -opcode_to_complete_message(?OP_CLOSE, <>) -> +opcode_to_complete_message(?WS_OP_CLOSE, <>) -> case validate_close(Code, Reason) of ok -> {ok, {close, Code, Reason}}; {error, _} = Error -> Error end; -opcode_to_complete_message(?OP_PING, <<>>) -> +opcode_to_complete_message(?WS_OP_PING, <<>>) -> {ok, ping}; -opcode_to_complete_message(?OP_PING, Data) -> +opcode_to_complete_message(?WS_OP_PING, Data) -> {ok, {ping, Data}}; -opcode_to_complete_message(?OP_PONG, <<>>) -> +opcode_to_complete_message(?WS_OP_PONG, <<>>) -> {ok, pong}; -opcode_to_complete_message(?OP_PONG, Data) -> +opcode_to_complete_message(?WS_OP_PONG, Data) -> {ok, {pong, Data}}; opcode_to_complete_message(Opcode, _) -> {error, {unknown_opcode, Opcode}}. @@ -441,14 +438,14 @@ validate_message(_Message) -> %% INTERNAL: ENCODE %%%----------------------------------------------------------------------------- -spec encode_for(ws_message(), fun((0..15, iodata()) -> iodata())) -> iodata(). -encode_for({text, Data}, F) -> F(?OP_TEXT, Data); -encode_for({binary, Data}, F) -> F(?OP_BINARY, Data); -encode_for(ping, F) -> F(?OP_PING, <<>>); -encode_for({ping, Data}, F) -> F(?OP_PING, Data); -encode_for(pong, F) -> F(?OP_PONG, <<>>); -encode_for({pong, Data}, F) -> F(?OP_PONG, Data); -encode_for(close, F) -> F(?OP_CLOSE, <<>>); -encode_for({close, Code, Reason}, F) -> F(?OP_CLOSE, <>). +encode_for({text, Data}, F) -> F(?WS_OP_TEXT, Data); +encode_for({binary, Data}, F) -> F(?WS_OP_BINARY, Data); +encode_for(ping, F) -> F(?WS_OP_PING, <<>>); +encode_for({ping, Data}, F) -> F(?WS_OP_PING, Data); +encode_for(pong, F) -> F(?WS_OP_PONG, <<>>); +encode_for({pong, Data}, F) -> F(?WS_OP_PONG, Data); +encode_for(close, F) -> F(?WS_OP_CLOSE, <<>>); +encode_for({close, Code, Reason}, F) -> F(?WS_OP_CLOSE, <>). -spec encode_frame(0..15, iodata()) -> iodata(). encode_frame(Opcode, Data) -> diff --git a/test/compliance/nhttp_h2_rfc9113_SUITE.erl b/test/compliance/nhttp_h2_rfc9113_SUITE.erl index 1d273fb..21d93a2 100644 --- a/test/compliance/nhttp_h2_rfc9113_SUITE.erl +++ b/test/compliance/nhttp_h2_rfc9113_SUITE.erl @@ -41,7 +41,12 @@ groups() -> server_accepts_preface_delivered_in_two_reads, server_rejects_a_preface_prefix_that_cannot_complete, client_preface_contains_magic_and_settings, - server_preface_is_settings_only + server_preface_is_settings_only, + server_rejects_a_second_preface, + client_rejects_a_preface_from_the_server, + server_requires_settings_after_the_magic, + server_rejects_settings_ack_as_first_frame, + client_requires_settings_as_first_server_frame ]}, {section_4_frames, [parallel], [ interleaved_frame_during_continuation_is_connection_error, @@ -97,7 +102,9 @@ groups() -> data_on_half_closed_remote_credits_the_connection, data_over_stream_window_credits_the_connection, content_length_mismatch_credits_the_connection, - goaway_transitions_to_closing + goaway_transitions_to_closing, + peer_without_max_concurrent_streams_has_no_limit, + peer_settings_report_rfc_initial_values ]}, {section_8_http_semantics, [parallel], [ request_missing_method_is_stream_error, @@ -202,6 +209,51 @@ server_preface_is_settings_only(_Config) -> {ok, {settings, _}, _} = nhttp_h2_frame:decode(Preface), ok. +%% RFC9113-3.4-1: the magic opens the client preface once. A second copy is +%% an invalid preface, a connection error. +server_rejects_a_second_preface(_Config) -> + Conn0 = nhttp_h2:new(server), + {ok, [{settings, #{}}], Conn1, _Ack} = + nhttp_h2:recv(Conn0, <<(literal_magic())/binary, (literal_settings())/binary>>), + {error, {connection_error, _, _}} = nhttp_h2:recv(Conn1, literal_magic()), + ok. + +%% RFC9113-3.4-2: the server preface is a SETTINGS frame and carries no +%% magic. The magic from a server is an invalid preface, at the start of the +%% connection and after it. +client_rejects_a_preface_from_the_server(_Config) -> + Conn0 = nhttp_h2:new(client), + {error, {connection_error, _, _}} = nhttp_h2:recv(Conn0, literal_magic()), + {ok, [{settings, #{}}], Conn1, _Ack} = nhttp_h2:recv(Conn0, literal_settings()), + {error, {connection_error, _, _}} = nhttp_h2:recv(Conn1, literal_magic()), + ok. + +%% RFC9113-3.4-3: the magic MUST be followed by a SETTINGS frame. Any other +%% frame is an invalid preface, a PROTOCOL_ERROR connection error. +server_requires_settings_after_the_magic(_Config) -> + Conn0 = nhttp_h2:new(server), + {error, {connection_error, protocol_error, _}} = + nhttp_h2:recv(Conn0, <<(literal_magic())/binary, (literal_ping())/binary>>), + {ok, [], Conn1} = nhttp_h2:recv(Conn0, literal_magic()), + {error, {connection_error, protocol_error, _}} = nhttp_h2:recv(Conn1, literal_ping()), + ok. + +%% RFC9113-3.4-4: a SETTINGS frame with ACK acknowledges the SETTINGS of this +%% endpoint and cannot open the peer preface. +server_rejects_settings_ack_as_first_frame(_Config) -> + Conn0 = nhttp_h2:new(server), + {error, {connection_error, protocol_error, _}} = + nhttp_h2:recv(Conn0, <<(literal_magic())/binary, (literal_settings_ack())/binary>>), + ok. + +%% RFC9113-3.4-5: the SETTINGS frame MUST be the first frame the server +%% sends. A PING first is an invalid preface. +client_requires_settings_as_first_server_frame(_Config) -> + Conn0 = nhttp_h2:new(client), + {error, {connection_error, protocol_error, _}} = nhttp_h2:recv(Conn0, literal_ping()), + {error, {connection_error, protocol_error, _}} = nhttp_h2:recv(Conn0, literal_settings_ack()), + ok. + %%%----------------------------------------------------------------------------- %%% Section 4 - HTTP Frames %%%----------------------------------------------------------------------------- @@ -235,7 +287,7 @@ data_on_idle_stream_is_connection_error(_Config) -> %% A stream id at or above the next one the client opens, or an even one, %% names a stream that never existed. client_data_on_idle_stream_is_connection_error(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), fin), {ok, Even} = nhttp_h2_frame:data(2, fin, <<"x">>), @@ -249,7 +301,7 @@ client_data_on_idle_stream_is_connection_error(_Config) -> %% connection window. The codec returns that credit, answers with no %% RST_STREAM, and the connection stays up. data_after_rst_stream_is_discarded_and_credited(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), fin), {ok, Conn3, _} = nhttp_h2:send_rst_stream(Conn2, StreamId, cancel), @@ -289,7 +341,7 @@ window_update_on_idle_stream_is_connection_error(_Config) -> ok. client_rejects_window_update_on_idle_stream(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, WuEven} = nhttp_h2_frame:window_update(2, 100), {error, {connection_error, protocol_error, _}} = nhttp_h2:recv(Conn0, iolist_to_binary(WuEven)), @@ -393,7 +445,7 @@ data_on_closed_stream_is_stream_closed(_Config) -> %% The client role twin of data_after_peer_rst_stream_is_stream_closed. client_data_after_peer_rst_stream_is_stream_closed(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), fin), {ok, Rst} = nhttp_h2_frame:rst_stream(StreamId, cancel), @@ -673,7 +725,7 @@ settings_initial_window_size_reaches_idle_stream(_Config) -> ok. settings_initial_window_size_growth_drains_queued_data(_Config) -> - Conn0 = nhttp_h2:new(client, #{send_queue => true}), + Conn0 = client_with_server_preface(#{send_queue => true}), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), {ok, Wu} = nhttp_h2_frame:window_update(100000), @@ -740,7 +792,7 @@ settings_initial_window_size_emits_no_window_update_event(_Config) -> ok. zero_length_end_stream_data_is_sent_at_zero_window(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), Conn3 = send_chunks(Conn2, StreamId, [16384, 16384, 16384, 16383]), @@ -868,7 +920,7 @@ padded_data_credit_matches_every_pad_length(_Config) -> %% connection window. DATA on a reset stream that exceeds what is left of %% the window is a FLOW_CONTROL_ERROR, not a frame to ignore. discarded_data_counts_against_the_window(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, Open, Conn1} = nhttp_h2:open_stream(Conn0), {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, Open, minimal_request_headers(), fin), {ok, Reset, Conn3} = nhttp_h2:open_stream(Conn2), @@ -937,6 +989,43 @@ goaway_transitions_to_closing(_Config) -> nhttp_h2:recv(Conn0, iolist_to_binary(Goaway)), ok. +%% RFC9113-6.5.2-1: SETTINGS_MAX_CONCURRENT_STREAMS has no limit until the +%% peer sends it. An empty SETTINGS frame leaves it unset. +peer_without_max_concurrent_streams_has_no_limit(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, [{settings, #{}}], Conn1, _Ack} = nhttp_h2:recv(Conn0, literal_settings()), + Conn2 = open_request_streams(Conn1, 101), + {ok, _StreamId, _Conn3} = nhttp_h2:open_stream(Conn2), + ok. + +%% RFC9113-6.5.2-2: every parameter the peer did not send holds its initial +%% value: 4096, 65535 and 16384 octets, and no limit on the concurrent +%% streams or on the field section. +peer_settings_report_rfc_initial_values(_Config) -> + Initial = #{ + header_table_size => 4096, + initial_window_size => 65535, + max_frame_size => 16384, + max_concurrent_streams => infinity, + max_header_list_size => infinity + }, + Conn0 = nhttp_h2:new(client), + ?assertEqual(Initial, nhttp_h2:peer_settings(Conn0)), + {ok, [{settings, #{}}], Conn1, _Ack} = nhttp_h2:recv(Conn0, literal_settings()), + ?assertEqual(Initial, nhttp_h2:peer_settings(Conn1)), + Server0 = nhttp_h2:new(server), + {ok, [{settings, #{}}], Server1, _} = + nhttp_h2:recv(Server0, <<(literal_magic())/binary, (literal_settings())/binary>>), + ?assertEqual(Initial, nhttp_h2:peer_settings(Server1)), + ok. + +open_request_streams(Conn, 0) -> + Conn; +open_request_streams(Conn0, N) -> + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), + open_request_streams(Conn2, N - 1). + %%%----------------------------------------------------------------------------- %%% Section 8 - HTTP Message Exchanges %%%----------------------------------------------------------------------------- @@ -1416,22 +1505,40 @@ decode_data_frames(Bin, MaxFrameSize) -> server_with_preface() -> Conn0 = nhttp_h2:new(server), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), - Conn1. + server_with_preface(Conn0). server_with_preface_settings(Settings) -> Conn0 = nhttp_h2:new(server, maps:merge(#{max_concurrent_streams => 100}, Settings)), + server_with_preface(Conn0). + +server_with_preface(Conn0) -> {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + {ok, Settings} = nhttp_h2_frame:settings(#{}), + {ok, [{settings, #{}}], Conn1, _Ack} = + nhttp_h2:recv(Conn0, iolist_to_binary([Preface, Settings])), Conn1. client_with_server_preface() -> - Conn0 = nhttp_h2:new(client), + client_with_server_preface(#{}). + +client_with_server_preface(Settings) -> + Conn0 = nhttp_h2:new(client, Settings), {ok, S} = nhttp_h2_frame:settings(#{}), {ok, _, Conn1, _} = nhttp_h2:recv(Conn0, iolist_to_binary(S)), Conn1. +literal_magic() -> + <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>. + +literal_settings() -> + <<0:24, 16#04:8, 16#00:8, 0:32>>. + +literal_settings_ack() -> + <<0:24, 16#04:8, 16#01:8, 0:32>>. + +literal_ping() -> + <<8:24, 16#06:8, 16#00:8, 0:32, "pingpong">>. + encode_headers(Headers) -> {ok, State} = nhttp_hpack:new(), {HeaderBlock, _} = encode_headers(Headers, State), diff --git a/test/nhttp_h2_SUITE.erl b/test/nhttp_h2_SUITE.erl index 56ffd19..f4a7850 100644 --- a/test/nhttp_h2_SUITE.erl +++ b/test/nhttp_h2_SUITE.erl @@ -479,7 +479,7 @@ stream_open_to_half_closed_remote(_Config) -> ok. stream_half_closed_to_closed(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), Headers = [{<<":method">>, <<"GET">>}, {<<":path">>, <<"/">>}], {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, Headers, fin), @@ -634,7 +634,7 @@ stream_window_update(_Config) -> ok. window_overflow_error(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), NearMaxIncrement = 16#7fffffff - 65535 - 1000, {ok, Frame1} = nhttp_h2_frame:window_update(NearMaxIncrement), {ok, [{window_update, 0, NearMaxIncrement}], Conn1} = nhttp_h2:recv( @@ -710,7 +710,7 @@ headers_decode_with_hpack(_Config) -> %%%----------------------------------------------------------------------------- goaway_closes_connection(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, GoawayFrame} = nhttp_h2_frame:goaway(0, no_error, <<>>), {ok, Events, Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(GoawayFrame)), ?assertMatch([{goaway, 0, no_error, <<>>}], Events), @@ -733,7 +733,7 @@ rst_stream_closes_stream(_Config) -> %%%----------------------------------------------------------------------------- full_request_response_cycle(_Config) -> - ClientConn0 = nhttp_h2:new(client), + ClientConn0 = client_with_server_preface(), ServerConn0 = nhttp_h2:new(server), {ok, StreamId, ClientConn1} = nhttp_h2:open_stream(ClientConn0), @@ -745,12 +745,11 @@ full_request_response_cycle(_Config) -> ], {ok, ClientConn2, ReqFrame} = nhttp_h2:send_headers(ClientConn1, StreamId, ReqHeaders, fin), - {ok, ClientPrefaceData} = nhttp_h2_frame:preface(), - ClientPreface = iolist_to_binary(ClientPrefaceData), - {ok, ReqEvents, ServerConn1} = nhttp_h2:recv( + ClientPreface = iolist_to_binary(nhttp_h2:preface(ClientConn0)), + {ok, ReqEvents, ServerConn1, _Ack} = nhttp_h2:recv( ServerConn0, <> ), - ?assertMatch([{request, StreamId, _, fin}], ReqEvents), + ?assertMatch([{settings, _}, {request, StreamId, _, fin}], ReqEvents), RespHeaders = [{<<":status">>, <<"200">>}], {ok, ServerConn2, RespFrame} = nhttp_h2:send_headers(ServerConn1, StreamId, RespHeaders, fin), @@ -818,7 +817,7 @@ send_headers_after_goaway(_Config) -> ok. send_headers_on_closed_stream(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), Headers = [{<<":method">>, <<"GET">>}, {<<":path">>, <<"/">>}], {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, Headers, fin), @@ -1118,7 +1117,9 @@ queue_stream_bound_refuses_without_side_effects(_Config) -> -doc "Without the key the stream bound is a quarter of the peer max_concurrent_streams, and infinity lifts it.". queue_stream_bound_defaults_to_a_quarter_of_peer_max(_Config) -> - {Conn0, [First | Rest]} = open_post_streams(queued_conn(#{}), 26), + {Conn0, [First | Rest]} = open_post_streams( + client_with_server_preface(#{send_queue => true}, #{max_concurrent_streams => 100}), 26 + ), {queued, Conn1, _, 4465} = nhttp_h2:send_data(Conn0, First, numbered_body(70000), nofin), {Accepted, [Last]} = lists:split(24, Rest), Conn2 = queue_one_octet_each(Conn1, Accepted), @@ -1399,10 +1400,10 @@ queue_off_keeps_partial_at_zero_window(_Config) -> ok. queued_conn(Settings) -> - nhttp_h2:new(client, Settings#{send_queue => true}). + client_with_server_preface(Settings#{send_queue => true}). open_post_stream() -> - open_post_stream(nhttp_h2:new(client)). + open_post_stream(client_with_server_preface()). open_post_stream(Conn0) -> {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), @@ -1496,14 +1497,14 @@ open_stream_after_goaway_sent(_Config) -> ok. open_stream_after_goaway_received(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, GoawayFrame} = nhttp_h2_frame:goaway(0, no_error, <<>>), {ok, _, Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(GoawayFrame)), {error, connection_closing} = nhttp_h2:open_stream(Conn1), ok. ping_ack_received(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), OpaqueData = <<1, 2, 3, 4, 5, 6, 7, 8>>, {ok, PingAckFrame} = nhttp_h2_frame:ping_ack(OpaqueData), {ok, Events, _Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(PingAckFrame)), @@ -1522,7 +1523,7 @@ priority_frame_ignored(_Config) -> ok. unknown_frame_ignored(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), UnknownFrame = <<5:24, 99:8, 0:8, 0:32, "hello">>, {ok, Events, _Conn1} = nhttp_h2:recv(Conn0, UnknownFrame), ?assertEqual([], Events), @@ -1565,8 +1566,7 @@ continuation_flood_protection_test(_Config) -> continuation_exceeds_header_list_size_test(_Config) -> Settings = #{max_header_list_size => 200}, Conn0 = nhttp_h2:new(server, Settings), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), First = binary:copy(<<"a">>, 100), {ok, HeadersFrame} = nhttp_h2_frame:headers(1, fin, nofin, First), {ok, [], Conn2} = nhttp_h2:recv(Conn1, iolist_to_binary(HeadersFrame)), @@ -1600,8 +1600,7 @@ continuation_count_infinity_allows_flood_test(_Config) -> oversized_initial_headers_test(_Config) -> Settings = #{max_header_list_size => 100}, Conn0 = nhttp_h2:new(server, Settings), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), Block = binary:copy(<<"x">>, 200), {ok, HeadersFrame} = nhttp_h2_frame:headers(1, fin, nofin, Block), {error, {connection_error, enhance_your_calm, _}} = @@ -1611,8 +1610,7 @@ oversized_initial_headers_test(_Config) -> decoded_header_list_too_large_test(_Config) -> Settings = #{max_header_list_size => 200}, Conn0 = nhttp_h2:new(server, Settings), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), StaticIdx = list_to_binary( lists:duplicate(20, 16#82) ++ lists:duplicate(20, 16#84) @@ -1657,7 +1655,7 @@ stream_window_overflow(_Config) -> ok. push_promise_rejected(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), HeaderBlock = encode_headers(minimal_request_headers()), {ok, PushFrame} = nhttp_h2_frame:push_promise(1, 2, fin, HeaderBlock), {ok, [], _Conn1, RstFrame} = nhttp_h2:recv(Conn0, iolist_to_binary(PushFrame)), @@ -1682,7 +1680,7 @@ ping_received_test(_Config) -> ok. settings_ack_received(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, SettingsAckFrame} = nhttp_h2_frame:settings_ack(), {ok, Events, Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(SettingsAckFrame)), ?assertEqual([settings_ack], Events), @@ -1757,8 +1755,7 @@ close_stream_nonexistent(_Config) -> -spec server_with_preface() -> nhttp_h2:conn(). server_with_preface() -> Conn0 = nhttp_h2:new(server), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), Conn1. -doc "Encode headers using HPACK (for test data generation).". @@ -1997,8 +1994,7 @@ multiple_host_test(_Config) -> extended_connect_accepted_test(_Config) -> Conn0 = nhttp_h2:new(server, #{enable_connect_protocol => true}), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), HeaderBlock = encode_headers([ {<<":method">>, <<"CONNECT">>}, {<<":scheme">>, <<"https">>}, @@ -2029,8 +2025,7 @@ extended_connect_not_enabled_test(_Config) -> protocol_without_connect_test(_Config) -> Conn0 = nhttp_h2:new(server, #{enable_connect_protocol => true}), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), HeaderBlock = encode_headers([ {<<":method">>, <<"GET">>}, {<<":scheme">>, <<"https">>}, @@ -2045,8 +2040,7 @@ protocol_without_connect_test(_Config) -> extended_connect_missing_authority_test(_Config) -> Conn0 = nhttp_h2:new(server, #{enable_connect_protocol => true}), - {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + Conn1 = server_with_preface(Conn0), HeaderBlock = encode_headers([ {<<":method">>, <<"CONNECT">>}, {<<":scheme">>, <<"https">>}, @@ -2148,7 +2142,7 @@ uppercase_header_name_test(_Config) -> ok. client_receives_even_stream_id_test(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), RespHeaders = encode_headers([{<<":status">>, <<"200">>}]), {ok, HeadersFrame} = nhttp_h2_frame:headers(1, fin, fin, RespHeaders), {error, {connection_error, protocol_error, _}} = @@ -2166,7 +2160,7 @@ window_update_on_closed_stream_test(_Config) -> ok. response_missing_status_test(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), ReqHeaders = [{<<":method">>, <<"GET">>}, {<<":path">>, <<"/">>}], {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, ReqHeaders, fin), @@ -2185,7 +2179,7 @@ response_missing_status_test(_Config) -> -spec recv_client_response_status(binary()) -> {ok, [term()], term(), iodata()}. recv_client_response_status(StatusValue) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), ReqHeaders = [{<<":method">>, <<"GET">>}, {<<":path">>, <<"/">>}], {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, ReqHeaders, fin), @@ -2220,7 +2214,7 @@ response_status_out_of_class_test(_Config) -> ). response_bad_status_connection_survives_test(_Config) -> - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, Stream1, Conn1} = nhttp_h2:open_stream(Conn0), ReqHeaders = [{<<":method">>, <<"GET">>}, {<<":path">>, <<"/">>}], {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, Stream1, ReqHeaders, fin), @@ -2412,10 +2406,27 @@ local_reset_not_counted(_Config) -> ), ok. +-spec client_with_server_preface() -> nhttp_h2:conn(). +client_with_server_preface() -> + client_with_server_preface(#{}). + +-spec client_with_server_preface(nhttp_h2:settings()) -> nhttp_h2:conn(). +client_with_server_preface(Settings) -> + client_with_server_preface(Settings, #{}). + +-spec client_with_server_preface(nhttp_h2:settings(), nhttp_h2:settings()) -> nhttp_h2:conn(). +client_with_server_preface(Settings, PeerSettings) -> + {ok, Frame} = nhttp_h2_frame:settings(PeerSettings), + {ok, [{settings, PeerSettings}], Conn, _Ack} = + nhttp_h2:recv(nhttp_h2:new(client, Settings), iolist_to_binary(Frame)), + Conn. + -spec server_with_preface(nhttp_h2:conn()) -> nhttp_h2:conn(). server_with_preface(Conn0) -> {ok, Preface} = nhttp_h2_frame:preface(), - {ok, [], Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(Preface)), + {ok, Settings} = nhttp_h2_frame:settings(#{}), + {ok, [{settings, #{}}], Conn1, _Ack} = + nhttp_h2:recv(Conn0, iolist_to_binary([Preface, Settings])), Conn1. -spec reset_h2_rounds(nhttp_h2:conn(), nhttp_lib:stream_id(), non_neg_integer()) -> diff --git a/test/nhttp_props_SUITE.erl b/test/nhttp_props_SUITE.erl index a817864..cc77647 100644 --- a/test/nhttp_props_SUITE.erl +++ b/test/nhttp_props_SUITE.erl @@ -119,7 +119,9 @@ groups() -> h2_send_data_conserves_body, h2_send_queue_round_robin, h2_send_queue_conserves_offers, - h2_send_queue_respects_credit + h2_send_queue_respects_credit, + h2_preface_split_reaches_open, + h2_preface_wrong_octet_is_protocol_error ]}, {qpack_props, [parallel], [ qpack_roundtrip_static, @@ -442,6 +444,12 @@ h2_send_queue_conserves_offers(Config) -> h2_send_queue_respects_credit(Config) -> run_property(nhttp_h2_props, prop_send_queue_respects_credit, Config). +h2_preface_split_reaches_open(Config) -> + run_property(nhttp_h2_props, prop_preface_split_reaches_open, Config). + +h2_preface_wrong_octet_is_protocol_error(Config) -> + run_property(nhttp_h2_props, prop_preface_wrong_octet_is_protocol_error, Config). + %%%----------------------------------------------------------------------------- %%% QPACK PROPERTY TESTS %%%----------------------------------------------------------------------------- diff --git a/test/property_test/nhttp_h2_props.erl b/test/property_test/nhttp_h2_props.erl index 344da6a..edd4841 100644 --- a/test/property_test/nhttp_h2_props.erl +++ b/test/property_test/nhttp_h2_props.erl @@ -19,9 +19,8 @@ prop_headers_roundtrip() -> {ok, StreamId, ClientConn1} = nhttp_h2:open_stream(ClientConn0), {ok, _ClientConn2, Frame} = nhttp_h2:send_headers(ClientConn1, StreamId, Headers, fin), - ServerConn0 = nhttp_h2:new(server), - {ok, ClientPreface} = nhttp_h2_frame:preface(), - Data = <>, + ServerConn0 = server_with_preface(), + Data = iolist_to_binary(Frame), case nhttp_h2:recv(ServerConn0, Data) of {ok, [{request, StreamId, Request, fin}], _ServerConn1} -> nhttp_props_helpers:request_matches(Headers, Request); @@ -46,9 +45,8 @@ prop_data_roundtrip() -> ClientConn1, StreamId, Headers, nofin ), {ok, _ClientConn3, DataFrames} = nhttp_h2:send_data(ClientConn2, StreamId, Data, fin), - ServerConn0 = nhttp_h2:new(server), - {ok, ClientPreface} = nhttp_h2_frame:preface(), - HeaderData = <>, + ServerConn0 = server_with_preface(), + HeaderData = iolist_to_binary(HeaderFrame), {ok, _, ServerConn1} = nhttp_h2:recv(ServerConn0, HeaderData), case nhttp_h2:recv(ServerConn1, iolist_to_binary(DataFrames)) of {ok, Events, _} -> @@ -75,15 +73,14 @@ prop_request_response_roundtrip() -> {ReqHeaders, RespHeaders}, {request_headers_gen(), response_headers_gen()}, begin - ClientConn0 = nhttp_h2:new(client), + ClientConn0 = client_with_server_preface(), {ok, StreamId, ClientConn1} = nhttp_h2:open_stream(ClientConn0), {ok, ClientConn2, ReqFrame} = nhttp_h2:send_headers( ClientConn1, StreamId, ReqHeaders, fin ), - ServerConn0 = nhttp_h2:new(server), - {ok, ClientPreface} = nhttp_h2_frame:preface(), - ReqData = <>, + ServerConn0 = server_with_preface(), + ReqData = iolist_to_binary(ReqFrame), case nhttp_h2:recv(ServerConn0, ReqData) of {ok, [{request, StreamId, Request, fin}], ServerConn1} -> {ok, _ServerConn2, RespFrame} = nhttp_h2:send_headers( @@ -157,7 +154,7 @@ prop_flow_control_window() -> Increment, int(1, 1000000), begin - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, Frame} = nhttp_h2_frame:window_update(Increment), case nhttp_h2:recv(Conn0, iolist_to_binary(Frame)) of {ok, [{window_update, 0, Increment}], Conn1} -> @@ -268,9 +265,8 @@ prop_continuation_sequence() -> HeaderBlock = iolist_to_binary(Encoded), StreamId = 1, Frames = split_into_headers_continuation(HeaderBlock, StreamId, NumChunks), - ServerConn0 = nhttp_h2:new(server), - {ok, ClientPreface} = nhttp_h2_frame:preface(), - Data = <>, + ServerConn0 = server_with_preface(), + Data = iolist_to_binary(Frames), case nhttp_h2:recv(ServerConn0, Data) of {ok, Events, _ServerConn1} -> %% Either a single request event reassembled, or 0 events (more bytes needed). @@ -462,6 +458,78 @@ data_gen() -> binary(Size) ). +-spec prop_preface_split_reaches_open() -> triq:property(). +prop_preface_split_reaches_open() -> + ?FORALL( + Settings, + settings_gen(), + begin + {ok, Frame} = nhttp_h2_frame:settings(Settings), + Preface = <<(literal_magic())/binary, (iolist_to_binary(Frame))/binary>>, + lists:all( + fun(At) -> preface_split_opens(Preface, At) end, + lists:seq(0, byte_size(Preface)) + ) + end + ). + +-spec preface_split_opens(binary(), non_neg_integer()) -> boolean(). +preface_split_opens(Preface, At) -> + <> = Preface, + SettingsAck = <<0:24, 16#04:8, 16#01:8, 0:32>>, + case recv_chunks(nhttp_h2:new(server), [Head, Tail], [], []) of + {ok, [{settings, _}], Conn, SettingsAck} -> answers_ping(Conn); + _ -> false + end. + +-spec answers_ping(nhttp_h2:conn()) -> boolean(). +answers_ping(Conn) -> + Ping = <<8:24, 16#06:8, 16#00:8, 0:32, "pingpong">>, + Pong = <<8:24, 16#06:8, 16#01:8, 0:32, "pingpong">>, + case recv_chunks(Conn, [Ping], [], []) of + {ok, [{ping, <<"pingpong">>}], _, Pong} -> true; + _ -> false + end. + +-spec prop_preface_wrong_octet_is_protocol_error() -> triq:property(). +prop_preface_wrong_octet_is_protocol_error() -> + ?FORALL( + {Position, Delta}, + {int(0, 23), int(1, 255)}, + begin + <> = literal_magic(), + Wrong = <>, + Octets = [<> || <> <= Wrong], + {Before, [Bad | _]} = lists:split(Position, Octets), + case recv_chunks(nhttp_h2:new(server), Before, [], []) of + {ok, [], Conn, <<>>} -> + is_protocol_error(nhttp_h2:recv(Conn, Bad)) andalso + is_protocol_error(nhttp_h2:recv(nhttp_h2:new(server), Wrong)); + _ -> + false + end + end + ). + +-spec is_protocol_error(nhttp_h2:recv_result()) -> boolean(). +is_protocol_error({error, {connection_error, protocol_error, _}}) -> true; +is_protocol_error(_) -> false. + +-spec recv_chunks(nhttp_h2:conn(), [binary()], [nhttp_h2:event()], iodata()) -> + {ok, [nhttp_h2:event()], nhttp_h2:conn(), binary()} | {error, nhttp_h2_frame:decode_error()}. +recv_chunks(Conn, [], Events, Out) -> + {ok, Events, Conn, iolist_to_binary(Out)}; +recv_chunks(Conn0, [Chunk | Chunks], Events, Out) -> + case nhttp_h2:recv(Conn0, Chunk) of + {ok, New, Conn1} -> recv_chunks(Conn1, Chunks, Events ++ New, Out); + {ok, New, Conn1, More} -> recv_chunks(Conn1, Chunks, Events ++ New, [Out, More]); + {error, _} = Error -> Error + end. + +-spec literal_magic() -> binary(). +literal_magic() -> + <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>. + -spec settings_gen() -> triq_dom:domain(). settings_gen() -> ?LET( @@ -485,7 +553,7 @@ prop_send_within_credit() -> Ops, list(flow_op_gen()), begin - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), Headers = [ {<<":method">>, <<"POST">>}, {<<":scheme">>, <<"https">>}, {<<":path">>, <<"/">>} @@ -547,7 +615,7 @@ prop_send_data_conserves_body() -> {{Parent, Body}, ConnCredit, StreamCredit}, {body_gen(), int(0, 100000), int(0, 100000)}, begin - Conn0 = nhttp_h2:new(client), + Conn0 = client_with_server_preface(), {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), Headers = [ {<<":method">>, <<"POST">>}, {<<":scheme">>, <<"https">>}, {<<":path">>, <<"/">>} @@ -733,9 +801,27 @@ remainder_retention(Rest, Parent, Body) -> own_size(Bin) -> binary:referenced_byte_size(binary:copy(Bin)). +-spec server_with_preface() -> nhttp_h2:conn(). +server_with_preface() -> + {ok, Settings} = nhttp_h2_frame:settings(#{}), + {ok, [{settings, #{}}], Conn, _Ack} = + nhttp_h2:recv(nhttp_h2:new(server), <<(literal_magic())/binary, (iolist_to_binary(Settings))/binary>>), + Conn. + +-spec client_with_server_preface() -> nhttp_h2:conn(). +client_with_server_preface() -> + client_with_server_preface(#{}). + +-spec client_with_server_preface(nhttp_h2:settings()) -> nhttp_h2:conn(). +client_with_server_preface(LocalSettings) -> + {ok, Settings} = nhttp_h2_frame:settings(#{}), + {ok, [{settings, #{}}], Conn, _Ack} = + nhttp_h2:recv(nhttp_h2:new(client, LocalSettings), iolist_to_binary(Settings)), + Conn. + -spec queued_conn() -> nhttp_h2:conn(). queued_conn() -> - nhttp_h2:new(client, #{ + client_with_server_preface(#{ send_queue => true, max_send_buffer => infinity, max_queued_streams => infinity }).