From 4fe75b864fdd3c3a5807d2af6fe3d89faf597c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Rubinos=20Rodr=C3=ADguez?= Date: Mon, 19 Oct 2026 10:53:39 +0200 Subject: [PATCH] fix(ws): validate close and control frames on the session API. remove the direct socket session. --- CHANGELOG.md | 29 ++++++++ src/nhttp_lib.app.src | 2 +- src/nhttp_ws.erl | 132 +++++++++++++++++----------------- src/nhttp_ws_frame.erl | 63 ++++++++++++---- test/nhttp_ws_SUITE.erl | 107 ++++++++++++++------------- test/nhttp_ws_frame_SUITE.erl | 58 +++++++++++++++ 6 files changed, 257 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a49e3..5ea8b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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). +## [2.0.0] + +### Added + +- `nhttp_ws_frame:validate_close/2` and `validate_message/1`: the RFC 6455 + §7.4 and §5.5 checks for a CLOSE code and reason and for a control + frame payload. The decoder and the session API share them. +- `nhttp_ws:send_error/0` and `nhttp_ws:close_error/0`. + +### Changed + +- `nhttp_ws:send/2,3` and `send_async/2` refuse CLOSE frames with + `{error, close_not_allowed}` and oversize PING or PONG payloads with + `{error, control_frame_too_large}`. +- `nhttp_ws:close/3,4` return `{error, close_error()}` for a code an + endpoint must not send, a reason over 123 bytes, or a reason that is + not UTF-8. +- `nhttp_ws:ws_close_opts/0` takes no keys. `force` and `timeout` were + never read. +- `nhttp_ws:send_async/2` documents what it does: the connection process + writes and replies. No path returned `would_block`. +- Every `exit` of the session call other than a timeout maps to + `{error, gone}`. + +### Removed + +- `nhttp_ws:new_session/5` and the direct socket write. A frame written + from the caller can follow the CLOSE the connection sent (§5.5.1). + ## [1.3.1] ### Fixed diff --git a/src/nhttp_lib.app.src b/src/nhttp_lib.app.src index cb00bc7..3e629cc 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.3.1"}, + {vsn, "2.0.0"}, {registered, []}, {applications, [ kernel, diff --git a/src/nhttp_ws.erl b/src/nhttp_ws.erl index a8eb9e6..62bbad6 100644 --- a/src/nhttp_ws.erl +++ b/src/nhttp_ws.erl @@ -86,7 +86,6 @@ for backwards compatibility. is_alive/1, new_session/3, new_session/4, - new_session/5, owner/1, session_ref/1, stream_id/1, @@ -116,9 +115,11 @@ for backwards compatibility. %%%----------------------------------------------------------------------------- -export_type([ close_code/0, + close_error/0, decode_error/0, decode_result/0, feed_result/0, + send_error/0, session/0, stateful_decode_result/0, ws_close_opts/0, @@ -133,6 +134,8 @@ for backwards compatibility. -type close_code() :: nhttp_ws_frame:close_code(). +-type close_error() :: nhttp_ws_frame:close_error(). + -type decode_result() :: nhttp_ws_frame:decode_result(). -type ws_message() :: nhttp_ws_frame:ws_message(). @@ -167,6 +170,11 @@ for backwards compatibility. | {more, MinBytes :: pos_integer(), ws_decoder()} | {error, term()}. +-type send_error() :: gone | timeout | close_not_allowed | control_frame_too_large. + +-type send_request() :: + {ws_send | ws_send_async, reference(), undefined | nhttp_lib:stream_id(), ws_message()}. + -type ws_send_opts() :: #{ timeout => timeout(), priority => low | normal @@ -186,15 +194,14 @@ for backwards compatibility. extensions => [binary()] }. --type ws_close_opts() :: #{ - force => boolean(), - timeout => timeout() -}. +-type ws_close_opts() :: #{}. %%%----------------------------------------------------------------------------- %% LOCAL MACROS %%%----------------------------------------------------------------------------- -define(WS_MAGIC, <<"258EAFA5-E914-47DA-95CA-C5AB0DC85B11">>). +-define(CLOSE_NORMAL, 1000). +-define(SEND_TIMEOUT_MS, 5000). -define(WS_DEFAULT_MAX_MESSAGE_SIZE, 16777216). -define(WS_MAX_HEADER_SIZE, 14). -define(OP_CONTINUATION, 0). @@ -212,8 +219,7 @@ for backwards compatibility. transport :: h1 | h2 | h3, conn_pid :: pid(), stream_id :: undefined | nhttp_lib:stream_id(), - ref :: reference(), - socket :: nhttp_sock:t() | undefined + ref :: reference() }). %%%----------------------------------------------------------------------------- @@ -560,17 +566,6 @@ new_session(Transport, ConnPid, StreamId, Ref) when transport = Transport, conn_pid = ConnPid, stream_id = StreamId, ref = Ref }. --doc """ -Same as `new_session/4` for HTTP/1.1. Sends write to `Socket` from the caller. -""". --spec new_session(h1, pid(), undefined, reference(), nhttp_sock:t()) -> session(). -new_session(h1, ConnPid, undefined, Ref, {Kind, _} = Socket) when - is_pid(ConnPid), is_reference(Ref), (Kind =:= tcp orelse Kind =:= ssl) --> - #nhttp_ws_session{ - transport = h1, conn_pid = ConnPid, stream_id = undefined, ref = Ref, socket = Socket - }. - -doc "Return the connection process pid for the session.". -spec owner(session()) -> pid(). owner(#nhttp_ws_session{conn_pid = Pid}) -> @@ -616,22 +611,27 @@ broadcast(Pid, Msg) when is_pid(Pid) -> -doc "Initiate the §5.5.1 close handshake with status 1000 and no reason.". -spec close(session()) -> ok. close(Session) -> - close(Session, 1000, <<>>, #{}). + cast_close(Session, ?CLOSE_NORMAL, <<>>, #{}). -doc "Initiate the §5.5.1 close handshake with explicit code and reason.". --spec close(session(), close_code(), binary()) -> ok. +-spec close(session(), close_code(), binary()) -> ok | {error, close_error()}. close(Session, Code, Reason) -> close(Session, Code, Reason, #{}). -doc """ -Initiate the §5.5.1 close handshake. Pending sends flush before the -reciprocal CLOSE is written unless `force => true` is set in `Opts`. +Initiate the §5.5.1 close handshake. The code and reason pass +`nhttp_ws_frame:validate_close/2` before the connection process hears of +them. The connection writes the CLOSE frame after the frames it already +holds for the session, and no frame follows it. `Opts` takes no keys. """. --spec close(session(), close_code(), binary(), ws_close_opts()) -> ok. -close(#nhttp_ws_session{conn_pid = Pid, stream_id = StreamId, ref = Ref}, Code, Reason, Opts) when - is_integer(Code), Code >= 0, Code =< 65535, is_binary(Reason), is_map(Opts) +-spec close(session(), close_code(), binary(), ws_close_opts()) -> ok | {error, close_error()}. +close(#nhttp_ws_session{} = Session, Code, Reason, Opts) when + is_integer(Code), is_binary(Reason), is_map(Opts) -> - gen_server:cast(Pid, {ws_close, Ref, StreamId, Code, Reason, Opts}). + case nhttp_ws_frame:validate_close(Code, Reason) of + ok -> cast_close(Session, Code, Reason, Opts); + {error, _} = Error -> Error + end. -doc """ Deliver an arbitrary Erlang term to a specific session's `handle_ws_info/3` @@ -643,50 +643,46 @@ info(#nhttp_ws_session{conn_pid = Pid, stream_id = StreamId}, Msg) -> gen_server:cast(Pid, {ws_info, StreamId, Msg}). -doc "Send an empty WebSocket PING (§5.5.2) on the session.". --spec ping(session()) -> ok | {error, term()}. +-spec ping(session()) -> ok | {error, send_error()}. ping(Session) -> send(Session, ping). --doc "Send a WebSocket PING with the given payload (≤ 125 bytes per §5.5).". --spec ping(session(), binary()) -> ok | {error, term()}. -ping(Session, Payload) when is_binary(Payload), byte_size(Payload) =< 125 -> +-doc "Send a WebSocket PING with a payload of at most 125 bytes (§5.5).". +-spec ping(session(), binary()) -> ok | {error, send_error()}. +ping(Session, Payload) when is_binary(Payload) -> send(Session, {ping, Payload}). -doc """ -Send a message and wait for the write. On a `new_session/5` session the -caller writes to the socket and `Opts` is ignored. Otherwise the connection -process writes it within `Opts#timeout` ms. Returns `{error, gone}` once the -session is closed. +Send a message and wait for the write. The connection process writes it +within the `timeout` of `Opts` (default 5000 ms) and replies. A CLOSE is +refused with `close_not_allowed`: `close/1,3,4` is the close path. A PING +or PONG payload over 125 bytes is refused with `control_frame_too_large`. +Returns `{error, gone}` once the session is closed. """. --spec send(session(), ws_message()) -> ok | {error, term()}. +-spec send(session(), ws_message()) -> ok | {error, send_error()}. send(Session, Msg) -> send(Session, Msg, #{}). --spec send(session(), ws_message(), ws_send_opts()) -> ok | {error, term()}. -send( - #nhttp_ws_session{socket = undefined, conn_pid = Pid, stream_id = StreamId, ref = Ref}, - Msg, - Opts -) when +-spec send(session(), ws_message(), ws_send_opts()) -> ok | {error, send_error()}. +send(#nhttp_ws_session{conn_pid = Pid, stream_id = StreamId, ref = Ref}, Msg, Opts) when is_map(Opts) -> - Timeout = maps:get(timeout, Opts, 5000), - call_conn(Pid, {ws_send, Ref, StreamId, Msg}, Timeout); -send(#nhttp_ws_session{socket = Socket}, Msg, Opts) when is_map(Opts) -> - send_direct(Socket, Msg). + maybe + ok ?= check_send(Msg), + Timeout = maps:get(timeout, Opts, ?SEND_TIMEOUT_MS), + call_conn(Pid, {ws_send, Ref, StreamId, Msg}, Timeout) + end. -doc """ -Enqueue a message on the connection process, or `{error, would_block}` when -its outbox is full. On a `new_session/5` session it is a blocking socket -write from the caller and never returns `would_block`. +Same as `send/2` with the default timeout: the connection process writes +the message and replies. There is no outbox. """. --spec send_async(session(), ws_message()) -> ok | {error, would_block | gone | term()}. -send_async( - #nhttp_ws_session{socket = undefined, conn_pid = Pid, stream_id = StreamId, ref = Ref}, Msg -) -> - call_conn(Pid, {ws_send_async, Ref, StreamId, Msg}, 5000); -send_async(#nhttp_ws_session{socket = Socket}, Msg) -> - send_direct(Socket, Msg). +-spec send_async(session(), ws_message()) -> ok | {error, send_error()}. +send_async(#nhttp_ws_session{conn_pid = Pid, stream_id = StreamId, ref = Ref}, Msg) -> + maybe + ok ?= check_send(Msg), + call_conn(Pid, {ws_send_async, Ref, StreamId, Msg}, ?SEND_TIMEOUT_MS) + end. -doc "Send a binary message.". -spec send_binary(session(), iodata()) -> ok | {error, term()}. @@ -701,26 +697,28 @@ send_text(Session, Data) -> %%%----------------------------------------------------------------------------- %% INTERNAL FUNCTIONS %%%----------------------------------------------------------------------------- --spec call_conn(pid(), term(), timeout()) -> ok | {error, term()}. +-spec call_conn(pid(), send_request(), timeout()) -> ok | {error, send_error()}. call_conn(Pid, Request, Timeout) -> try gen_server:call(Pid, Request, Timeout) of Reply -> Reply catch - exit:{noproc, _} -> {error, gone}; - exit:{normal, _} -> {error, gone}; - exit:{shutdown, _} -> {error, gone}; exit:{timeout, _} -> {error, timeout}; - exit:{Reason, _} -> {error, Reason} + exit:{_Reason, _} -> {error, gone} end. --spec send_direct(nhttp_sock:t(), ws_message()) -> - ok | {error, gone | nhttp_sock:socket_error()}. -send_direct(Socket, Msg) -> - case nhttp_sock:send(Socket, nhttp_ws_frame:encode(Msg)) of +-spec cast_close(session(), close_code(), binary(), ws_close_opts()) -> ok. +cast_close(#nhttp_ws_session{conn_pid = Pid, stream_id = StreamId, ref = Ref}, Code, Reason, Opts) -> + gen_server:cast(Pid, {ws_close, Ref, StreamId, Code, Reason, Opts}). + +-spec check_send(ws_message()) -> ok | {error, close_not_allowed | control_frame_too_large}. +check_send(close) -> + {error, close_not_allowed}; +check_send({close, _Code, _Reason}) -> + {error, close_not_allowed}; +check_send(Msg) -> + case nhttp_ws_frame:validate_message(Msg) of ok -> ok; - {error, closed} -> {error, gone}; - {error, einval} -> {error, gone}; - {error, _} = Error -> Error + {error, control_frame_too_large} = Error -> Error end. -spec check_message_size(non_neg_integer(), ws_decoder()) -> diff --git a/src/nhttp_ws_frame.erl b/src/nhttp_ws_frame.erl index 23301d1..ab07027 100644 --- a/src/nhttp_ws_frame.erl +++ b/src/nhttp_ws_frame.erl @@ -59,17 +59,21 @@ code and reason. scan_utf8/1, scan_utf8/2, unmask_from/3, + validate_close/2, validate_control_frame/3, - validate_control_header/3 + validate_control_header/3, + validate_message/1 ]). -export_type([ close_code/0, + close_error/0, decode_result/0, encode_opts/0, frame_limits/0, frame_too_large/0, header_error/0, + message_error/0, raw_decode_result/0, ws_message/0, ws_opcode/0 @@ -80,6 +84,10 @@ code and reason. %%%----------------------------------------------------------------------------- -type close_code() :: 0..65535. +-type close_error() :: invalid_close_code | invalid_close_reason | close_reason_too_large. + +-type message_error() :: close_error() | control_frame_too_large. + -type ws_message() :: {text, binary()} | {binary, binary()} @@ -130,6 +138,7 @@ code and reason. %% MACROS %%%----------------------------------------------------------------------------- -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). @@ -275,14 +284,9 @@ opcode_to_complete_message(?OP_CLOSE, <<>>) -> opcode_to_complete_message(?OP_CLOSE, <<_:8>>) -> {error, invalid_close_payload}; opcode_to_complete_message(?OP_CLOSE, <>) -> - case is_valid_close_code(Code) of - false -> - {error, invalid_close_code}; - true -> - case is_valid_utf8(Reason) of - true -> {ok, {close, Code, Reason}}; - false -> {error, invalid_close_reason} - end + case validate_close(Code, Reason) of + ok -> {ok, {close, Code, Reason}}; + {error, _} = Error -> Error end; opcode_to_complete_message(?OP_PING, <<>>) -> {ok, ping}; @@ -374,6 +378,27 @@ scan_utf8(Carry, <>) -> {error, _} = Err -> Err end. +-doc """ +Check a CLOSE code and reason against RFC 6455 §7.4 and §5.5.1. The code +must be one an endpoint can send: 1000 to 1003, 1007 to 1014, or 3000 to +4999. The reason must be UTF-8 and fit a control frame beside the code. +The decoder and the session API share this check. +""". +-spec validate_close(close_code(), binary()) -> ok | {error, close_error()}. +validate_close(Code, _Reason) when + not (Code >= 1000 andalso Code =< 1003), + not (Code >= 1007 andalso Code =< 1014), + not (Code >= 3000 andalso Code =< 4999) +-> + {error, invalid_close_code}; +validate_close(_Code, Reason) when byte_size(Reason) > ?MAX_CLOSE_REASON -> + {error, close_reason_too_large}; +validate_close(_Code, Reason) -> + case is_valid_utf8(Reason) of + true -> ok; + false -> {error, invalid_close_reason} + end. + -doc """ Validate that a control frame is not fragmented and not too large (RFC 6455 §5.5: control frame payloads MUST be <= 125 bytes). @@ -398,6 +423,20 @@ validate_control_header(0, _Opcode, _Len) -> validate_control_header(1, _Opcode, _Len) -> ok. +-doc """ +Check a message an endpoint is about to send: a CLOSE passes +`validate_close/2`, a PING or PONG payload fits a control frame (§5.5). +""". +-spec validate_message(ws_message()) -> ok | {error, message_error()}. +validate_message({close, Code, Reason}) -> + validate_close(Code, Reason); +validate_message({ping, Data}) when byte_size(Data) > ?MAX_CONTROL_PAYLOAD -> + {error, control_frame_too_large}; +validate_message({pong, Data}) when byte_size(Data) > ?MAX_CONTROL_PAYLOAD -> + {error, control_frame_too_large}; +validate_message(_Message) -> + ok. + %%%----------------------------------------------------------------------------- %% INTERNAL: ENCODE %%%----------------------------------------------------------------------------- @@ -705,12 +744,6 @@ is_utf8_prefix(<>) when A >= 16#F0, A =< 16#F4, C >= 16#80, C =< 16#BF is_utf8_prefix(_) -> false. --spec is_valid_close_code(close_code()) -> boolean(). -is_valid_close_code(Code) when Code >= 1000, Code =< 1003 -> true; -is_valid_close_code(Code) when Code >= 1007, Code =< 1014 -> true; -is_valid_close_code(Code) when Code >= 3000, Code =< 4999 -> true; -is_valid_close_code(_) -> false. - -spec is_valid_utf8(binary()) -> boolean(). is_valid_utf8(<<_/utf8, Rest/binary>>) -> is_valid_utf8(Rest); is_valid_utf8(<<>>) -> true; diff --git a/test/nhttp_ws_SUITE.erl b/test/nhttp_ws_SUITE.erl index 1ad04f8..d056fe3 100644 --- a/test/nhttp_ws_SUITE.erl +++ b/test/nhttp_ws_SUITE.erl @@ -119,8 +119,9 @@ decoding, and stateful fragmentation for both server and client roles. session_broadcast_to_session/1, session_broadcast_to_pid/1, session_is_alive/1, - session_direct_send_from_non_owner/1, - session_direct_send_gone_after_close/1, + session_send_refuses_close/1, + session_send_refuses_oversize_control/1, + session_close_refuses_invalid/1, session_h1_without_socket_routes_to_conn/1, feed_empty_input/1, feed_header_split_at_every_boundary_unmasked/1, @@ -284,8 +285,9 @@ groups() -> session_broadcast_to_session, session_broadcast_to_pid, session_is_alive, - session_direct_send_from_non_owner, - session_direct_send_gone_after_close, + session_send_refuses_close, + session_send_refuses_oversize_control, + session_close_refuses_invalid, session_h1_without_socket_routes_to_conn ]}, {feed, [parallel], [ @@ -902,11 +904,11 @@ session_send_async_envelope(_Config) -> Probe = spawn_probe(fun() -> receive {'$gen_call', From, {ws_send_async, _SessRef, 5, {binary, <<"x">>}}} -> - gen_server:reply(From, {error, would_block}) + gen_server:reply(From, {error, gone}) end end), Sess = make_session(h2, Probe, 5), - ?assertEqual({error, would_block}, nhttp_ws:send_async(Sess, {binary, <<"x">>})), + ?assertEqual({error, gone}, nhttp_ws:send_async(Sess, {binary, <<"x">>})), stop_probe(Probe). session_send_gone_when_pid_dead(_Config) -> @@ -941,8 +943,8 @@ session_close_envelope(_Config) -> ?assertEqual({'$gen_cast', {ws_close, Ref, undefined, 1000, <<>>, #{}}}, await_msg(1000)), ?assertEqual(ok, nhttp_ws:close(Sess, 1011, <<"oops">>)), ?assertEqual({'$gen_cast', {ws_close, Ref, undefined, 1011, <<"oops">>, #{}}}, await_msg(1000)), - ?assertEqual(ok, nhttp_ws:close(Sess, 1001, <<"bye">>, #{force => true})), - Expected = {'$gen_cast', {ws_close, Ref, undefined, 1001, <<"bye">>, #{force => true}}}, + ?assertEqual(ok, nhttp_ws:close(Sess, 1001, <<"bye">>, #{})), + Expected = {'$gen_cast', {ws_close, Ref, undefined, 1001, <<"bye">>, #{}}}, ?assertEqual(Expected, await_msg(1000)). session_broadcast_to_session(_Config) -> @@ -964,32 +966,51 @@ session_is_alive(_Config) -> SessDead = make_session(h1, Probe, undefined), ?assertNot(nhttp_ws:is_alive(SessDead)). -session_direct_send_from_non_owner(_Config) -> - {Server, Client} = tcp_pair(), - Sess = nhttp_ws:new_session(h1, dead_pid(), undefined, make_ref(), {tcp, Server}), - ?assertEqual(h1, nhttp_ws:transport(Sess)), - ?assertEqual(undefined, nhttp_ws:stream_id(Sess)), - Owner = self(), - Sender = spawn_probe(fun() -> - Sent = nhttp_ws:send(Sess, {text, <<"hi">>}), - SentAsync = nhttp_ws:send_async(Sess, {binary, <<1, 2, 3>>}), - Owner ! {sent, Sent, SentAsync} +session_send_refuses_close(_Config) -> + Probe = spawn_probe(fun() -> receive + after infinity -> ok + end end), + Sess = make_session(h1, Probe, undefined), + ?assertEqual({error, close_not_allowed}, nhttp_ws:send(Sess, close)), + ?assertEqual({error, close_not_allowed}, nhttp_ws:send(Sess, {close, 1000, <<>>})), + ?assertEqual({error, close_not_allowed}, nhttp_ws:send(Sess, close, #{timeout => 100})), + ?assertEqual({error, close_not_allowed}, nhttp_ws:send_async(Sess, {close, 1000, <<"bye">>})), + ?assertEqual({messages, []}, process_info(Probe, messages)), + stop_probe(Probe). + +session_send_refuses_oversize_control(_Config) -> + Probe = spawn_probe(fun() -> + receive + {'$gen_call', From, {ws_send, _, 3, {ping, _}}} -> gen_server:reply(From, ok) + end end), - ?assertEqual({sent, ok, ok}, await({sent, ok, ok}, 1000)), - Expected = <<16#81, 2, "hi", 16#82, 3, 1, 2, 3>>, - ?assertEqual({ok, Expected}, gen_tcp:recv(Client, byte_size(Expected), 1000)), - stop_probe(Sender), - ok = gen_tcp:close(Server), - ok = gen_tcp:close(Client). - -session_direct_send_gone_after_close(_Config) -> - {Server, Client} = tcp_pair(), - Sess = nhttp_ws:new_session(h1, dead_pid(), undefined, make_ref(), {tcp, Server}), - ?assertEqual(ok, nhttp_ws:send(Sess, ping)), - ok = gen_tcp:close(Server), - ?assertEqual({error, gone}, nhttp_ws:send(Sess, {text, <<"late">>})), - ?assertEqual({error, gone}, nhttp_ws:send_async(Sess, {text, <<"late">>})), - ok = gen_tcp:close(Client). + Sess = make_session(h2, Probe, 3), + Big = binary:copy(<<"x">>, 126), + ?assertEqual({error, control_frame_too_large}, nhttp_ws:send(Sess, {ping, Big})), + ?assertEqual({error, control_frame_too_large}, nhttp_ws:send(Sess, {pong, Big})), + ?assertEqual({error, control_frame_too_large}, nhttp_ws:send_async(Sess, {pong, Big})), + ?assertEqual({error, control_frame_too_large}, nhttp_ws:ping(Sess, Big)), + ?assertEqual({messages, []}, process_info(Probe, messages)), + ?assertEqual(ok, nhttp_ws:ping(Sess, binary:copy(<<"x">>, 125))), + stop_probe(Probe). + +session_close_refuses_invalid(_Config) -> + Sess = make_session(h3, self(), 4), + Invalid = [0, 999, 1004, 1005, 1006, 1015, 1016, 2999, 5000, 65535], + [ + ?assertEqual({error, invalid_close_code}, nhttp_ws:close(Sess, Code, <<>>)) + || Code <- Invalid + ], + ?assertEqual( + {error, close_reason_too_large}, nhttp_ws:close(Sess, 1000, binary:copy(<<"r">>, 124)) + ), + ?assertEqual({error, invalid_close_reason}, nhttp_ws:close(Sess, 1000, <<16#FF>>, #{})), + ?assertEqual(ok, nhttp_ws:close(Sess, 4999, binary:copy(<<"r">>, 123))), + ?assertMatch({'$gen_cast', {ws_close, _, 4, 4999, _, #{}}}, await_msg(1000)), + receive + Other -> ct:fail({unexpected_message, Other}) + after 0 -> ok + end. session_h1_without_socket_routes_to_conn(_Config) -> Owner = self(), @@ -1000,13 +1021,13 @@ session_h1_without_socket_routes_to_conn(_Config) -> end, receive {'$gen_call', From2, {ws_send_async, _, undefined, {text, <<"b">>}}} -> - gen_server:reply(From2, {error, would_block}) + gen_server:reply(From2, {error, gone}) end, Owner ! routed end), Sess = make_session(h1, Probe, undefined, make_ref()), ?assertEqual(ok, nhttp_ws:send(Sess, {text, <<"a">>})), - ?assertEqual({error, would_block}, nhttp_ws:send_async(Sess, {text, <<"b">>})), + ?assertEqual({error, gone}, nhttp_ws:send_async(Sess, {text, <<"b">>})), ?assertEqual(routed, await(routed, 1000)), stop_probe(Probe). @@ -1067,22 +1088,6 @@ make_session(Transport, Pid, StreamId) -> make_session(Transport, Pid, StreamId, Ref) -> nhttp_ws:new_session(Transport, Pid, StreamId, Ref). --spec tcp_pair() -> {gen_tcp:socket(), gen_tcp:socket()}. -tcp_pair() -> - {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {ip, loopback}]), - {ok, Port} = inet:port(Listen), - {ok, Client} = gen_tcp:connect({127, 0, 0, 1}, Port, [binary, {active, false}], 1000), - {ok, Server} = gen_tcp:accept(Listen, 1000), - ok = gen_tcp:close(Listen), - {Server, Client}. - --spec dead_pid() -> pid(). -dead_pid() -> - {Pid, MRef} = spawn_monitor(fun() -> ok end), - receive - {'DOWN', MRef, process, Pid, _} -> Pid - end. - -spec await(term(), timeout()) -> term() | no_return(). await(Expected, Timeout) -> receive diff --git a/test/nhttp_ws_frame_SUITE.erl b/test/nhttp_ws_frame_SUITE.erl index 8e1dd18..54a09e8 100644 --- a/test/nhttp_ws_frame_SUITE.erl +++ b/test/nhttp_ws_frame_SUITE.erl @@ -58,6 +58,10 @@ groups() -> validate_control_frame_ok, validate_control_frame_too_large, validate_control_frame_fragmented, + validate_message_ok, + validate_message_close_errors, + validate_message_control_too_large, + validate_close_shared_with_decoder, opcode_to_message_fragmented_rejected, opcode_to_message_unknown_opcode, opcode_to_complete_message_unknown @@ -330,3 +334,57 @@ partial_payload_stops_at_declared_length(_Config) -> partial_payload_length_top_bit(_Config) -> Frame = <<16#82, 127, 1:1, 70000:63, "xy">>, ?assertEqual(none, nhttp_ws_frame:partial_payload(Frame, client, 0)). + +validate_message_ok(_Config) -> + Ok = [ + {text, <<"hi">>}, + {binary, <<1, 2>>}, + ping, + pong, + {ping, binary:copy(<<"p">>, 125)}, + {pong, <<>>}, + close, + {close, 1000, <<>>}, + {close, 4999, binary:copy(<<"r">>, 123)}, + {close, 3000, <<"caf", 16#C3, 16#A9>>} + ], + [?assertEqual(ok, nhttp_ws_frame:validate_message(M)) || M <- Ok]. + +validate_message_close_errors(_Config) -> + Invalid = [0, 999, 1004, 1005, 1006, 1015, 1016, 2999, 5000, 65535], + [ + ?assertEqual( + {error, invalid_close_code}, nhttp_ws_frame:validate_message({close, Code, <<>>}) + ) + || Code <- Invalid + ], + ?assertEqual( + {error, close_reason_too_large}, + nhttp_ws_frame:validate_message({close, 1000, binary:copy(<<"r">>, 124)}) + ), + ?assertEqual( + {error, invalid_close_reason}, + nhttp_ws_frame:validate_message({close, 1000, <<16#FF>>}) + ). + +validate_message_control_too_large(_Config) -> + Big = binary:copy(<<"x">>, 126), + ?assertEqual({error, control_frame_too_large}, nhttp_ws_frame:validate_message({ping, Big})), + ?assertEqual({error, control_frame_too_large}, nhttp_ws_frame:validate_message({pong, Big})). + +validate_close_shared_with_decoder(_Config) -> + ?assertEqual({error, invalid_close_code}, nhttp_ws_frame:validate_close(1005, <<>>)), + ?assertEqual( + {error, invalid_close_code}, + nhttp_ws_frame:opcode_to_complete_message(8, <<1005:16>>) + ), + ?assertEqual({error, invalid_close_reason}, nhttp_ws_frame:validate_close(1000, <<16#FF>>)), + ?assertEqual( + {error, invalid_close_reason}, + nhttp_ws_frame:opcode_to_complete_message(8, <<1000:16, 16#FF>>) + ), + ?assertEqual(ok, nhttp_ws_frame:validate_close(1000, <<"ok">>)), + ?assertEqual( + {ok, {close, 1000, <<"ok">>}}, + nhttp_ws_frame:opcode_to_complete_message(8, <<1000:16, "ok">>) + ).