NAME
PAGI::Server::Compliance - HTTP/1.1, HTTP/2, WebSocket, SSE, and Security Compliance Documentation
DESCRIPTION
This document details the compliance testing results for PAGI::Server against HTTP/1.1 (RFC 7230/7231), HTTP/2 (RFC 9113), WebSocket (RFC 6455), Server-Sent Events, and common security attack vectors.
PAGI::Server demonstrates full HTTP/1.1 compliance, 93.8% HTTP/2 conformance (matching the nghttp2 library ceiling), strong security posture, stable resource management, and 71% WebSocket RFC 6455 compliance.
TEST ENVIRONMENT
Server: PAGI::Server with IO::Async and EV backend
Platform: macOS Darwin / Linux
Event Loop: EV with kqueue (macOS) or epoll (Linux)
Test Date: February 2026
RESULTS SUMMARY
+---------------------------+-------+--------+--------+
| Category | Tests | Passed | Failed |
+---------------------------+-------+--------+--------+
| HTTP/1.1 Compliance | 10 | 10 | 0 |
| HTTP/2 (h2spec) | 146 | 137 | 9 |
| Slow HTTP Attacks | 4 | 4 | 0 |
| Concurrent Attack+Traffic | 1 | 1 | 0 |
| Request Smuggling | 6 | 6 | 0 |
| nikto Scanner | 4 | 4 | 0 |
| Protocol Fuzzing | 49 | 49 | 0 |
| Memory/Resource Leaks | 4 | 4 | 0 |
| WebSocket (Autobahn) | 301 | 215 | 86 |
+---------------------------+-------+--------+--------+
PAGI SPECIFICATION SUPPORT
Beyond protocol conformance, PAGI::Server implements the optional capabilities defined in PAGI::Spec::Www, across both HTTP/1.1 and HTTP/2:
Transport flow control (
pagi.transport) --buffered_amount, high/low watermarks, andon_high_water/on_drainbackpressure callbacks, on both HTTP/1.1 and HTTP/2, for every streaming scope type PAGI offers it on: http, sse, and websocket (see "Transport Flow Control (pagi.transport)").Connection state (
pagi.connection) for HTTP scopes --is_connected,disconnect_reason,on_disconnect(abnormal only),on_complete(success only), anddisconnect_future. Tracked independently per stream on HTTP/2 (see "Connection State (pagi.connection)").WebSocket Denial Response (the
websocket.http.responseextension) -- reject a handshake with a custom HTTP response instead of a bare403, on both HTTP/1.1 and HTTP/2.WebSocket over HTTP/2 (RFC 8441 Extended CONNECT), including per-stream
websocket.keepalive(see "WebSocket over HTTP/2 (RFC 8441)").TLS introspection (the
tlsextension) -- certificates, negotiated version, and cipher suite. The server negotiates TLS 1.3 by default (min_versionis a floor, not a pin).Server-Sent Events over all HTTP methods, with keepalive (see "SERVER-SENT EVENTS (SSE)").
Lifespan -- the per-process startup/shutdown protocol, in
auto(decline-tolerant, default) andon(strict) modes (see "Lifespan Modes").
The server validates outbound events against the spec and rejects malformed ones unconditionally, on every send path, in every environment.
Lifespan Modes
lifespan_mode controls how the server treats the PAGI lifespan protocol -- the lifespan scope an application may use to run startup/shutdown logic once per process.
auto (default) -- decline-tolerant. If the application does not implement lifespan, or declines it (raises on the
lifespanscope, or returns without signallinglifespan.startup.complete/lifespan.startup.failed), the server continues startup without it. An explicitlifespan.startup.failedstill aborts startup in this mode too.on -- strict. A decline at startup is a fatal startup failure: the server refuses to start rather than serve traffic without the initialization the application depended on.
There is no off mode. The PAGI Lifespan spec forbids a server from skipping the protocol entirely: "A server must not offer an 'off' switch for this protocol." Passing lifespan_mode => 'off' to the constructor or to the configure setter dies immediately, naming the rejected value.
In multi-worker mode (workers => N), each forked worker inherits lifespan_mode and lifespan_startup_timeout from the master's configuration and runs its own lifespan handshake against its own copy of the application -- a worker never silently reverts to the auto default when the master was configured on.
Extension Advertisement
PAGI::Server's rule for the extensions key in a scope is truthfulness: it names only extensions the connection can actually honor for that scope type. Extensions configured on the server (extensions => \%extensions, e.g. { fullflush => {} }) pass through to every scope's extensions hash unchanged, with one exception -- WebSocket scopes never advertise fullflush. validate_websocket_send has no http.fullflush arm, so a WebSocket application that trusted the advertisement would get "Unrecognized event type" instead of a working extension; http and sse scopes are unaffected and still advertise fullflush when configured. Any other custom extension key an operator configures passes through to every scope, including WebSocket, unchanged -- the truthfulness rule removes only fullflush, the one extension WebSocket cannot honor -- except a key named tls, which the server itself injects on TLS connections and removes on non-TLS ones regardless of what was configured.
HTTP/1.1 COMPLIANCE
PAGI::Server is fully compliant with RFC 7230 (HTTP/1.1 Message Syntax and Routing) and RFC 7231 (HTTP/1.1 Semantics and Content).
Compliance Tests
+------------------------------+----------+--------+--------+
| Test | Expected | Actual | Status |
+------------------------------+----------+--------+--------+
| Normal request | 200 | 200 | PASS |
| HTTP/1.1 missing Host | 400 | 400 | PASS |
| HTTP/1.0 no Host | 200 | 200 | PASS |
| Content-Length: abc | 400 | 400 | PASS |
| Content-Length: -1 | 400 | 400 | PASS |
| Content-Length: overflow | 413 | 413 | PASS |
| Content-Length: with spaces | 400 | 400 | PASS |
| URI > 8KB | 414 | 414 | PASS |
| Header > 8KB | 431 | 431 | PASS |
| CL+TE conflict | 400 | 400 | PASS |
+------------------------------+----------+--------+--------+
RFC 7230 Section 5.4 - Host Header
HTTP/1.1 requests without a Host header correctly return 400 Bad Request. HTTP/1.0 requests without Host are allowed per specification.
RFC 9112 Section 6.3.3 - Message Body Length
A request carrying both Transfer-Encoding and Content-Length is rejected with 400 Bad Request rather than resolved by precedence — the two are treated as mutually exclusive. This is the stricter posture permitted by RFC 9112 Section 6.3.3 and closes the CL/TE desync that enables HTTP request smuggling.
HTTP/2 COMPLIANCE (EXPERIMENTAL)
HTTP/2 support in PAGI::Server is experimental. The API and behavior may change in future releases.
PAGI::Server provides HTTP/2 support via the nghttp2 C library (Net::HTTP2::nghttp2). HTTP/2 is enabled with the --http2 flag and supports both TLS (h2 via ALPN) and cleartext (h2c) modes.
Tested against h2spec, the standard HTTP/2 conformance testing tool.
h2spec Results
137/146 tests passed (93.8%)
+-----------------------------+-------+--------+--------+
| Category | Tests | Passed | Failed |
+-----------------------------+-------+--------+--------+
| Starting HTTP/2 | 1 | 1 | 0 |
| Streams and Multiplexing | 5 | 5 | 0 |
| DATA frames | 3 | 3 | 0 |
| HEADERS frames | 3 | 3 | 0 |
| PRIORITY frames | 5 | 5 | 0 |
| RST_STREAM frames | 1 | 1 | 0 |
| SETTINGS frames | 1 | 1 | 0 |
| PING frames | 1 | 1 | 0 |
| GOAWAY frames | 1 | 1 | 0 |
| WINDOW_UPDATE frames | 2 | 2 | 0 |
| CONTINUATION frames | 2 | 2 | 0 |
| HTTP Message Exchanges | 4 | 4 | 0 |
| HPACK | 13 | 13 | 0 |
| Detailed RFC 9113 tests | 98 | 89 | 9 |
| HPACK RFC 7541 tests | 6 | 6 | 0 |
+-----------------------------+-------+--------+--------+
Remaining Failures (nghttp2 Library Limitations)
All 9 failures are shared with the bare nghttp2 test server and cannot be fixed at the PAGI level. These are intentional leniencies in the nghttp2 C library for interoperability:
+------------+----------------------------------------------------+
| Test | Description |
+------------+----------------------------------------------------+
| 5.1 #8 | DATA after RST_STREAM (closed stream) |
| 5.1 #9 | HEADERS after RST_STREAM (closed stream) |
| 5.1 #11 | DATA on fully closed stream |
| 5.1 #12 | HEADERS on fully closed stream |
| 5.1.1 #2 | Stream ID ordering enforcement |
| 5.3.1 #2 | PRIORITY self-dependency detection |
| 6.3 #1 | PRIORITY frame with stream ID 0 |
| 6.9.1 #3 | Window overflow (RST_STREAM vs connection close) |
| 6.9.2 #2 | Negative flow control window tracking |
+------------+----------------------------------------------------+
Separately (not an h2spec failure, but the same category of nghttp2-library leniency): nghttp2's own HTTP-messaging validation rejects and omits a header field whose value carries leading or trailing whitespace (RFC 9113 section 8.2.1 forbids sending it) before the request or response ever reaches PAGI application code -- the field is dropped entirely, not merely trimmed of its whitespace. This holds in both directions: a response header an application sends with a padded value never reaches the client (t/http2/33-connection-specific-headers.t), and a request header a client sends with a padded value never reaches scope->{headers} (same file, request-direction subtest) -- in neither case does the C library layer, nor PAGI, silently rewrite the value. HTTP/1.1 performs no such rejection.
Load Testing (h2load)
Tool: h2load (nghttp2)
Protocol: h2 over TLS (ALPN negotiated)
+--------------+------+----------+-----------+---------+---------+
| Requests | Conn | Streams | Req/sec | Errors | Timeout |
+--------------+------+----------+-----------+---------+---------+
| 10,000 | 10 | 100 | 8,294 | 0 | 0 |
| 50,000 | 50 | 100 | 6,823 | 0 | 0 |
+--------------+------+----------+-----------+---------+---------+
Zero failures across 60,000 total requests under concurrent load.
nghttp Client Validation
Tool: nghttp (nghttp2 CLI client)
GET request: Clean h2 exchange with ALPN, SETTINGS, HPACK compression
POST with body: DATA frame + END_STREAM handled correctly
16 concurrent streams: All responded 200 on a single connection in 3ms
HTTP/2 Features
ALPN negotiation (h2, http/1.1)
Stream multiplexing (100 concurrent streams default)
HPACK header compression (93.7% space savings observed)
Flow control (per-stream and connection-level)
GOAWAY handling (graceful session shutdown)
Stream state validation (END_STREAM tracking, RST_STREAM on violations)
Extended CONNECT for WebSocket over HTTP/2 (RFC 8441)
Server SETTINGS: configurable max_concurrent_streams, initial_window_size, max_frame_size, max_header_list_size
A request HEADERS block exceeding max_header_list_size gets a real 431 response (RFC 9113 section 10.5.1) instead of a bare RST_STREAM, matching HTTP/1.1
HEAD request body suppression, matching HTTP/1.1: DATA frames are withheld and file/fh bodies are never opened
File and filehandle body streaming through the per-stream send queue, under the same per-stream backpressure watermark as chunked bodies
http.fullflushon HTTP and SSE streams
Filehandle (fh) response bodies are read synchronously, in fixed-size chunks inside the connection's own send loop, on both HTTP/1.1 and HTTP/2 -- the handle is application-owned and cannot be handed to the async worker pool across a fork boundary. This differs from file (path) bodies opened by the server, which use the async worker pool (PAGI::Server::AsyncFile) for files larger than sync_file_threshold; a fh body bypasses that pool regardless of size.
Transport Flow Control (pagi.transport)
PAGI exposes outbound flow-control introspection to applications through the pagi.transport scope key (see "Transport Flow Control" in PAGI::Spec::Www): buffered_amount, the high/low watermarks, and the on_high_water/on_drain backpressure callbacks. Over HTTP/2 the handle measures the per-stream send backlog, so each multiplexed stream is bounded independently.
PAGI::Server provides pagi.transport for every streaming scope type on both transports: http (streaming responses), sse, and websocket, on both HTTP/1.1 and HTTP/2. The HTTP/2 WebSocket send path frames application messages, protocol replies (pong, the close-frame echo), and its own keepalive ping through the same per-stream send queue and pull-based data-provider model that HTTP/2 streaming and SSE use. From the application's perspective the transport is invisible: a WebSocket app sees the same pagi.transport surface and semantics -- buffered_amount, the high/low watermarks, on_high_water/on_drain -- whether the connection is HTTP/1.1 or HTTP/2.
on_drain fires only for a genuine drain -- the buffer actually falling back below the low mark -- on both transports. Tearing a connection down while the buffer is still above the high mark (client disconnect, timeout, server shutdown, ...) does not fire on_drain: the connection is going away, not draining. A producer parked on the blocking backpressure path (a $send awaiting the buffer to drain) still resumes on teardown, so no coroutine is left hanging; only the app-facing hysteresis callback is withheld.
WebSocket over HTTP/2 (RFC 8441)
PAGI::Server accepts a WebSocket upgrade over HTTP/2 through RFC 8441 Extended CONNECT (:protocol => 'websocket'). Each accepted stream carries its own websocket.receive/websocket.disconnect event stream, multiplexed alongside every other stream on the connection.
Framing Enforcement
RFC 8441 requires WebSocket-over-HTTP/2 framing to be "identical to HTTP/1.1" (section 4), so the same three RFC 6455 framing checks the HTTP/1.1 WebSocket compliance section above performs also apply here, per stream: nonzero RSV1-3 bits close with 1002 (section 5.2; Protocol::WebSocket::Frame exposes rsv but does not itself validate it), reserved/unknown opcodes 3-7 and 11-15 close with 1002 (section 5.2), and a control frame (close/ping/pong) payload over 125 bytes closes with 1002 (section 5.5). Each violation follows the same server-initiated-protocol-close path described below: one Close frame on the wire, one websocket.disconnect (code 1002, reason protocol_error) delivered to the application.
Keepalive
websocket.keepalive is supported per stream. An HTTP/2 connection multiplexes many WebSocket streams, so keepalive state and its timers live on the individual stream rather than the connection -- unlike HTTP/1.1, where one WebSocket occupies the whole connection and keepalive is connection-wide. A ping is delivered as an RFC 6455 ping frame carried in an HTTP/2 DATA frame on that stream; the peer's pong (opcode 10) clears the stream's wait flag. Each websocket.keepalive event for a stream supersedes any keepalive already running on it, and interval => 0 stops it. When timeout is given and no pong arrives within it, only that stream closes -- code 1006, reason keepalive_timeout -- other streams multiplexed on the same connection are unaffected. That 1006 is the code the application sees; it never reaches the wire, because RFC 6455 section 7.4.1 forbids 1006 as the status code of a Close frame. A keepalive timeout sends no Close frame at all: it resets the stream (RST_STREAM, error code CANCEL), the HTTP/2 analogue of HTTP/1.1 dropping the transport. An omitted timeout means the server never checks for a missing pong.
Disconnect
Each HTTP/2 WebSocket stream enqueues exactly one websocket.disconnect event onto its scope's receive queue, whichever of the conditions below closes it first: the closure paths all funnel through one deduplicating enqueue, so no closure can queue a second disconnect and no two closure paths can race to queue conflicting ones. code and reason pair up the same way as the WebSocket "Disconnect - receive event" section of PAGI::Spec::Www describes for the reference server:
The peer sent a Close frame.
codeis the peer's own close code (1005if the frame carried none) andreasonis the peer's own reason text, which is commonly empty. The server does not substitute a token.Abnormal drop with no close handshake -- a bare
END_STREAM, anRST_STREAM, or any other stream teardown that did not go through a Close frame.codeis1006andreasonisclient_closed.Keepalive pong timeout.
codeis1006andreasoniskeepalive_timeout.Server-initiated protocol close -- the server detected a framing or protocol violation (a nonzero RSV bit, a reserved/unknown opcode, an oversized control-frame payload, an invalid close frame, an invalid close code, or invalid UTF-8 in a text frame or in a Close frame's reason) and sent the Close frame itself.
codeis the RFC 6455 code for the fault (1002for a framing/protocol error,1007for invalid UTF-8) andreasonisprotocol_error.Inbound receive-queue overflow -- a text or binary message arrived while the stream's own receive queue already held
max_receive_queueundelivered events (default 1000; see PAGI::Server for themax_receive_queueconstructor option), the same per-stream cap and enforcement point HTTP/1.1 uses.codeis1008andreasonisqueue_overflow.
"Exactly one" above is a property of the receive queue, not a hard cap on what any number of receive() calls can ever return. Once the stream's state has been torn down -- the connection is closed, or the stream's state has already been reclaimed -- a further receive() has no queue left to read and falls back to synthesizing a generic websocket.disconnect with code 1006 and an empty reason. An application that keeps calling receive() after its disconnect event therefore keeps getting that fallback rather than hanging. The queued disconnect (the one carrying the real code and reason from the table above) is still delivered exactly once.
HTTP/2 Trailers (http.response.trailers)
When an HTTP/2 response start declares trailers => 1, a terminal body event does not end the stream: the final DATA frame carries no END_STREAM. A following http.response.trailers event submits a trailing HEADERS block that carries END_STREAM, completing the response; an empty or absent headers list is valid and still terminates the stream. On a HEAD request the trailers event is validated and discarded rather than transmitted -- HEAD sends no body, so nothing needs to reach the wire, but the response still completes normally.
The trailer field list goes through the same connection-specific-header strip as every other HTTP/2 response path (see "HTTP/2 Connection-Specific Header Stripping" below): an application-supplied Connection, Keep-Alive, Proxy-Connection, Transfer-Encoding, Upgrade, or TE field is removed from the trailer block before submission, with a warning logged per stripped occurrence. Unlike a response's own HEADERS, a trailer block carries no TE-with-trailers carve-out -- RFC 9110 section 6.6.2 forbids every connection-specific field from a trailer section outright, so a trailer-borne TE is stripped unconditionally.
Because the trailing HEADERS must not be submitted ahead of body data the peer hasn't yet received, a send() carrying http.response.trailers can remain pending for as long as the peer withholds HTTP/2 flow-control window on the still-draining body: trailers participate in the same per-stream backpressure as ordinary body sends. The Future still resolves once nghttp2 accepts the trailing HEADERS submission, the same "accepted, not necessarily delivered" semantics every other HTTP/2 send Future uses.
A response that declares trailers => 1 and then returns, or throws, without ever sending http.response.trailers is an incomplete response under the same rule that governs an unsent terminal body chunk (see "Application Left a Response Incomplete" below): the stream is reset (RST_STREAM, INTERNAL_ERROR) rather than left open or closed as if nothing were missing, so the incompleteness is observable to the client.
PAGI::Server also accepts request trailers from the client -- a HEADERS block following the request's initial headers and any DATA frames (RFC 9113 section 8.1). PAGI::Server accumulates the field list from nghttp2's own on_header callback -- nghttp2 itself, not a PAGI-side check, is what enforces the field is well-formed at the HPACK/HTTP-messaging layer before that callback ever fires -- and then discards it: PAGI defines no request-trailer receive event yet, so an application never sees them. If END_STREAM rides the trailer HEADERS instead of a DATA frame, the request body still completes normally for the application. A pseudo-header field in a trailer block is malformed under RFC 9113 section 8.1; PAGI::Server's own callback layer rejects it as a defense-in-depth check, though in practice a conforming client cannot construct one and get it as far as PAGI's code: nghttp2's own HTTP-messaging validation catches a pseudo-header in a trailer block first, at the C library layer, and tears down the whole connection (GOAWAY) rather than only the offending stream.
HTTP/2 Oversized Request Headers (RFC 9113 section 10.5.1)
When a request's HEADERS block exceeds max_header_list_size, PAGI::Server sends a real HTTP 431 (Request Header Fields Too Large) response rather than a bare RST_STREAM with no :status at all -- matching both RFC 9113 section 10.5.1 ("a server that receives a larger header block than it is willing to handle can send an HTTP 431") and PAGI::Server's own HTTP/1.1 behavior for the same condition.
HPACK decoding of the oversized block is allowed to run to completion instead of being aborted mid-frame: nghttp2's dynamic table stays consistent, and PAGI simply stops storing fields for a block it will not dispatch once the running RFC 7541 accounting (name_len + value_len + 32 per field) crosses the limit. The 431 is synthesized once the HEADERS frame commits; the request is never delivered to the application (no on_request dispatch, no pagi.connection, no receive-queue state).
If a client sends request trailers (a later HEADERS block, see "HTTP/2 Trailers" above) after headers that were already too large to dispatch, the trailer block is still classified normally by the pending-block machinery and is harmlessly discarded (or, if the trailer block is itself oversized, handled by the case below) -- it never causes a crash or a second dispatch.
This applies to the request's initial HEADERS block only. A trailer HEADERS block that is itself oversized keeps its original, unchanged behavior: the pending trailer block is discarded and the stream is reset (RST_STREAM), since by that point a response may already be in flight and there is no request-trailer receive event for an application to see in the first place (see "HTTP/2 Trailers" above).
Connection State (pagi.connection)
PAGI exposes connection-lifecycle introspection to applications through the pagi.connection scope key (see "Connection State" in PAGI::Spec::Www): is_connected, disconnect_reason, on_disconnect (fires on an abnormal end only), on_complete (fires on a clean completion only), and disconnect_future. Exactly one of on_disconnect / on_complete fires per request; a request ends in one of two terminal states, completed (clean) or disconnected (abnormal), never both.
On HTTP/2 each stream carries its own pagi.connection, tracked independently of every other stream multiplexed on the same connection: one stream completing does not affect another still in flight. A stream reaches its terminal state exactly once, when the stream closes -- a response whose terminal event completed with no HTTP/2 error code on the stream is a clean completion; an early close or a nonzero error code is an abnormal disconnect (reason client_closed). A whole-connection teardown (server shutdown, socket error) sweeps every still-open stream's connection state with the connection-level disconnect reason, so a stream mid-response when the connection dies still reports why.
disconnect_future follows the three possible states at the moment it is first called: while the request is still in flight it returns a fresh, pending Future that resolves later if the connection disconnects abnormally; after an abnormal disconnect it returns a Future already resolved with the disconnect reason; after a clean completion it returns a Future that is deliberately left pending forever, since a clean completion has no disconnect reason to resolve it with.
Socket errors: a sysread/syswrite failure on the underlying socket (other than a clean EOF, which is client_closed) reports read_error / write_error respectively, per "Standard Disconnect Reasons" in PAGI::Spec::Www. write_timeout is not currently produced -- PAGI::Server has no mechanism that times a write's completion independently of the connection's other timeouts (timeout, request_timeout), so a stalled write is only ever caught, if at all, by one of those.
Application Left a Response Incomplete: when http.response.start has been sent but the response's terminal event never arrives -- the final body chunk (more => 0), a file/fh body, or (having declared trailers => 1) the trailers event -- because the application returned early or raised an error, PAGI::Server forces an abnormal closure rather than synthesizing terminal framing that was never sent. On HTTP/1.1 the connection closes with no chunked terminator and is not kept alive or reused for pipelining. On HTTP/2 the stream is reset with RST_STREAM (INTERNAL_ERROR); other streams multiplexed on the same connection are unaffected. Either way on_disconnect fires with reason server_error (never on_complete), and the server logs a warning unless the client had already disconnected first, in which case there is nothing new to report.
pagi.connection is provided for http scopes on both HTTP/1.1 and HTTP/2. WebSocket and SSE scopes do not receive pagi.connection on either transport -- they use their own dedicated disconnect events instead.
response_complete() -- the spec's SHOULD-level synchronous companion to on_complete, reporting whether the response body has finished sending -- is present but always returns undef: this server does not track response-body completion. Per "Connection State" in PAGI::Spec::Www, undef is the documented "unsupported" signal, so applications testing defined before relying on the value behave correctly; response_started (MUST-level) and on_complete/disconnect_future remain fully supported.
Enabling HTTP/2
# TLS mode (h2 via ALPN)
pagi-server --http2 --ssl-cert cert.pem --ssl-key key.pem --app myapp.pl
# Cleartext mode (h2c)
pagi-server --http2 --app myapp.pl
Requires Net::HTTP2::nghttp2 (XS bindings for the nghttp2 C library).
SECURITY TESTING
Slow HTTP Attack Resistance
PAGI::Server's async architecture provides natural resistance to slow HTTP attacks. Unlike pre-fork servers (Apache, Starman), slow connections don't consume worker processes.
Slowloris Attack (Slow Headers)
Test: 500 connections sending headers very slowly (10 second intervals)
Duration: 30 seconds
Result: Service remained fully available
Status: PASS
Slow POST Attack (Slow Body)
Test: 500 connections sending POST body very slowly
Duration: 30 seconds
Result: Server actively closed slow connections
Status: PASS
Slow Read Attack
Test: 500 connections reading responses very slowly (32 bytes per 5 seconds)
Duration: 30 seconds
Result: Service remained fully available
Status: PASS
Concurrent Attack + Normal Traffic
Test: Slowloris attack while serving normal traffic
Attack: 500 slow connections
Normal traffic results:
- Requests/sec: 3,843
- Total requests: 76,963
- Errors: 0
- p99 latency: 33ms
Status: PASS
HTTP Request Smuggling
PAGI::Server is NOT VULNERABLE to HTTP Request Smuggling attacks.
CL.TE and TE.CL Attack Vectors
When a request presents both Transfer-Encoding and Content-Length, the two are treated as mutually exclusive per RFC 9112 Section 6.3.3 and the request is rejected with 400 Bad Request, so neither header can be used to smuggle data past the other.
Transfer-Encoding Obfuscation
All obfuscation techniques are handled safely:
+-------------------------+-------------------+
| Variant | Result |
+-------------------------+-------------------+
| Normal chunked | TE recognized |
| CHUNKED (uppercase) | TE recognized |
| Space before colon | TE recognized |
| Tab after colon | TE recognized |
| Lowercase header | TE recognized |
| Line folding (obs-fold) | TE recognized |
| Null byte in value | 400 Rejected |
| Vertical tab | 400 Rejected |
| Form feed | 400 Rejected |
+-------------------------+-------------------+
Duplicate Content-Length Headers
+----------------------------------+---------------+
| Test | Result |
+----------------------------------+---------------+
| Duplicate CL (same values) | 400 Rejected |
| Duplicate CL (different values) | 400 Rejected |
| CL with leading zeros | 200 Accepted |
| CL with + sign | 400 Rejected |
+----------------------------------+---------------+
HTTP/2 Rapid Reset (CVE-2023-44487)
PAGI-Server defends the HTTP/2 Rapid Reset attack — a peer creating and immediately resetting streams to bypass SETTINGS_MAX_CONCURRENT_STREAMS and exhaust resources — via a token-bucket rate limit on incoming RST_STREAM frames (nghttp2's mitigation, surfaced as h2_rst_rate_limit). Default budget is burst 1000 / rate 33 per second; on exhaustion the server sends GOAWAY and drops the connection. This is on by default and requires nghttp2 >= 1.57. h2_max_concurrent_streams alone does not stop Rapid Reset (reset streams do not count as concurrent).
Protocol Fuzzing
49/49 fuzzing tests passed. Server never crashed or became unresponsive.
Malformed Request Lines
Empty requests, missing path/version, invalid HTTP version, null bytes in method/path all correctly return 400 Bad Request.
Malformed Headers
Headers without colon, empty header names, null bytes in headers all correctly return 400 Bad Request. Excessive headers (>100) return 431.
Extreme Sizes
+----------------------+-------------------------+
| Test | Result |
+----------------------+-------------------------+
| 1MB URI | Timeout (protected) |
| 1MB header value | Timeout (protected) |
| 100KB single header | 431 Header Too Large |
+----------------------+-------------------------+
Binary/Random Data
Random bytes, all nulls, and mixed binary + HTTP data are handled safely without crashes.
nikto Security Scanner
No critical or high-severity vulnerabilities found. Security headers (X-Frame-Options, X-Content-Type-Options) are application-level responsibilities and can be added using the PAGI::Middleware::SecurityHeaders middleware.
RESOURCE MANAGEMENT
Memory Usage Under Sustained Load
+-----------+------------+--------+-------------+
| Requests | RSS (KB) | Growth | Growth/100K |
+-----------+------------+--------+-------------+
| Initial | 28,392 | - | - |
| 100K | 30,048 | +1,656 | +1,656 |
| 200K | 31,048 | +2,656 | +1,000 |
| 500K | 32,736 | +4,344 | +563 |
| 1,000K | 34,944 | +6,552 | +440 |
+-----------+------------+--------+-------------+
Memory growth of ~6.5 MB over 1 million requests (~6.5 bytes/request) is normal Perl memory allocator behavior, not a leak. Growth rate decreases over time.
File Descriptor Management
+--------------------------------+--------------------+
| Phase | File Descriptors |
+--------------------------------+--------------------+
| Initial (idle) | 35 |
| During load (100 concurrent) | 135 |
| After load (idle) | 35 |
| After 1M requests | 35 |
+--------------------------------+--------------------+
File descriptors return to baseline after connections close.
Connection Object Cleanup
WebSocket frame parsers, connection objects after exceptions, and connections after partial responses are all properly cleaned up.
WEBSOCKET COMPLIANCE (RFC 6455)
Tested against the Autobahn Testsuite v25.10.1, the industry-standard WebSocket conformance test suite.
Results Summary
+---------------+-------+--------------------------------+
| Behavior | Count | Description |
+---------------+-------+--------------------------------+
| OK | 202 | Passed |
| NON-STRICT | 10 | Minor deviations, acceptable |
| INFORMATIONAL | 3 | Informational only |
| FAILED | 86 | Failed tests |
| UNIMPLEMENTED | 216 | Compression (intentional) |
+---------------+-------+--------------------------------+
Pass rate (excluding compression): 215/301 (71%)
Results by Category
+----------+----------------------+----+--------+---------------------------+
| Category | Description | OK | Failed | Notes |
+----------+----------------------+----+--------+---------------------------+
| 1 | Text/Binary Messages | 16 | 0 | All pass |
| 2 | Ping/Pong | 11 | 0 | Control frame size OK |
| 3 | Reserved Bits | 7 | 0 | RSV bits validated |
| 4 | Opcodes | 10 | 0 | Reserved opcodes rejected |
| 5 | Fragmentation | 14 | 6 | Edge cases |
| 6 | UTF-8 Handling | 99 | 42 | Streaming validation |
| 7 | Close Handling | 30 | 1 | Close codes validated |
| 9 | Limits/Performance | 14 | 37 | Large messages (>64KB) |
| 10 | Misc | 1 | 0 | All pass |
| 12-13 | Compression | 0 | 0 | 216 unimplemented |
+----------+----------------------+----+--------+---------------------------+
Validations Implemented in PAGI::Server
RSV Bits (Category 3)
Validates RSV1-3 must be 0 when no extensions are negotiated. Closes connection with 1002 Protocol Error if non-zero.
Reserved Opcodes (Category 4)
Rejects opcodes 3-7 and 11-15 with 1002 Protocol Error.
Control Frame Size (Category 2)
Rejects ping/pong/close frames with payload >125 bytes per RFC 6455 Section 5.5.
Close Code Validation (Category 7)
Validates close codes per RFC 6455 Section 7.4.1:
Valid: 1000-1003, 1007-1011, 3000-4999 Invalid: 0-999, 1004-1006, 1012-2999, 5000+Close Frame Format
Rejects close frames with 1-byte payload (must be 0 or >=2 bytes).
Close Reason UTF-8
Validates close reason is valid UTF-8.
Disconnect
An HTTP/1.1 WebSocket connection enqueues exactly one websocket.disconnect event onto its scope's receive queue -- the same "exactly once" guarantee described above for HTTP/2: whichever closure path fires first -- a peer Close frame (code/reason passed through unchanged, no PAGI token substitution), a TCP close with no close handshake (1006 / client_closed), a keepalive pong timeout (1006 / keepalive_timeout), or a server-initiated protocol close (1002/1007, reason protocol_error) -- marks the connection's disconnect-handled state so no other path can queue a second, conflicting event. As with HTTP/2, this is a property of the receive queue, not a hard cap on what any number of receive() calls can return: once the connection is closed, a further receive() has no queue left to read and falls back to synthesizing a generic websocket.disconnect (code 1006, empty reason) rather than hanging.
Known Limitations
These limitations are in the Protocol::WebSocket library, not PAGI::Server:
UTF-8 Streaming (Category 6)
Invalid UTF-8 in fragmented messages is validated at message end, not per-fragment.
Fragmentation Edge Cases (Category 5)
Complex interleaved control/data frame scenarios.
Large Messages (Category 9)
Default 64KB limit. Configurable via
max_ws_frame_size:my $server = PAGI::Server->new( max_ws_frame_size => 16 * 1024 * 1024, # 16MB );
Compression (Categories 12-13)
PAGI does not implement WebSocket compression (permessage-deflate per RFC 7692). This is optional and intentionally not supported. 216 tests are marked UNIMPLEMENTED, which is the correct behavior.
SERVER-SENT EVENTS (SSE)
PAGI::Server implements Server-Sent Events per PAGI::Spec::Www on both HTTP/1.1 and HTTP/2, sharing behavior across transports wherever the wire format allows it.
Connection Detection
A request is classified as an sse scope by a boolean client-signal check on the combined values of all Accept headers, parsed as a comma-separated list of media ranges (RFC 9110 Section 12.5.1):
the
ssescope is assigned iff the exact rangetext/event-streamappears, case-insensitively, with an effective quality value greater than zero;q=0is an explicit refusal and never signals SSE;wildcard ranges (
*/*,text/*) never signal SSE;media-type parameters other than
qare ignored for the test.
This is a light parse shared identically by both transports -- not full content negotiation, which stays application/middleware territory. It applies regardless of HTTP method: a GET, or a POST from the fetch-event-source/datastar request-then-stream pattern, both classify the same way.
UTF-8 Wire Encoding
sse.send string fields, sse.comment text, and server-generated keepalive comments are formatted as characters and then encoded to UTF-8 exactly once, at the wire boundary, before queueing or HTTP/1.1 chunk framing. For an application-initiated send (sse.send or sse.comment), a Perl string that cannot be encoded fails that send's Future instead of writing corrupted bytes to the socket. Chunk lengths, per-stream send-queue byte counts, and transport watermarks are all measured on the already-encoded byte string.
An sse.keepalive event's optional comment field is checked for the same encodability, but at arm time -- when the sse.keepalive event itself is sent, not deferred to the keepalive timer's first tick. EventValidator requires a present comment to be a defined, non-reference string that round-trips Encode::encode('UTF-8', $comment, Encode::FB_CROAK), croaking "sse.keepalive 'comment' must be a UTF-8-encodable string" otherwise. An unencodable comment therefore fails the send Future that armed the keepalive, rather than escaping as an uncaught die inside the timer tick.
Request Bodies
sse.request (the event an application receives when it reads the body of a POST/PUT SSE request) shares its body-reading semantics with ordinary HTTP requests rather than a separate partial parser:
HTTP/1.1 supports both
Content-Lengthand chunkedTransfer-Encodingbodies, through the same body readerhttp.requestuses;Expect: 100-continueis handled transparently -- the server sends100 Continuebefore waiting for body data;HTTP/2 waits for the stream's
DATAframes andEND_STREAMbefore delivering the (one-shot)sse.requestevent, so a body still arriving across frames is never reported early behind a truthful-lookingmore => 0;an empty-body request receives one empty terminal event, and disconnect stays observable once the body is complete.
Per-Stream HTTP/2 Keepalive and Idle Timeout
As with HTTP/2 WebSocket (see "Keepalive"), an HTTP/2 connection multiplexes many SSE streams, so sse.keepalive and sse_idle_timeout state -- timer, interval, comment, writer, and last-activity reset -- live on the individual stream, not the connection. Starting or updating keepalive on one stream cannot stop, replace, or redirect another stream's timer, and an idle-timeout expiry ends only that stream (the server marks it closing, flushes any already-queued data, then sends the final END_STREAM frame -- the same path an application-initiated sse.close takes). Sibling streams and the connection itself are unaffected.
Over HTTP/1.1, one connection carries only one SSE stream at a time, so sse_idle_timeout is enforced at the connection level there: expiry closes the connection outright.
Response Headers
On sse.start the server supplies Content-Type, Cache-Control, and Date only when the application did not already set them; an application-supplied value for any of these is sent as-is, not duplicated alongside a server default. Connection: keep-alive on HTTP/1.1 is the exception: it is a framing header the protocol requires the server to control, so it is always advertised regardless of what the application sent. HTTP/2 never emits an HTTP/1-only Connection header or a Transfer-Encoding (chunked-framing) header -- an HTTP/2 SSE stream is framed as DATA frames via submit_response_streaming, which carries no such headers.
The Date-only-when-absent rule above holds server-wide, not only for sse.start: on every response path, the server supplies a plain Date header only when a Date is not already present; where the application supplies its own header list, an application-supplied Date is always honored as-is instead of being duplicated. This spans both transports and every response shape the server can emit:
Normal application responses --
http.response.starton HTTP/1.1 and HTTP/2 (including the HTTP/2 HEAD-request path, which reuses the same header list), andsse.starton both transports (above).Application declines -- the
websocket.http.responseextension's custom denial response and thesse.http.response.*decline response, on both transports.Server-generated responses, which carry no application header list to check -- the HTTP/1.1 synthesized error response (e.g. the unanswered-request
500backstop and the bare WebSocket-handshake rejection, which both share that same path); and, HTTP/2 only, the bare WebSocket-handshake-rejection403, the413request-precheck rejection, the413body-size-overrun rejection, the synthesized500, and the plain (non-Extended-CONNECT)CONNECTmethod's501rejection.
HTTP/2 Connection-Specific Header Stripping
RFC 9113 section 8.2.2 forbids connection-specific header fields on HTTP/2: an application-supplied Connection, Keep-Alive, Proxy-Connection, Transfer-Encoding, or Upgrade header -- or a TE header carrying anything but the exact token trailers -- corrupts the response at the framing layer (nghttp2 rejects the header list; the client receives only the :status pseudo-header, with no body). PAGI::Server strips these six header names, case-insensitively, from application-supplied response headers before they reach nghttp2, on every HTTP/2 response path that maps application headers: http.response.start (including the HEAD-request path, which reuses the same header list), sse.start, the websocket.http.response denial, the sse.http.response.* decline, websocket.accept, and the trailing HEADERS block submitted by http.response.trailers (see "HTTP/2 Trailers" above).
TE is the one carve-out, and it applies only to a response's own HEADERS block, where it advertises that trailers are coming: a TE header whose value -- after trimming leading/trailing optional whitespace (OWS) -- is exactly the token trailers is kept there; any other TE value, including a compound value like trailers, gzip, is stripped. That carve-out does not extend inside a trailer block itself -- RFC 9110 section 6.6.2 forbids every connection-specific field, TE included, from a trailer section outright, so http.response.trailers strips a trailer-borne TE unconditionally, regardless of its value. Each stripped occurrence, not deduplicated by name, logs PAGI: connection-specific header '$name' stripped from HTTP/2 response (RFC 9113) for a response header, or PAGI: connection-specific header '$name' stripped from HTTP/2 trailers (RFC 9113) for a trailer field.
HTTP/1.1 performs a narrower, separate strip of its own -- see the next section.
HTTP/1.1 Connection-Specific Header Stripping
The PAGI specification states: "Over HTTP/1.1 the server must ignore or strip application-supplied Transfer-Encoding and Connection -- it supplies its own -- and SHOULD log when it does." Unlike the HTTP/2 strip above, this covers only two header names, Transfer-Encoding and Connection -- HTTP/1.1 has no prohibition on Keep-Alive, Proxy-Connection, Upgrade, or TE as ordinary application response headers, so those four remain untouched.
PAGI::Server strips Transfer-Encoding and Connection, case-insensitively, from application-supplied response headers before they reach the wire, on every HTTP/1.1 response path that maps application headers: http.response.start, sse.start, the websocket.http.response denial, the sse.http.response.* decline, and the extra headers an application supplies on websocket.accept. Before this strip, an application-supplied Transfer-Encoding could duplicate or conflict with the server's own framing decision (two Transfer-Encoding lines, or Transfer-Encoding alongside Content-Length), and an application-supplied Connection could duplicate the server's own forced Connection: keep-alive on sse.start, contradict the guaranteed close that follows an SSE decline or WebSocket denial, or duplicate the server's own Connection: Upgrade on a websocket.accept 101 response (that line is constructed separately and is itself untouched -- RFC 6455 requires it). Each stripped occurrence, not deduplicated by name, logs PAGI: connection-specific header '$name' stripped from HTTP/1.1 response.
The same two-name strip also applies to http.response.trailers, on its own trailer header list, before the trailer block reaches the wire. RFC 9110 section 6.5.1 forbids connection-specific fields in trailers outright, on any HTTP version -- not only the PAGI spec's h1 response-header rule above, so an application-supplied Transfer-Encoding or Connection trailer field is stripped the same way an application-supplied http.response.start header is, logging the same PAGI: connection-specific header '$name' stripped from HTTP/1.1 response line. Unlike the HTTP/2 strip, HTTP/1.1 trailers get no separate log message for the trailer context, and no TE carve-out is relevant here -- TE is not one of the two names HTTP/1.1 strips at all.
Connection Reuse after a Clean Stream End (HTTP/1.1)
sse.start advertises Connection: keep-alive, and the server honors it. When an HTTP/1.1 SSE stream ends cleanly -- the application returns, or it sends sse.close -- the server writes the chunked terminator, resets the per-request state the stream accumulated, and hands the connection back to ordinary keep-alive request handling, including serving any request already pipelined in the read buffer. A pooled client (browser, Net::Async::HTTP, curl) can therefore reuse the same socket for its next request, which matters for the short POST-SSE-exchange pattern used by fetch-event-source and datastar.
Keep-alive yields to the usual overrides, each of which closes the connection the same way it does outside SSE: a client Connection: close, HTTP/1.0 semantics, server shutdown, an application exception, a completed sse.http.response.* decline (it always closes the connection -- no event stream was ever started -- and tells a pooling client with an explicit Connection: close header so it does not reuse the socket), and any abnormal end (client disconnect, idle timeout, write error). An abnormal end is also the only thing that delivers sse.disconnect to the application; a clean end never does. This includes a completed decline: it is not an abnormal end, so it delivers no sse.disconnect either -- an application that calls receive() again after its decline response has already finished (before returning) gets no event at all; the call parks rather than resolving with a synthesized disconnect.
An application that returns without ever answering the request at all -- no sse.start, and no completed decline via sse.http.response.start/sse.http.response.body -- does not reach the keep-alive path. That is the same protocol error the plain HTTP path reports: the server warns, synthesizes a 500 response if the client is still connected, logs the access entry, and closes the connection. A decline that started (sse.http.response.start) but never sent its terminal body has the same unanswerable shape -- its headers were only buffered -- and takes the same path.
IMPLEMENTED PROTECTIONS
Request Parsing
RFC 7230 Section 5.4: HTTP/1.1 Host header requirement - returns 400
RFC 9112 Section 6.3.3: TE and CL are mutually exclusive - returns 400 (smuggling protection)
Request line size limit (8KB default) - returns 414
Header size limit (8KB default) - returns 431
Header count limit (100 default) - returns 431
Content-Length validation - returns 400/413
Duplicate Content-Length rejection - returns 400
Control character rejection in headers - returns 400
WebSocket
Frame size limit (
--max-ws-frame-size, default 64KB)Receive queue limit (
--max-receive-queue, default 1000 messages)RSV bits validation (closes with 1002)
Reserved opcode rejection (closes with 1002)
Control frame size validation (closes with 1002)
Close code validation (closes with 1002)
The websocket scope advertises its enforced limits per "WebSocket Scope" in PAGI::Spec::Www:
max_receive_queueis always present (a hard, always-enforced cap);max_frame_sizeis present whenmax_ws_frame_sizeis nonzero and omitted when configured to0(unlimited), on both HTTP/1.1 and HTTP/2 transports.
Resource Protection
Connection idle timeout (60 seconds default)
Graceful shutdown with timeout (30 seconds default)
Listener backlog limit (2048 default)
ARCHITECTURE ADVANTAGES
PAGI::Server's async architecture provides inherent protection against certain attack classes:
+------------------+--------------+-------------------------+
| Server | Architecture | Slowloris Vulnerability |
+------------------+--------------+-------------------------+
| Apache | Pre-fork | HIGH - Workers exhaust |
| Starman | Pre-fork | HIGH - Workers exhaust |
| Gazelle | Pre-fork | HIGH - Workers exhaust |
| PAGI::Server | Async | LOW - Event loop OK |
| Uvicorn (Python) | Async | LOW - Similar to PAGI |
| Nginx | Event-driven | LOW - Designed for this |
+------------------+--------------+-------------------------+
RECOMMENDATIONS
Production Deployment
For production deployments, consider:
Use a reverse proxy (nginx, HAProxy) for TLS termination, rate limiting, and per-IP connection limits.
Enable the PAGI::Middleware::SecurityHeaders middleware for security headers.
Set appropriate limits for your use case:
my $server = PAGI::Server->new( timeout => 60, # Connection idle timeout max_header_size => 8192, # 8KB header limit max_header_count => 100, # Max headers per request max_body_size => 10_000_000, # 10MB body limit max_ws_frame_size => 1_000_000, # 1MB WebSocket frames shutdown_timeout => 30, # Graceful shutdown timeout );
Performance Optimization
For optimal performance on macOS, set the EV backend to kqueue:
LIBEV_FLAGS=8 pagi-server --loop EV --app myapp.pl
SEE ALSO
PAGI::Server - The main server module
PAGI::Spec::Www - The PAGI HTTP/WebSocket/SSE wire specification
PAGI::Middleware::SecurityHeaders - Security headers middleware
RFC 7230 - HTTP/1.1 Message Syntax and Routing
RFC 7231 - HTTP/1.1 Semantics and Content
RFC 9113 - HTTP/2
RFC 6455 - The WebSocket Protocol
h2spec - HTTP/2 conformance test suite
Autobahn Testsuite - WebSocket test suite
AUTHOR
John Napiorkowski <jjnapiork@cpan.org>
LICENSE
This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.