Skip to content

v0.12.0

Choose a tag to compare

@ehsanmok ehsanmok released this 05 Oct 17:34
· 174 commits to main since this release

flare v0.12.0 removes the server shims that v0.11 deprecated and fixes 138 bugs found by formalizing the HTTP/1, HTTP/2, HTTP/3, QUIC, TLS, WebSocket, gRPC and runtime code in Lean 4. Each bug has its own commit, a regression test, a docs update, a matching change to the Lean model and a Mojo repro that now reports resolved. Several fixes make flare refuse input it used to accept, so the breaking changes come first. 156 commits since v0.11.0.

By severity the findings are 6 High, 50 Medium, 81 Low and 1 Info. The six High findings were an out-of-bounds read when a UDP datagram came from an IPv6 sender, a use after free in drain, a WebSocket upgrade answered in plaintext on a TLS connection, a connection-level flow-control window that HTTP/2 never checked, a QPACK reference outside the Required Insert Count, and a gRPC length field that wrapped its bounds check.

Breaking changes

Removed server shims

v0.11 announced these for removal, and they are gone.

Removed Use instead
HttpServer.bind_many(addrs, config) HttpServer.bind(addrs, config)
serve_tls(h) and serve_tls(h, n) serve(h) and serve(h, n) on a bind_tls server
serve_ws_upgrade(h, ws_fn, ...) config.ws = WsUpgrade(ws_fn, offload), then serve(h)
serve_static_multicore(resp, n) serve_static(resp, n)
serve_comptime[handler, config]() ServerConfig.check[config]()

tests/http/test_server_serve_comptime.mojo is now test_server_config_check.mojo.

Sessions expire by default

CookieSessionStore, InMemorySessionStore and BackedSessionStore take ttl_s and default it to DEFAULT_SESSION_TTL_S (86400 seconds). Pass ttl_s=0 for the old behavior. A signed cookie now carries an absolute expiry inside the MAC, and load refuses a validly signed cookie that has none, so cookies issued by v0.11 sign their holders out once. BackedSessionStore used to default to a TTL of 0. The stores gained encode_at, load_at and insert_at forms that take the clock, and InMemorySessionStore.sweep(now_s).

TLS session tickets are opt-in

TlsServerConfig.enable_session_tickets defaults to False, and False now really sends no tickets and keeps no server-side session cache. TlsAcceptor.reload() builds a new context with a fresh ticket key, so a ticket issued before a reload does not resume after it. HttpServer.bind_tls takes no TlsServerConfig and is unaffected.

WebSocket

  • WsFrame.decode_one raises WsProtocolError on a reserved opcode (0x3 to 0x7, 0xB to 0xF). The server, client and HTTP/2 receive paths all decode through it, so none of them hands such a frame to the application any more.
  • WsConnection takes part in the closing handshake. recv() answers a received CLOSE before returning it, with the echoed code, an empty CLOSE for an empty body, or 1002 for a 1-byte body, a code that may not appear on the wire or a reason that is not UTF-8. close() sends one CLOSE and does nothing if one already went out. After a CLOSE, send_text, send_binary and send_frame raise NetworkError, and send_frame takes mut self.
  • A final TEXT frame that is not valid UTF-8 closes the connection with 1007. An unmasked client frame closes it with 1002. Before, the socket was dropped with no status.
  • WsClient.recv() and recv_message() raise on a masked frame from the server. WsClient.connect raises WsHandshakeError unless the 101 carries Upgrade: websocket and a Connection field with the upgrade token, and it refuses a response that names a subprotocol or extension the client never offered.
  • The standalone WsServer and the shared-listener upgrade check the whole opening handshake (RFC 6455 section 4.2.1): a GET over HTTP/1.1, Upgrade and Connection matched as token lists (Connection: noupgrade no longer counts), exactly one key that is base64 of 16 bytes, and exactly one version 13. A refused handshake gets 426 or 400 before the close.
  • ServerConfig.ws is ignored on a TLS-terminated listener. The upgrade path writes on the raw socket, so on a bind_tls listener it used to answer the handshake in cleartext on the TLS connection. A wss:// request is now served as ordinary HTTP/1.1 inside TLS. Use a standalone WsServer for wss://.

HTTP/1.1 client

  • A chunked response that ends before the last chunk and the trailing empty line raises NetworkError, on TCP and on TLS. It used to return the bytes read so far as the whole body.
  • get_streaming over HTTPS raises when a close-delimited body ends without close_notify.
  • A response head with a bare LF or an empty line before its end, and a status code of more than three digits, are refused. HTTP/1.1 2041 OK used to become a bodyless 204.
  • A connection is pooled only after an HTTP/1.1 response that did not say Connection: close. An HTTP/1.0 response closes it.
  • RedirectPolicy.same_origin_only() compares hosts case-insensitively, and Location is resolved per RFC 3986 section 5.2.

HTTP/1.1 server

  • A header value that is not valid UTF-8 is a 400, including one that arrives through an obs-fold line. A bare LF inside a chunk-size or trailer line is a 400. A Name : value line is framed the way the lenient parser reads it.
  • drain(timeout_ms) on a single worker waits out the full timeout before it stops the reactor. It used to return early. drain(0) is still a hard stop.
  • Errors from a handler that raises are logged to stderr with the request id (rid=, or - when there is none). The message is still never sent to the client.
  • A response to HEAD carries no body, including the reactor's own error responses and the static fast path.

