- Zig 89.5%
- Nix 6.8%
- Python 3.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
zig-quic 4cb753b, carrying tls.zig 9a669ab. The handshake for a server
certificate, measured against curl on this machine:
two days ago now
P-256 2 ms 2 ms
RSA-2048 31 ms 6 ms
RSA-3072 59 ms 15 ms
Five times, from two bugs and an omission, none of them in this repository.
Half of every signature was squarings of leading zeros: `std.crypto.ff`'s
`Modulus.pow` sizes its exponent buffer by the type's maximum width, and
supporting 4096-bit keys means instantiating `Modulus(4096)` for every key.
Then the Chinese remainder theorem, which every PKCS#1 and PKCS#8 key carries
the components for and neither implementation used -- two exponentiations
modulo numbers half the width of `n` being a quarter of the work of one
modulo `n`. And when that was implemented nothing changed, because
`PrivateKey.parseDer` walks PKCS#8 by hand and had never read the five
integers it needed; the measurement not moving is what found that.
The CRT is only safe with the check that comes with it, and tls.zig makes it:
every private operation recomputes `out^e mod n` and compares it against its
own input before returning, because a signer that gets one of its two halves
wrong emits a result from which `gcd(out^e - m, n)` is one of the primes.
An elliptic curve certificate is still three times cheaper again and still
the right default.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0141M41NZ9P4WQsMW41VYZGs
|
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
zig-http
An HTTP server library for Zig 0.16 that does not know what it is running on.
The server reads requests from an std.Io.Reader and writes responses to an
std.Io.Writer, plus three facts about the channel that HTTP needs and cannot
read off it. That is the whole transport interface. A TCP listener and an
in-memory one are included; a TLS session, a Unix socket, or a pair of pipes
handed over by inetd would each be about fifty lines and nothing above them
would know the difference.
HTTP/1.0, HTTP/1.1 and HTTP/2 are implemented, and the same handlers serve all three without being recompiled against anything different. HTTP/2 passes 146 of h2spec's 147 conformance cases; the one exception is documented and deliberate.
The API documentation is at https://jeff.jcollie.page/zig-http/. It is generated from the doc comments, which carry most of the explanation in this library — the framing rules in particular are argued out where they are implemented rather than summarised here.
Status
Early. The wire formats are exercised by 220-odd tests, ten property-based
fuzz targets, a real curl, a real h2load and the h2spec conformance suite,
and the framing rules are the ones RFC 9112 and RFC 9113 give — but this has
not served anything in anger, there is no TLS, and there is no client. Read the What is not here section before
depending on it.
Where this lives
The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-http is the web-visible one:
$ git clone https://git.jcollie.dev/jeff/zig-http.git
it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-http, and it is also on the Radicle network, where the repository's identifier is
rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS
and rad clone rad:z2deKU3oTWRwJmw7jzmoG4FAgLNiS fetches it from any node that
seeds it. Any of the three is the whole project.
Adding it to a project
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-http.git
const http = b.dependency("zig_http", .{ .target = target }).module("http");
exe.root_module.addImport("http", http);
A server
const std = @import("std");
const http = @import("http");
fn hello(req: *http.server.Request, res: *http.server.Response) anyerror!void {
try res.setHeader("Content-Type", "text/plain; charset=utf-8");
try res.sendPrint("you asked for {s}\n", .{req.target.path});
}
pub fn main(init: std.process.Init) !void {
var threaded: std.Io.Threaded = .init(init.gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var listener = try http.transport.tcp.Listener.listen(
io,
.{ .ip4 = .loopback(8080) },
.{},
);
defer listener.deinit(io);
var server: http.server.Server = .init(
io,
init.gpa,
listener.listener(),
.initStateless(hello),
.{},
);
try server.serve();
}
zig build run starts a slightly larger version of this — src/main.zig — with
four routes chosen to show one thing each: a body whose length is not known in
advance, a request body read back, a status set by hand, and a handler with
state of its own.
The four layers
The boundaries between them are the point of the library.
protocol — HTTP semantics
Methods, statuses, targets, header fields and dates, with nothing in it about how any of them are written down. RFC 9110 made that separation and this follows it, because it is what makes everything above reusable across versions.
A Method is a string rather than an enumeration, because that is what a
method is: RFC 9110 defines a registry anyone may add to, and a server that
models it as a closed set cannot tell PROPFIND from nonsense — and so cannot
answer the first with 405 and the second with 501.
Target.resolvePath is the security-critical piece: it percent-decodes each
segment on its own, refuses a segment that decodes to something containing a
/ or a NUL, removes . and .. after decoding so that %2e%2e is a dot
segment, and refuses a .. with nothing left to leave rather than silently
discarding it. It must be applied exactly once; the doc comment says why at
length.
transport — where the octets come from
A transport.Connection is a reader, a writer, and Info: the peer address,
the local address, whether the channel is confidential, and what ALPN settled
on. A transport.Listener is a source of them.
transport.memory is a second real implementation, and it is in src rather
than in tests deliberately: a transport-agnostic server whose only transport
is a socket is not transport-agnostic, it is a socket server with an interface
in front of it. Having two is what keeps the seam honest — and it is why the
test suite has no ports, no scheduling and no waiting, and why the fuzzer gets
through hundreds of thousands of requests a second.
http1 and http2 — the wire formats
RFC 9112's request line, field lines, Content-Length and chunked coding;
RFC 9113's frames and streams and RFC 7541's header compression. These are the
only parts of the library that know what any of that looks like, and neither
knows about the other.
That the layer boundary above them holds is now demonstrated rather than asserted: there are two implementations behind it, and neither the handler nor the transport knows which one is running.
server — the accept loop and the handler
Request, Response and Handler, none of which mention a version of HTTP.
A handler declares how long its body is going to be — .none, .{ .exact = n } or .unknown — and the driver turns that into a Content-Length, a
chunked coding, a closed connection, or a sequence of DATA frames. A handler
never writes a Content-Length, never sees a chunk, and cannot set
Connection; the driver overrules all three, because the first two are
framing and the third is an HTTP/1.1 connection-management field that HTTP/2
makes a connection error to send at all.
HTTP/2
On by default. server.Server picks it per connection: by ALPN where the
transport negotiated one, and otherwise by the first four octets, which is
enough to recognise a client using prior knowledge — RFC 9113 §3.4 chose the
PRI method for its connection preface precisely so that test would work.
Since this library has no TLS there is no ALPN, so in practice HTTP/2 arrives
one of two ways: a cleartext prior-knowledge client (curl --http2-prior-knowledge, h2load, nghttp), or a TLS terminator in front
that sets transport.Connection.Info.alpn to h2.
h2c Upgrade is deliberately not implemented, because RFC 9113 removed
it. A client that asks gets an ordinary HTTP/1.1 response rather than a 101;
answering would mean speaking a mechanism no current standard defines, and
there is a test in tests/nixos/curl.nix that pins the behaviour.
RFC 8441 extended CONNECT is implemented, which is what makes
Response.upgrade work over HTTP/2 — see Switching
protocols.
Also not implemented, and each for a reason: server push (deprecated; a
client PUSH_PROMISE is a connection error and SETTINGS_ENABLE_PUSH is
advertised as 0) and RFC 7540 prioritisation (PRIORITY frames are parsed,
checked for the one rule that still bites — a stream may not depend on itself —
and then discarded; RFC 9113 deprecated the scheme and RFC 9218 is the
replacement this does not implement).
Response trailers
res.trailers is a Headers a handler fills any time before the response
ends, for what is not knowable until the content has been produced: a
checksum, a signature, why a stream stopped early. Each version sends it in
the shape it has, and the three are a fair summary of the three protocols.
HTTP/1.1 needs the chunked coding and RFC 9112 §7.1.2's trailer section, which splits the terminator in two: the last chunk, the fields, then the empty line. A response whose length was declared has nowhere to put them, and §6.6.2 of RFC 9110 says so — "a trailer section is only possible when the message framing allows it" — so they are dropped. Dropping is the specified behaviour rather than a shortcut, which is also why §6.5.1 forbids putting anything the recipient needs in one.
HTTP/2 needs RFC 9113 §8.1: a second field block with END_STREAM. HTTP/3 needs RFC 9114 §4.1: a second HEADERS frame, with no flag at all, because the stream ends when QUIC says it does. One layer fewer each time.
/trailers on the demonstration server is the shape of a handler that wants
one: five lines of a body nobody declared the length of, then two fields
saying how many octets went out and what their checksum was. Neither value
exists before the last octet is written, which is the whole reason a trailer
section exists, and it is what makes the route worth having in the virtual
machine tests — curl reads the same two fields over all three framings and
gets the same checksum each time, so the claim that one handler serves three
protocols is checked by something that shares no code with this.
The fields §6.5.1 forbids are refused on the way out rather than trusted to a handler that read the section — anything that would change how a message is framed, routed, cached or authenticated arrives too late to be obeyed, and is a way to tell a cache one thing and whatever is behind it another.
A handler is now called concurrently
This is the one thing HTTP/2 asks of a handler that HTTP/1.1 did not, and it
is easy to miss because the types did not change. HTTP/2 multiplexes, so up to
max_concurrent_streams requests can be inside the same handler, on the same
connection, at the same moment. Any state reached through a handler's context
is shared across tasks.
src/main.zig uses std.atomic.Value for its request counter for exactly
this reason, and the load test asserts the count comes out exactly right —
which a lost increment would break.
A client that stops talking still gets its answers
When the peer's sending half ends, the connection stops being read — but the requests already in flight arrived whole and their responses still have somewhere to go, so the reader waits for those stream tasks before the GOAWAY goes out. What it does not wait for is waiting itself: a handler blocked on a request body that will never arrive sees the end of that body, and one blocked on a send window that will never open is told the connection is gone, since a WINDOW_UPDATE can only arrive on a connection still being read. Credit already granted is still spendable.
A connection error is the other case, and it is not graceful. Nothing further can be understood, so a handler still running is producing octets nobody will ever read, and the streams are stopped rather than waited for.
What a task costs is the caller's choice
Each stream gets a task. Under std.Io.Threaded that is an operating system
thread, so max_concurrent_streams is a per-connection thread bound; under a
fibre-based Io it is nearly free. The default is the conventional 100 and
the right number depends on which Io you supply.
The one thing that is wire-visible
/stream in the demonstration server is a handler that does not know how long
its body will be. Over HTTP/1.1 that costs a chunked transfer coding, over
HTTP/1.0 a closed connection, and over HTTP/2 an ordinary run of DATA frames
with no transfer-encoding at all — which HTTP/2 makes a malformed message.
One handler, three framings, and it never finds out which.
HTTP/3
Implemented, and it needed more than a driver: a QUIC connection is many
independent streams where a transport.Connection is one, so HTTP/3 arrives
with a listener of its own rather than being handed a connection by
server.Server. protocol did not change, which is the whole reason
protocol is where it is — and this is the first time that claim has been
tested against a transport that is not a byte stream at all.
$ zig-http --port 8080 --h3-port 8443 --cert cert.pem --key key.pem
$ curl --http3-only --cacert cert.pem https://localhost:8443/
$ nix build .#checks.x86_64-linux.h3 # the same, in a virtual machine
$ nix build .#checks.x86_64-linux.aioquic # and again, from a second stack
The last of those is where the claim is actually tested. tests/h3.zig drives
a request through the driver over a loopback socket, and every assertion in it
is this library reading what this library wrote — a QUIC handshake, a QPACK
field section and HTTP/3 framing all written against the same understanding as
the code they check, so a disagreement that is self-consistent passes. The
virtual machine test points a curl built with ngtcp2, nghttp3 and OpenSSL at
it instead, over a real network, with a certificate it verifies properly. That
found four things the in-process test could not, one of which — a ClientHello
too large for one packet — aioquic's QUIC suite could not have found either,
because aioquic's fits.
A second one points aioquic at it, which is a different reading of the same
RFCs in a different language with a different QPACK, and which can be told to
send what curl will not. It found that a malformed field section was being
answered with a status code where §4.1.2 requires the stream to be reset, and
then something worse: this server advertised a per-stream flow control window
sixty-four times the one its transport can hold. quic.Connection keeps one
fixed buffer per stream per direction and says in as many words that "a caller
advertising more than this in its transport parameters is promising room it
does not have" — and this was that caller. A peer taking the advertisement at
its word filled the buffer, the connection had nowhere to put the rest, and it
closed with INTERNAL_ERROR. Every HTTP/3 test in the repository passed
throughout, because every body any of them sent fit in one window.
QUIC itself is zig-quic, which is a
dependency rather than part of this: two modules of it, quic for the protocol and quic_io for the loop that
owns a UDP socket and routes datagrams to connections.
What it costs, and how to measure it. Every figure here is from a
--release=safe build, which is what nix build produces and what the
virtual machine tests run. It is worth saying because a plain zig build
produces a Debug binary and the first round of these numbers was measured on
one: the handshake came out ten times slower and the transfer three, which was
enough to point at entirely the wrong thing.
Against a curl on the same machine, a megabyte from /bulk moves at about
40 MB/s and is steady within a few percent; the same handler over HTTP/1.1
moves at about 1.2 GB/s. An upload to /echo runs at about 20 MB/s, which is
two megabytes crossing the connection for every one uploaded. A full handshake
costs about 2 ms with a P-256 certificate, 6 ms with RSA-2048 and 15 ms with
RSA-3072 — so on a short connection the certificate is still most of the cost,
and choosing an elliptic curve key is still worth more than anything else on
this page.
Those RSA figures were 31 ms and 59 ms two days ago, and the five-fold difference is two bugs and an omission, none of them here:
- Every private-key operation went through
std.crypto.ff'sModulus.pow, which sizes the exponent buffer by the type's maximum width rather than the modulus's. Supporting 4096-bit keys means instantiatingModulus(4096)for all of them, so a 2048-bit key had a 256-byte exponent written into a 512-byte buffer and the ladder walked all 512, spending four squarings on every nibble whether it was significant or not. Half of every signature. - The Chinese remainder theorem was not used, though every PKCS#1 and PKCS#8
key carries what it needs. Two exponentiations modulo numbers half the
width of
nare a quarter of the work of one modulon. - And when that was fixed, nothing changed — because
PrivateKey.parseDerwalks PKCS#8 by hand and had never read the five integers it needed.
All three are fixed in tls.zig. The first is also fixed in
std.crypto.ff itself, carried as a patch in
zig-std-crypto-ext along
with a second one — a precedence slip that sends a secret three-byte
exponent down a path that branches on its bits — and meant for upstream.
Signing with the CRT is only safe with the check that comes with it, and
tls.zig now makes it: every private operation recomputes out^e mod n and
compares it against its own input before returning. A signer that gets one of
its two halves wrong emits a result from which gcd(out^e - m, n) is one of
the primes, which is the whole private key from a single bad signature.
That 35 MB/s was six times worse until the body writer stopped sending each
write as its own datagram — the story is in the doc comment on BodyWriter in
src/http3/Connection.zig, and the short version is that a handler writing
sixty-six octet lines was spending a whole packet on each one.
What makes it faster, and what that costs. The per-stream buffers, and the sending one of the two. A download is this server sending, so what bounds it is how much unacknowledged data one stream may hold — §13.3 has a retransmission carry the contents of a lost frame, so an octet stays held until the peer says it arrived. Enlarging the receive buffer alone changes it by nothing measurable.
http3.Config.sizes is where that is chosen, and --h3-window on the
demonstration server turns it. Five runs a setting, a megabyte from /bulk
against curl on the loopback interface:
$ zig-http --h3-port 8443 --h3-window 4 # 41 MB/s (the default)
$ zig-http --h3-port 8443 --h3-window 64 # 54 MB/s
$ zig-http --h3-port 8443 --h3-window 256 # 65 MB/s
Two things are worth knowing before turning it up. The first is that the
window and the loss-tracking table are one decision, not two: the table has to
hold the packets the window puts in the air, and a table that cannot is not a
connection that slows down but one that stops. quic.Connection.init asserts
the floor, Config.sizes derives the table from the window, and neither can
be got wrong quietly any more.
The second is memory, which is charged per connection alive at once rather
than per connection permitted: quic_io.Endpoint furnishes a slot when a
connection claims it, so raising the window costs nothing until somebody
connects. Resident memory for this server, measured with nothing connected and
then with one transfer in flight:
--h3-window |
idle | one connection |
|---|---|---|
| 4 | 10 MB | 12 MB |
| 256 | 10 MB | 21 MB |
| 1024 | 10 MB | 47 MB |
So the number to reason about is the peak concurrency rather than
max_connections: sixteen streams' worth of buffer per connection, times how
many are actually being served. A megabyte-per-stream window costs 37 MB a
connection, which is affordable for a handful and not for a thousand.
Chasing it turned up two deadlocks in zig-quic, both fixed there, and both
reached by going faster rather than by anything going wrong. recovery.Space
remembers sixty-four unacknowledged packets and drops the oldest to make room;
a dropped packet used to take its bytes out of the table and leave them
counted in flight for ever, so the congestion window closed and never
reopened. And its data was never marked to be sent again, so the octets
stayed held in the stream's send buffer, where compact frees only the
acknowledged prefix — one such hole near the front pins the whole buffer, the
stream fills, and the writer waits for room that cannot come. Both look
identical from outside: a connection that stops dead with every thread asleep.
Sixty-four kilobytes hit them every time and forty-eight never did. Both were
worth fixing on their own account — each is a permanent stall reachable by
going faster — but neither raised the ceiling, which is the tracking table's
and was measured afterwards rather than before.
The driver is smaller than the HTTP/2 one, and what is absent is the point.
No connection preface, because QUIC's handshake is one. No flow control, no
WINDOW_UPDATE, no stream table, no MAX_CONCURRENT_STREAMS — §4.6 of RFC 9114
hands all of it to QUIC. No RST_STREAM, because QUIC has one. And no writer
lock, which was the largest single piece of the HTTP/2 driver: there is no
shared byte stream to serialise onto, so the Serializer with its mutex, its
control outbox and the deadlock that outbox exists to prevent has no
counterpart here.
What is left is three unidirectional streams carrying the connection's own business — §6.2's control stream and the two QPACK streams, in each direction — and a task per request.
QPACK is the static table and the literal representations, with a dynamic table capacity of zero. §3.2.2 of RFC 9204 forbids a peer inserting when the advertised capacity is zero, so no field section can ever depend on an insertion, and the Required Insert Count arithmetic, the eviction, the Known Received Count and the blocked streams are absent rather than approximate. It costs compression and not correctness, which is the same trade the HPACK encoder here already makes.
The Huffman code is shared with HPACK rather than transcribed twice, because
§5 of RFC 9204 defines none — it points at RFC 7541 Appendix B and stops. So
it lives in field, along with the rules for turning a field section into a
Request: RFC 9114 §4.3 restates RFC 9113 §8.3 almost word for word, and the
version is the only parameter that differs between the two callers.
A graceful shutdown is §5.2's GOAWAY, and it is the frame that makes
restarting a server safe. The identifier it names is the request this endpoint
has not started, so everything already in flight is answered and everything
after it is refused with H3_REQUEST_REJECTED — a code the client can act on,
because it means the request did not happen. A server that simply closed its
socket would leave every client with a request outstanding unable to tell
whether it took effect, and guessing wrong means either losing a request or
making it twice.
The check on an incoming one is §7.2.6's: a second GOAWAY may only name something lower, because an increasing one would be a peer taking back a promise the frame exists to make reliable.
Extended CONNECT is RFC 9220, which is three pages and most of them are
about what it does not change from RFC 8441: the same setting identifier, the
same :protocol in the request, the same 2xx rather than a 101. So
res.upgrade works here as it does over HTTP/2, and a WebSocket can be
bootstrapped over HTTP/3.
What differs is what a tunnel is made of, and it is simpler each time. Over HTTP/1.1 an upgrade must refuse when the request body has not been read, because those octets sit in the byte stream in front of the new protocol's first one. Over HTTP/2 that check is unnecessary because DATA frames are framed separately. Here it is unnecessary for a stronger reason: the request body and the tunnel are the same QUIC stream, delivered in order by the transport, so there is nothing to be in front of anything.
One trap, which cost an afternoon and is not in the library. A tunnel is
read with one stream call and not with readSliceShort. The latter keeps
asking until the destination is full or the stream ends, which is right for a
message of known length and wrong here: the first ask returns everything that
has arrived, the second blocks on a peer that has not ended its half and is
not going to, and the connection dies of its idle timeout with the data long
since delivered. It looks exactly like a transport that will not carry a
tunnel, and it is a reader asking for more than a tunnel promises.
Not implemented: a dynamic QPACK table, server push, and plain CONNECT,
which answers 501.
HTTP/1.1 framing, and what is refused
Almost everything in the HTTP/1.1 driver is about where a message ends, because that is where HTTP/1.1 is dangerous. Two participants that disagree about it see two different sequences of requests in the same bytes, and the one in front — a cache, a load balancer — can be made to hand the one behind a request the client never sent. RFC 9112 §11.2 calls it request smuggling.
Where the standard offers a choice between rejecting a message and guessing at it, this rejects it:
| Input | Answer |
|---|---|
Content-Length and Transfer-Encoding together |
400, connection closed |
Two Content-Length lines that disagree |
400 |
A Content-Length that is not a plain string of digits |
400 |
Transfer-Encoding whose last coding is not chunked |
400 |
| A coding under the chunking that we cannot decode | 501 |
Transfer-Encoding in an HTTP/1.0 request |
400 |
A field line folded onto the next (obs-fold) |
400 |
| Whitespace between a field name and its colon | 400 |
| A bare CR or LF anywhere in the head | 400 |
| Anything but one space between the parts of the request line | 400 |
HTTP/1.1 with no Host, or two that disagree |
400 |
HTTP/0.9, or a major version above 1 |
505 |
| A head, or a field count, past the configured limit | 431 |
An Expect nobody understands |
417 |
A CONNECT |
501 — see below |
HTTP/2: what is a stream error and what takes the connection
The distinction matters more here than anywhere else in the library: escalating a stream error kills requests that were fine, and demoting a connection error leaves the two ends disagreeing about a compression context they both have to keep in step.
| Input | Answer |
|---|---|
| A preface that is not the preface | GOAWAY, PROTOCOL_ERROR |
| DATA, HEADERS or RST_STREAM on stream 0 | GOAWAY, PROTOCOL_ERROR |
| SETTINGS, PING or GOAWAY on a non-zero stream | GOAWAY, PROTOCOL_ERROR |
| Anything but CONTINUATION between HEADERS and CONTINUATION | GOAWAY, PROTOCOL_ERROR |
| A field block that does not decode | GOAWAY, COMPRESSION_ERROR |
| A frame longer than the size we advertised | GOAWAY, FRAME_SIZE_ERROR |
| An even client stream id, or one that goes backwards | GOAWAY, PROTOCOL_ERROR |
| A frame for a stream the peer itself ended | GOAWAY, STREAM_CLOSED |
| A PUSH_PROMISE from a client | GOAWAY, PROTOCOL_ERROR |
| A CONTINUATION flood, or too many cancelled streams | GOAWAY, ENHANCE_YOUR_CALM |
| A missing, duplicated or misplaced pseudo-header | RST_STREAM, PROTOCOL_ERROR |
| An uppercase field name | RST_STREAM, PROTOCOL_ERROR |
Connection, Keep-Alive, Transfer-Encoding, Upgrade |
RST_STREAM, PROTOCOL_ERROR |
TE other than trailers |
RST_STREAM, PROTOCOL_ERROR |
content-length disagreeing with the DATA sent |
RST_STREAM, PROTOCOL_ERROR |
| More streams at once than we advertised | RST_STREAM, REFUSED_STREAM |
A field section over SETTINGS_MAX_HEADER_LIST_SIZE |
431, on that stream |
:protocol without SETTINGS_ENABLE_CONNECT_PROTOCOL |
RST_STREAM, PROTOCOL_ERROR |
A CONNECT with no :protocol |
501, on that stream |
| A PRIORITY frame | Ignored (RFC 9113 deprecated it) |
| An unknown frame type outside a field block | Ignored (§5.5) |
The 431 in that table is the one row that is not an error code, and it is
deliberate. RFC 9113 §10.5.1 allows a status there, and the easy alternative —
RST_STREAM with REFUSED_STREAM — would be a lie: that code means "nothing
happened here, try another connection", so a client believing it sends the
same oversized request again and gets the same answer forever. The field block
is decoded to the end whatever is done with it, because the peer's dynamic
table has already moved and ours has to move with it.
Two attacks get named defences, because neither is caught by the rules above.
CONTINUATION flooding (CVE-2024-27316) is not "a big field block" but "a
field block that never ends", so the limits are enforced inside the
reassembly loop and each frame's own nine-octet header is charged against the
byte budget — which is what stops a flood of empty CONTINUATION frames.
Rapid reset (CVE-2023-44487) works because a cancelled stream closes at
once and so never counts against max_concurrent_streams; a token budget
charges every RST_STREAM the peer sends and refunds every stream that runs to
completion.
On the way out, a field value carrying a CR or an LF is refused by
Response.setHeader rather than written, and a handler that writes more than
the length it declared is refused at the write rather than at the flush — so
not one octet past the declared length reaches the wire.
Expect: 100-continue is answered when the handler asks for the first octet
of the body and not before, which is the whole point of the expectation: a
handler that is going to answer 401 or 413 does it without the client ever
sending the gigabyte it was offering. A handler that answers without reading
the body closes the connection, because the client is still holding one.
Switching protocols
Response.upgrade stops speaking HTTP on a connection and hands the reader
and the writer to the handler. That is RFC 9110 §7.8's Upgrade mechanism,
and it is what a WebSocket server needs:
fn chat(req: *http.server.Request, res: *http.server.Response) anyerror!void {
if (!req.offersUpgrade("websocket")) return res.sendStatus(.upgrade_required);
try res.setHeader("Sec-WebSocket-Accept", accept_token);
const stream = try res.upgrade("websocket");
// No HTTP left. `stream.input` and `stream.output` are yours until you
// return, and then the connection closes.
}
Three things make it safe to use:
- Nothing is lost behind the handshake. The stream comes back positioned
exactly where the request head ended, so a client that pipelined its first
frames without waiting for the
101still has them read. That is the classic way a protocol switch goes wrong and there is a test for it that delivers the request one octet at a time. - It refuses what RFC 9110 §7.8 says to refuse. A protocol the client did
not list in
Upgrade(withConnection: upgradealongside it), an HTTP/1.0 client, or a request whose body the handler has not read — that last one because the unread octets sit in front of the new protocol's first octet. - A refusal changes nothing.
upgradereturns an error with the response untouched, so the handler goes on to send426 Upgrade Requiredor whatever else it likes.
It is a method rather than a callback registered with the server, and the reason is lifetime. A callback invoked after the handler returned would be handed a connection whose owner had already moved on, so the server would have to keep it alive on the callback's behalf and the two would have to agree about when it was finished. Returning the stream to the handler that is already running keeps the whole thing in one stack frame.
The same handler over HTTP/2
Over HTTP/2 there is no Upgrade — §8.2.2 forbids the fields outright — so
the mechanism is RFC 8441's extended CONNECT: a CONNECT request carrying a
:protocol pseudo-header, which a client may only send after the server has
advertised SETTINGS_ENABLE_CONNECT_PROTOCOL.
The handler above does not change. req.offersUpgrade("websocket")
answers from Upgrade over HTTP/1.1 and from :protocol over HTTP/2, and
res.upgrade("websocket") sends 101 Switching Protocols on one and 200 on
the other. That is the whole argument for a Request that is not a wire
format, and it is the first place in this library where the argument pays for
something a handler could not reasonably have written itself.
Three differences are real and worth knowing:
- A tunnel is one stream, not the connection. Over HTTP/1.1 an upgraded connection is spent: every request that would have been multiplexed with it has to find another socket. Over HTTP/2 the other streams carry on, and there is a test that runs a tunnel and an ordinary request side by side on one connection.
- There is no
Sec-WebSocket-Keyhandshake. RFC 8441 §5 says so explicitly — the pseudo-header replaces it, because a pseudo-header is something noXMLHttpRequest-style client can forge in the first place, which is what theSec-prefix was for. - The request body is the tunnel, so
upgradedoes not refuse an unread one. Over HTTP/1.1 an unread body sits in the byte stream in front of the new protocol's first octet; here the DATA frames are the new protocol.
Setting http2.Config.enable_connect_protocol to false switches the whole
thing off, and then a client that sends :protocol anyway gets a stream
error — which is exactly what RFC 8441 §3 says a non-supporting peer does.
WebSocket framing itself is not here and is not going to be: it is a different wire format that shares nothing with HTTP, and it lives in zig-websocket, which depends on this library and speaks RFC 6455 through the seam described above.
CONNECT is out of scope
It is answered 501 Not Implemented, and that is a decision rather than an
omission.
CONNECT asks a server to open a TCP connection to somewhere else and relay
octets between it and the client (RFC 9110 §9.3.6). Almost none of that is
HTTP. What it needs is a dialling policy, a name resolver, a relay loop, and
answers to the questions that make a proxy a proxy: which hosts and ports may
be reached, by whom, and what is logged. A library that shipped it without
those answers would be shipping an open relay; one that shipped them would be
a proxy rather than a server.
The mechanical half is already here for whoever wants to build that — the
same Response.upgrade that hands a WebSocket handler its stream would hand
a CONNECT handler the same one. What is missing is the proxy, not the seam.
What is not here
- No TLS.
transport.Connection.Info.secureis how a TLS layer would say so, and until something sets it, treat everything sent over this as public. With no TLS there is no ALPN either, so HTTP/2 is reachable only by prior knowledge or from behind a terminator. - No client.
- No dynamic QPACK table, and no
CONNECTover HTTP/3. Both argued in the HTTP/3 section. - No server push and no RFC 9218 prioritisation. Each is argued in the HTTP/2 section.
- No WebSocket framing. The handshake is here, over both versions; RFC 6455 itself is zig-websocket.
- No content codings. A request arriving
Content-Encoding: gzipreaches the handler still compressed, which is correct — a content coding is part of the representation, not the framing. - Trailer fields are consumed and discarded rather than exposed. They arrive after the handler has usually begun its response, so there is nowhere useful to put them; what matters is that they are consumed exactly, so the next request on the connection starts where it should.
- No timeouts. A connection is bounded by its buffer sizes and its request count, not by a clock. Put something in front of it that has one.
Building and testing
The toolchain comes from the flake:
$ nix develop
$ zig build test --summary all
$ zig build # the demonstration server, into zig-out/bin
$ zig build check # compile the things no test builds
The suite has four parts, and tools/docs_server.zig is a fifth: it serves
zig build docs using this library, so reading the documentation exercises
it.
src/** holds the unit tests, next to what they test. tests/e2e.zig drives
whole requests through the HTTP/1.x driver over the memory transport and
asserts on the octets that come back — every case delivered whole, then one
octet at a time, then three, because a parser that only works when its input
arrives all at once is not a parser. tests/tcp.zig does a handful of the
same over a real loopback socket, to check that the pieces underneath are
wired together.
tests/h2.zig is the same idea for HTTP/2, with two differences forced by the
protocol. The Io is real rather than Io.failing: a driver that gives each
stream a task cannot run on one that has no tasks, since a handler reading its
own request body waits for DATA frames only the reader task can deliver. And
the client is written by hand in tests/h2/frames.zig, which imports nothing
from http — so every request in the suite is framed and HPACK-coded by code
sharing nothing with the code under test, and a mistake the server and the
library's own encoder agreed about cannot hide. tests/h2/oracle.zig holds
what must be true of anything the server writes, including the property that
matters most: every field block is contiguous and complete, because HPACK
state is shared by every stream and an interrupted block leaves the peer's
decoder permanently out of step.
Because an HTTP/2 conversation is binary, a failure there prints the whole
exchange decoded frame by frame rather than a hex diff. That is dump(), and
it was written before any of the tests were.
Fuzzing
tests/fuzz.zig holds ten targets, each a property rather than an example:
heads— a head the parser accepted is one whose every piece is well formed, and every field points inside the buffer it came from.paths— no input may produce a path that leaves the root, keeps a dot segment, or holds a NUL.dates— a date this understood survives being written out and read again.fields— the comma-separated list grammar:containsToken, which every framing decision in the driver is made from, must never disagree with a walk of the same list.exchanges— whole requests through the HTTP/1.1 driver, after which the output is parsed back: it must be a whole number of well-framed responses with nothing left over. That is the property request smuggling breaks.hpack— nothing the HPACK decoder returns may point into the field block it was given or into the dynamic table. The block is a buffer the driver reuses; the table is a ring that evicts, and can evict during the very section that references it. Either would be a use-after-free a handler reaches in the ordinary course of its work. The table's recorded size must also equal the sum of its entries, because the peer is keeping the same table by the same rules and a disagreement makes every later index mean something different to each end.h2-requests— a request the server accepted is one no downstream HTTP/1.1 recipient could be made to re-frame: no uppercase name, no CR, LF or NUL in a value, no hop-by-hop field. This is the h2-to-1.1 gateway smuggling class.huffman— every byte string survives coding and reading back, which is the only property that catches a table transcribed one row out of place. A decode-only check would call that a perfectly good decode of something else.frames—Header.parseis total: §4.1 requires unknown types to be discarded and the reserved bit ignored, so there is no nine-octet sequence it may refuse. Parse and write are inverse, andcheckHeaderhas an answer for every header at every maximum frame size.h2-exchanges— whole connections through the HTTP/2 driver, judged by the walker intests/h2/oracle.zig. It is strictly stronger thanexchanges, which has to carve out exceptions because HTTP/1.1 framing is not self-describing: here every octet belongs to a frame whose length is written on the front of it, so the oracle can insist that every field block is contiguous and complete, that no frame arrives for a stream already finished, and that the server never names a stream it could not have opened.
Each is an ordinary test too, so zig build test runs the seeds beside it.
h2-exchanges is the one target that needs a real Io, for the same reason
tests/h2.zig does: a driver that gives each stream a task would refuse every
stream on one that has no tasks, and the target would fuzz the refusal. It is
also where three real defects were found — a RST_STREAM naming a stream
identifier no client can own, two RST_STREAM frames for one stream when the
reader task and a stream task both decided to reset it, and a leak of one
arena chunk per request body, because the arena was copied into the stream
and then allocated from through the original. hpack found a fourth: a
use-after-free when an insertion evicts the very entry its own name was taken
from, which is the case RFC 7541 §4.4 warns about in one sentence that is easy
to read past.
exchanges fuzzes more than the request. The handler behind it is a small
router, so chunked responses, bodiless statuses, interim responses and the
protocol switch are all reached; how many octets a read delivers at a time is
part of the input; and so are the driver's limits, because most of the code
that enforces a limit only runs when something hits it.
Zig 0.16.0 cannot build a test executable in fuzz mode at all — its own
compiler/test_runner.zig hands an error return trace to a function that
takes a different type — and leaves the fuzzer's coverage table empty even
once that is patched. flake.nix carries the one-word patch, with the whole
story in a comment. In the meantime tools/fuzz.zig is a loop written down
honestly: it mutates a corpus, hands the result to a target, and says what
came back.
$ zig build fuzz-run # a minute of each
$ zig build fuzz-run -- --seconds 300 --target exchanges
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target paths
It has already earned its place four times, and not once by finding a bug in the server.
Twice it found that a property was wrong: resolvePath decodes, so its
result is not a request target and cannot be fed back through the parser.
/%2541 resolves to /%41, which resolves again to /A. That is not a
defect to be fixed — it is what percent-decoding is — but it is exactly the
double-decoding traversal bug that keeps getting filed against other people's
servers, and it is now written down at the top of the function.
The fourth time it found a layering mistake in a property I had just written:
hpack asserted that no decoded value carries a CR. But HPACK is a
compression format — RFC 7541 carries whatever octets it is given, and it is
RFC 9113 §8.2.1 that forbids the CR, which makes the request malformed
rather than the connection broken. Asserting it at the decoder would have
conflated a stream error with a connection error and torn down every other
request multiplexed alongside it. The property moved to h2-requests, which
is the layer that enforces it.
The third time it found that the fuzzer had been lying. Smith.value reads
eight bytes as a little-endian u64 and, when that number is outside the
range the type allows, returns the range's minimum rather than reducing it.
The generator was writing two length-prefixed chunks and then noise, so a
target that read one slice and then a u2 found the second chunk's length
header where the number should have been — out of range, every time, for
every seed. The knob answered zero forever and the branch behind it had never
run, while the tool reported millions of iterations a minute. Nothing about
that is visible from the outside, which is the whole problem: the fix was
checked by making the unreached value an error and confirming the fuzzer now
finds it in three thousand inputs and did not before.
The virtual machine tests
nix flake check boots NixOS guests and points real clients at a real server.
There are five, and they ask different questions.
tests/nixos/curl.nix asks whether another implementation agrees.
Everything in the Zig test suite is this library reading what this library
wrote, and a framing mistake that is self-consistent would pass all of it;
curl is strict, has been shown every broken server on the internet, and
reports a framing disagreement as an error rather than quietly recovering. It
now runs every check twice, once over HTTP/1.1 and once with
--http2-prior-knowledge.
tests/nixos/h2spec.nix asks whether the HTTP/2 driver conforms. 147
cases from the RFC 9113 and RFC 7541 conformance suite, run --strict, with
the JUnit report checked against an allowlist that has exactly one entry and
is verified in both directions — a case that starts passing has to leave the
list, or the next real regression hides behind an entry that no longer
describes anything.
That one entry is worth stating, because it is a design decision rather than a
defect. h2spec sends INVALID CONNECTION PREFACE and expects a GOAWAY. This
server shares one cleartext port between HTTP/1.1 and HTTP/2 and tells them
apart by the first four octets — which is exactly what RFC 9113 §3.4 designed
the PRI method to allow. Those four octets are not PRI , so the bytes go
to the HTTP/1.1 driver and get an HTTP/1.1 400, which is the right answer to
that input on a shared port. h2spec is assuming the port is HTTP/2 only.
A handful of h2spec's other cases race this server rather than test it: they send a request and then, without waiting for the response, send the frame whose answer they are checking, expecting that answer to be the next frame on the connection. Each stream is served by a task of its own, so the response can legitimately arrive in between, and h2spec reports the first frame that was not what it wanted. Being timing, it moves — three local runs in a row named three different cases. A conformance failure is deterministic, so anything unexpected is judged by a second whole pass and only a case that fails in both counts; the flakes are printed rather than swallowed, because a case that starts flaking often is a race worth chasing even though it is not a conformance bug.
tests/nixos/h3.nix asks whether curl agrees about HTTP/3, which is the
same question as curl.nix over a transport where nothing about the framing
is shared with the byte-stream drivers. It needs a machine rather than a
process because HTTP/3 needs a certificate and a certificate needs a name that
resolves, which cannot be arranged against 127.0.0.1.
tests/nixos/aioquic.nix asks whether a second HTTP/3 implementation
agrees, and it is the one of these that most obviously earned itself. curl's
stack is ngtcp2, nghttp3 and OpenSSL; aioquic is Python, and its field
compression is Litespeed's lsqpack — a third reading of RFC 9204, in a third
language. It also has an API rather than a command line, so it can be told to
send what no ordinary client would.
That is what found the last conformance bug here. Asked for a field name with
a capital letter in it — which curl lower-cases on the way out, so no
assertion built on curl can reach the rule at all — this server answered 400 Bad Request, where §4.2 and §4.1.2 of RFC 9114 between them require a stream
error of type H3_MESSAGE_ERROR. A status code is a claim that the request was
understood well enough to be answered, which a field section the server has
refused to read plainly was not. It also asks what the server's SETTINGS said,
which curl never reports, and it checks that the octet count in a trailer
field matches the body the client counted for itself.
Beside it is aioquic_conformance.py, which is thirteen rules broken one at a
time by a client with no HTTP/3 layer at all — no SETTINGS first, a second
SETTINGS, a DATA frame where only control frames belong, a control stream the
peer ends, either QPACK stream the peer ends, a second control stream, a
request stream opening with DATA, a SETTINGS frame part way through a request,
a PUSH_PROMISE from a client, a CANCEL_PUSH for a push nobody promised, a
MAX_PUSH_ID that goes backwards, one with something after it, and a field
section over the advertised maximum, which is the one of them that is not a
connection error — §4.2.2 makes it one request's problem, answered 431,
because a reset means "nothing happened, try again" and a client would, for
ever, with the same oversized head. Each
asserts the connection error code and the reason phrase, since the phrase is
the only thing that tells the two H3_FRAME_UNEXPECTED cases apart. This is
standing in for h3spec, which would be the real conformance suite and cannot
be built: its Haskell quic dependency is marked broken in nixpkgs and its
version bounds are not satisfiable there.
tests/nixos/load.nix asks what happens with many clients at once, over
both protocols in one process. Six thousand HTTP/1.1 requests through siege,
then three thousand HTTP/2 requests through h2load at fifty streams per
connection, then bodies in both directions large enough that flow control has
to work. Three assertions only a load test can make:
- Availability is 100%, and the server's own count of what it served matches the client's count of what it asked for, exactly. Under HTTP/2 that count is the sharp end: two requests inside the handler at the same time on the same connection is something only HTTP/2 can do, and a lost atomic increment there is a race the HTTP/1.1 rounds could never reach.
- The open file descriptor count is identical before and after.
- Resident memory is compared between two identical rounds rather than against
the start, so thread-pool ramp is not mistaken for a per-request leak — and
the warm-up runs every kind of load, repeatedly, until the thread count
stops moving. That last part is not optional: a task is an operating system
thread here, and the pool grows with the peak concurrency it has been
asked for, which a fast route never reaches. Warming up on
/alone and then measuring a round containing/bulkreports a 36 MB leak that is two hundred thread stacks.
The load test earned its place immediately. It found that active_streams
never fell for a normally-completed request — the reader's half of a stream
was closed only on RST_STREAM, never on END_STREAM — so every connection
silently stopped accepting new streams after exactly max_concurrent_streams
of them. Twenty connections times a hundred streams is two thousand; a run of
three thousand requests came back with a thousand failures and not one error
logged, because from the server's point of view nothing had gone wrong.
All five need KVM, so none runs on an always-on Forgejo runner. The workflow puts them on an ephemeral DigitalOcean tier instead.
API documentation
Generated from the doc comments, which carry most of the explanation in this library:
$ zig build docs # into zig-out/docs
$ zig build docs-serve # and read them at http://127.0.0.1:8000/
It has to be served rather than opened: the viewer is a WebAssembly program
that fetches its sources at runtime, and a browser refuses that from a
file:// page.
tools/docs_server.zig is that server, and it is built on this library. A
project that ships an HTTP server and then reaches for somebody else's to
serve its own documentation has said something about its library that no
README can take back — and it is the most useful test there is besides, being
a second real consumer of the API doing the thing most consumers will do
(resolve a path, send a file) with a browser as its client.
References cited
Belshe, M., R. Peon, and M. Thomson, eds. 2015. Hypertext Transfer Protocol Version 2 (HTTP/2). RFC 7540. RFC Editor. Obsoleted by RFC 9113; cited for the history of the connection preface.
Berners-Lee, T., R. Fielding, and L. Masinter. 2005. Uniform Resource Identifier (URI): Generic Syntax. RFC 3986. RFC Editor. https://www.rfc-editor.org/info/rfc3986.
Bishop, M., ed. 2022. HTTP/3. RFC 9114. RFC Editor. https://www.rfc-editor.org/info/rfc9114.
Krasic, C., M. Bishop, and A. Frindell, ed. 2022. QPACK: Field Compression
for HTTP/3. RFC 9204. RFC Editor. https://www.rfc-editor.org/info/rfc9204.
The static table of Appendix A and the field line representations of §4.5 are
implemented in src/http3/qpack.zig; §5 defines no Huffman code and defers to
RFC 7541, which is why that table is shared rather than transcribed twice.
Iyengar, J., and M. Thomson, eds. 2021. QUIC: A UDP-Based Multiplexed and
Secure Transport. RFC 9000. RFC Editor.
https://www.rfc-editor.org/info/rfc9000. What HTTP/3 runs on, implemented in
zig-quic rather than here — but its §2.1 stream types, §4.6 stream limits and
§19.19 application error codes all reach into src/http3.
Thomson, M., and S. Turner, eds. 2021. Using TLS to Secure QUIC. RFC 9001.
RFC Editor. https://www.rfc-editor.org/info/rfc9001. Why HTTP/3 has no
cleartext form: QUIC carries TLS and there is no version of it that does not,
so there is no h3c the way there is an h2c.
Dusseault, L., and J. Snell. 2010. PATCH Method for HTTP. RFC 5789. RFC Editor. https://www.rfc-editor.org/info/rfc5789.
Fette, I., and A. Melnikov. 2011. The WebSocket Protocol. RFC 6455. RFC
Editor. https://www.rfc-editor.org/info/rfc6455. The opening handshake in
tests/e2e.zig is the worked example from §1.3. The protocol itself is
implemented in zig-websocket rather than here.
Fielding, R., M. Nottingham, and J. Reschke, eds. 2022. HTTP Semantics. RFC 9110. RFC Editor. https://www.rfc-editor.org/info/rfc9110.
———, eds. 2022. HTTP/1.1. RFC 9112. RFC Editor. https://www.rfc-editor.org/info/rfc9112.
Hinnant, Howard. 2021. chrono-Compatible Low-Level Date Algorithms.
https://howardhinnant.github.io/date_algorithms.html. The source of
days_from_civil and civil_from_days in src/protocol/date.zig.
McManus, P. 2018. Bootstrapping WebSockets with HTTP/2. RFC 8441. RFC
Editor. https://www.rfc-editor.org/info/rfc8441. The :protocol
pseudo-header and SETTINGS_ENABLE_CONNECT_PROTOCOL, which are how
Response.upgrade works over HTTP/2.
Nottingham, M., and R. Fielding. 2012. Additional HTTP Status Codes. RFC
6585. RFC Editor. https://www.rfc-editor.org/info/rfc6585. Where 431 Request Header Fields Too Large comes from.
Oku, K. 2017. An HTTP Status Code for Indicating Hints. RFC 8297. RFC
Editor. https://www.rfc-editor.org/info/rfc8297. 103 Early Hints, which is
what Response.sendInterim exists for.
Peon, R., and H. Ruellan. 2015. HPACK: Header Compression for HTTP/2. RFC
7541. RFC Editor. https://www.rfc-editor.org/info/rfc7541. The Appendix A
static table and the Appendix B Huffman code are transcribed in
src/http2/hpack/, and the Appendix C worked examples run as tests in
src/http2/hpack/rfc7541.zig.
Thomson, M., and C. Benfield, eds. 2022. HTTP/2. RFC 9113. RFC Editor. https://www.rfc-editor.org/info/rfc9113.
Licence
MIT. The project follows the REUSE specification;
nix develop -c reuse lint checks it.