PAGI-Server
view release on metacpan or search on metacpan
t/http2/38-ws-queue-overflow.t view on Meta::CPAN
use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use IO::Async::Stream;
use Future;
use Future::AsyncAwait;
use FindBin;
use lib "$FindBin::Bin/../../lib";
use Socket qw(AF_UNIX SOCK_STREAM);
plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';
BEGIN {
require PAGI::Server::Protocol::HTTP2;
PAGI::Server::Protocol::HTTP2->available
or plan(skip_all => 'HTTP/2 not available (Net::HTTP2::nghttp2 0.008+ required)');
}
# ============================================================
# Test: h2 WebSocket inbound queue-overflow enforcement (audit-found gap)
# ============================================================
# h1's _process_websocket_frames checks max_receive_queue before queueing
# each text/binary websocket.receive event -- once the queue is already at
# the configured cap, the next inbound message closes the connection with
# 1008 ("Message queue overflow") and delivers a queue_overflow
# websocket.disconnect instead of queueing without bound. h2's
# _h2_process_ws_frames had no such check: a client flooding messages
# faster than the app drains receive() could grow a stream's receive_queue
# without limit -- an unbounded per-connection memory DoS on a
# transport that otherwise mirrors HTTP/1.1's WebSocket support exactly.
#
# This uses an app that never calls receive() at all (accepts and returns
# immediately) as its "never drains" case -- the stream stays open at the
# h2 level (nothing about returning from a WebSocket app coroutine tears
# the stream down; only an explicit close/END_STREAM does), so every
# flooded message is available to inspect via white-box access to the
# stream's own receive_queue.
use PAGI::Server::Connection;
use PAGI::Server;
use PAGI::Server::Protocol::HTTP1;
use PAGI::Server::Protocol::HTTP2;
use Protocol::WebSocket::Frame;
my $loop = IO::Async::Loop->new;
my $protocol = PAGI::Server::Protocol::HTTP1->new;
# Small cap so a handful of flooded messages clearly demonstrates the
# enforcement (or its absence) without needing a large flood.
use constant MAX_RECEIVE_QUEUE => 3;
use constant FLOOD_COUNT => 10; # well past the cap
# ============================================================
# Helpers (lifted from t/http2/31-ws-keepalive-disconnect.t /
# t/http2/36-ws-transport.t / t/http2/37-ws-frame-validation.t)
# ============================================================
sub create_test_server {
my (%args) = @_;
my $server = PAGI::Server->new(
app => $args{app} // sub { },
host => '127.0.0.1',
port => 0,
quiet => 1,
http2 => 1,
max_receive_queue => MAX_RECEIVE_QUEUE,
%args,
);
$loop->add($server);
return $server;
}
sub create_h2_connection {
my (%overrides) = @_;
socketpair(my $sock_a, my $sock_b, AF_UNIX, SOCK_STREAM, 0)
or die "socketpair: $!";
$sock_a->blocking(0);
$sock_b->blocking(0);
my $app = $overrides{app} // sub { };
my $server = $overrides{server} // create_test_server(app => $app);
my $stream = IO::Async::Stream->new(
read_handle => $sock_a,
write_handle => $sock_a,
on_read => sub { 0 },
);
my $conn = PAGI::Server::Connection->new(
stream => $stream,
app => $app,
protocol => $protocol,
server => $server,
t/http2/38-ws-queue-overflow.t view on Meta::CPAN
buffer => $text,
masked => 1,
);
send_stream_data($client, $client_sock, $stream_id, $frame->to_bytes);
}
sub exchange_frames {
my ($client, $client_sock, $rounds) = @_;
$rounds //= 10;
for (1..$rounds) {
$loop->loop_once(0.1);
my $buf = '';
$client_sock->sysread($buf, 16384);
$client->mem_recv($buf) if length($buf);
my $out = $client->mem_send;
$client_sock->syswrite($out) if length($out);
}
}
sub open_ws_stream_tracked {
my ($client, $client_sock, $path, $id_ref) = @_;
$path //= '/ws';
$$id_ref = $client->submit_request(
method => 'CONNECT',
path => $path,
scheme => 'https',
authority => 'localhost',
headers => [
[':protocol', 'websocket'],
['sec-websocket-version', '13'],
],
body => sub { return undef }, # streaming: keep open
);
$client_sock->syswrite($client->mem_send);
exchange_frames($client, $client_sock);
return $$id_ref;
}
sub extract_ws_frames {
my ($raw) = @_;
my @frames;
my $parser = Protocol::WebSocket::Frame->new;
$parser->append($raw);
while (defined(my $bytes = $parser->next_bytes)) {
push @frames, { opcode => $parser->opcode, bytes => $bytes };
}
return @frames;
}
sub close_codes {
my ($raw) = @_;
return map { unpack('n', substr($_->{bytes}, 0, 2)) }
grep { $_->{opcode} == 8 && length($_->{bytes}) >= 2 }
extract_ws_frames($raw);
}
# App that accepts and immediately returns -- it never calls receive() at
# all, so nothing ever drains the stream's receive_queue. Nothing about a
# WebSocket app coroutine returning tears the h2 stream down by itself
# (only an explicit close/END_STREAM does), so the stream stays open and
# every flooded message lands in receive_queue for white-box inspection.
sub make_never_draining_app {
return async sub {
my ($scope, $receive, $send) = @_;
return unless $scope->{type} eq 'websocket';
await $send->({ type => 'websocket.accept' });
return;
};
}
subtest 'flooding past max_receive_queue is bounded, closes 1008/queue_overflow' => sub {
my $app = make_never_draining_app();
my ($conn, $stream_io, $client_sock, $server) = create_h2_connection(app => $app);
my $ws_stream_id;
my $ws_data = '';
my $client = create_client(
on_data_chunk_recv => sub {
my ($sid, $data) = @_;
$ws_data .= $data if defined $ws_stream_id && $sid == $ws_stream_id;
return 0;
},
);
complete_h2_handshake($client, $client_sock);
open_ws_stream_tracked($client, $client_sock, '/ws', \$ws_stream_id);
my $ss = $conn->{h2_streams}{$ws_stream_id};
ok($ss, 'server holds stream state for the accepted ws stream');
# Flood well past the configured cap. Nobody is draining (the app never
# calls receive()), so every message that is allowed to queue stays
# queued for inspection below.
for my $i (1 .. FLOOD_COUNT) {
send_ws_text($client, $client_sock, $ws_stream_id, "msg$i");
}
# Bounded poll: give the server enough rounds to process the flood and
# (post-fix) flush its Close frame to the wire. 30 rounds (3s) is a wide
# margin over the single in-process flood this subtest drives.
my $queue_len = 0;
for (1 .. 30) {
exchange_frames($client, $client_sock, 1);
$queue_len = scalar @{ $ss->{receive_queue} // [] };
last unless exists $conn->{h2_streams}{$ws_stream_id};
}
# A couple more rounds so a just-queued Close frame reaches the wire.
exchange_frames($client, $client_sock, 5);
# The definitive "no leak" check: the queue must never hold more than
# max_receive_queue websocket.receive entries plus the one disconnect
# event queue-overflow enforcement appends when it closes the
# connection -- never FLOOD_COUNT (10) entries, which is what an
# unenforced queue would accumulate.
ok($queue_len <= MAX_RECEIVE_QUEUE + 1,
"receive_queue stayed bounded (<= @{[ MAX_RECEIVE_QUEUE + 1 ]}), got $queue_len")
or diag("FLOOD_COUNT was @{[ FLOOD_COUNT ]}; an unbounded queue would show that many");
my @receive_events = grep { $_->{type} eq 'websocket.receive' } @{ $ss->{receive_queue} // [] };
( run in 2.272 seconds using v1.01-cache-2.11-cpan-364913b4093 )