HTTP/2

The server and client enforce rules they used to skip, so a peer that depended on the laxness gets a connection or stream error. The first frame after the client preface must be SETTINGS. PUSH_PROMISE is a connection error. Field names with non-ASCII or colon bytes, and malformed or conflicting content-length, are rejected. DATA before the response HEADERS is a stream error. The connection-level receive window is enforced.

Fixed

The whole list, with commit hashes, is in formal/report/Fixed.md. By area:

Memory safety and crashes:

  • UDP recvfrom gave the kernel an IPv4-sized address buffer, so an IPv6 sender's address was truncated and the decoder read eight bytes past the buffer. It now reports the right sender.
  • drain freed the stop flag while a detached worker could still read it. It also leaked the joined workers' listeners, closed the shared listener before the workers joined, and left per-worker listeners open when Scheduler.start rolled back.
  • sem_open was called with the wrong ABI on macOS arm64, so the blocking-pool cap was never enforced there. A NULL result on Linux was dereferenced. The pool now fails closed when the semaphore cannot be opened.
  • A length of 2^63-1 on the wire wrapped the bounds check in the gRPC proto reader, which then aborted on an out-of-range index. ByteReader had the same overflow in its own check.
  • A QPACK field section with a Required Insert Count of 0 could still resolve dynamic entry 0 through wrapped indices, and the Sign byte of its prefix was never bounds-checked.
  • The idle io_uring worker now wakes on a timeout and sees the stop flag, and the wakeup read is re-armed after the submission queue is flushed.

HTTP/1.1, URLs and middleware:

  • The URL authority now ends at ? and the fragment at the first #. Url.parse strips userinfo through the last @. urldecode rejects ill-formed UTF-8.
  • Compress leaves partial content alone and sends Vary: Accept-Encoding on identity responses. negotiate_encoding honors the * weight.
  • The CORS allowlist no longer depends on entry order, and every response carries Vary: Origin.
  • RateLimit no longer wraps after a long idle period. CircuitBreaker counts its cooldown from the failure and lets one probe through while half-open.
  • ComptimeRouter treats a * that is not the last segment as matching nothing. The Connection header is read as an option list on every line.
  • An h2c upgrade whose 101 flushes on a later writable edge now migrates, and a partly written 100 Continue is completed.

HTTP/2 and HPACK:

  • Closed, refused, idle and half-closed streams follow RFC 9113 in both roles, including connection credit returned for DATA that a reset discarded.
  • HPACK strings are stored byte for byte, the decode budget counts the last field, and the decoder stays at 4096 until the peer's size update.
  • A client answers an oversized frame with GOAWAY instead of raising, and a GOAWAY shorter than 8 octets is a FRAME_SIZE_ERROR.

QUIC and HTTP/3:

  • Unknown frame types, stream limits above 2^60, ACK ranges below packet number 0 and frames on streams that cannot exist are rejected.
  • The server validates the client's transport parameters, and the client tells an absent connection ID parameter from a zero-length one.
  • The server sends CONNECTION_CLOSE and holds a closing period, sends nothing while draining, and follows RFC 9000 section 10.1 for its idle timer. The client now has an idle timeout too.
  • The two halves of a stream reset independently, and STOP_SENDING is answered with RESET_STREAM.
  • The HTTP/3 server opens its own control stream and sends SETTINGS. An undecodable field section closes the connection with QPACK_DECOMPRESSION_FAILED, and HTTP/2-reserved frame types and settings are refused.

Runtime and networking:

  • IpAddr.is_multicast is exactly ff00::/8, the civil date conversion no longer puts dates before year 0 one day off, and DnsCache saturates a huge TTL.
  • UnixListener.bind removes only a socket whose probe is refused. Non-ASCII socket paths round-trip.
  • write_all and writev_buf_all raise when the kernel makes no progress instead of spinning.
  • TimerWheel.next_fire_ms is capped at the overflow promotion boundary, BufferPool.release drops handles smaller than their class, and the watchdog deadline is clamped to a valid range.

Known limits

  • The server side of WebSocket has no fragment reassembly. 42 of about 450 Autobahn cases still fail, down from 63 in v0.11, and all 42 come from that gap. They are listed one by one in tests/tools/conformance/autobahn-known-fail.txt, and the 21 that now pass are off the list, so CI would catch them failing again.
  • QUIC send pacing is built but not wired, and the congestion window does not gate the HTTP/3 DATA pump.
  • The QPACK dynamic table is dormant. The encoder references no dynamic entry and refuses an insert that would evict one.
  • HTTP/3 serves single-worker only, and the h3 client rejects a request body larger than one packet.
  • The Lean model and its repros live in formal/ and are not part of CI.

Verification

All five CI jobs pass on the tagged commit: tests on Ubuntu and macOS, the ASan build, the lints, and conformance with h2spec and Autobahn. pixi run formal-repros reports 138 resolved and none open. The per-feature details are in docs/features.md at this tag.

Full Changelog: v0.11.0...v0.12.0