github yhirose/cpp-httplib v0.54.0

2 hours ago

What's Changed

New features

  • Add Server::CustomRoute() for HTTP methods outside the built-in set (#2553). parse_request_line rejects any method not on a fixed whitelist before routing runs, which blocked WebDAV (PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK) and extension methods like UPnP's SUBSCRIBE (open since #847). Registering a handler is now what makes the server accept a method: svr.CustomRoute("PROPFIND", "/dav/:id", handler). Custom methods go through the normal dispatch path, so patterns and req.body work the same as for Get()/Post(), and a HandlerWithContentReader overload is available for methods like PROPPATCH/LOCK that require a body. Method names are validated as RFC 9110 tokens; the ten built-in methods are refused, and a refused registration makes is_valid() return false so listen() fails rather than silently ignoring it. Servers that never call CustomRoute() keep the previous per-request cost. A new cookbook recipe (S23) covers it
  • Add Server::set_static_file_compression(), off by default (Fix #2545, #2572). apply_ranges() only ran the compressor on the res.body-is-non-empty branch, so a response served from a file (via set_mount_point() or Response::set_file_content()) never got compressed, unlike set_content(). Turning the option on runs the file-backed provider through the compressor ahead of the rest of apply_ranges(); ranges are still answered from the identity representation (RFC 9110 applies Range after content coding), and the ETag carries whichever coding the body has so a client that cached the compressed form revalidates correctly. set_static_file_compression_min_length() (default 1400 bytes, one MTU) and set_static_file_compression_max_length() (default 4MB, since the compressed bytes stay in memory until the response is written) bound the feature. Providers registered with set_content_provider() are left alone, since a streaming provider that a caller expects to see written incrementally would instead be held back by zlib's window

Security-relevant fixes

  • Cap the received multipart boundary at RFC 2046's 70 characters (#2565). parse_multipart_boundary only rejected an empty boundary, so a request could declare one up to ~8146 bytes long. FormDataParser's substring scan for the boundary delimiter is O(body length × boundary length), so an oversized boundary turns an otherwise linear scan quadratic: measured at 100MB of - bytes, CPU cost went from 2.59s with a 70-byte boundary to 281.83s with an 8147-byte one. Only the server's receive path is affected; boundaries this library generates are 45 characters
  • Bound the multipart parser's buffer while it waits for a boundary (#2557). FormDataParser accumulated the entire request body when the declared boundary never appeared in it, so the buffer grew to the full payload (100MB by default) and was rescanned on every 16KB read, an O(n²) cost — 50MB of - took 198s of CPU on one core. A single unauthenticated request was enough to trigger it, and the parser runs for any multipart request regardless of whether the handler inspects the result. The buffer is now bounded to the boundary length while waiting, dropping the same case to 0.14s; a boundary split across reads still parses correctly
  • Fix TLS session data race on wss:// WebSocket connections (#2551). A wss:// connection touches one TLS session from several threads (read loop, send()/close(), heartbeat ping), and the existing write_mutex_ only serialized writers, so a reader's SSL_read and a writer's SSL_write ran concurrently on the same session. OpenSSL and the other backends forbid this; it corrupted the record layer, silently dropping messages, and showed up as a heap-buffer-overflow under ASan. Plain ws:// was unaffected. A new WebSocketSSLStream serializes every TLS call with one per-stream mutex, holding the lock only for the CPU-bound TLS call itself so a reader blocked on data never stalls a concurrent sender; ordinary HTTP/HTTPS is untouched

HTTP field parsing: honor list values and repeated field lines

Several list-valued header fields were being matched as if they could only ever hold a single value on a single field line, which both RFC 9110 §5.3 (repeated field lines are equivalent to one comma-joined value) and its per-field grammars (§5.6.1 #-rule lists, §7.6.1 Connection, RFC 6455 §4.2.1 Upgrade) rule out:

  • Combine repeated field lines before parsing Accept, Accept-Encoding, If-None-Match, Sec-WebSocket-Protocol, Trailer and X-Forwarded-For, so a value spread across multiple lines of the same field name is no longer silently truncated to the first occurrence
  • Match Connection options, Upgrade, Expect: 100-continue, and content codings (Content-Encoding, Brotli, Zstandard) as complete, case-insensitive tokens instead of substring or whole-field-value comparisons. Previously "notupgrade" passed as a bare Upgrade token, "Connection: keep-alive, close" matched neither option, an HTTP/1.0 request asking for 100-continue still got a forbidden interim response, and "fibre"/"librarian" were read as Brotli (#2542, #2544)
  • Parse WWW-Authenticate/Proxy-Authenticate as an RFC 9110 challenge list rather than reading only the first challenge, so a Basic challenge listed before Digest no longer hides the Digest one, and two Digest challenges with different parameters no longer mix params
  • Ignore empty list elements in Accept rather than reject the request with 400 (Fix #2567): "Accept: text/html," — a leading, trailing, or doubled comma — is legal per RFC 9110 §5.6.1.2 and was being rejected on every route
  • Validate the bearer scheme in get_bearer_token_auth (#2544)

Bug fixes

  • Respect quoted-strings when splitting header parameters (Fix #2568, #2573). parse_disposition_params() and extract_media_type() split on every ; and = with no awareness that a parameter value can be a quoted-string, so filename="report=v2.pdf" came out as v2.pdf" and a MIME boundary like boundary="----=_NextPart_000_0000_01D9" parsed as _NextPart_000_0000_01D9". A new split_unquoted() treats a delimiter inside a quoted-string as ordinary text
  • Stop a throwing user callback from terminating the server (#2564). Only routing() was wrapped in try/catch; an exception from a content provider, post_routing_handler_, error_handler_, logger_, expect_100_continue_handler_, or a WebSocket handler unwound out of the task queue and terminated the whole process, taking down every other connection with it. Server::serve_guarded() now wraps the serving loop in both process_and_close_socket() overloads; the exception is reported via the error logger as Error::UserCallbackException and only that connection is dropped
  • Do not let a zero-length write end a chunked body (#2563). write_content_chunked()'s sink treated any zero-length write from the provider as "finished," ending the chunked body without the terminating zero-length chunk — even though a provider legitimately writing nothing on a given pass (e.g., a compressor that hasn't produced output yet) is not the end of the message. The response was left unterminated while still reporting success
  • Give DataSink's optional callbacks (is_writable, done, done_with_trailer) safe defaults (#2562). Only write was assigned by every writer; a provider calling one of the other three on a sink where it was left unassigned threw std::bad_function_call from an uncaught context and terminated the process — reachable via the README's own documented idiom
  • Fail a content provider that makes no progress, and fail make_file_body()'s provider specifically when the backing file has been truncated since its length was measured (#2566). Both cases previously spun, re-entering the provider at full speed with an unchanged offset until the peer gave up
  • Fix accept() error handling on Windows (#2561). The accept loop classified failures by reading errno, but Winsock reports them through WSAGetLastError(), so every retry branch was dead code on Windows and any transient accept() failure (e.g., a peer resetting a pending connection) took down listen() entirely
  • Clear svr_sock_ before closing it on the accept loop's fatal path (#2560), matching what stop() already does — otherwise a later stop() could close an unrelated, since-reused descriptor, and worker threads would keep waiting on a listening socket that no longer exists
  • Fix WebSocket::close() racing a concurrent read() on the same stream. close()'s own frame read to drain the peer's Close reply could steal bytes out from under an application thread already inside read(), silently corrupting the in-flight message's content while keeping its declared length. A new read_mutex_, only try_lock'd by close(), leaves the stream entirely to whichever side already owns it
  • Make decode_uri the inverse of encode_uri (#2540). It was previously a byte-for-byte copy of decode_uri_component, decoding reserved-character escapes that encode_uri leaves literal — decode_uri("http://h/a%2Fb") returned .../a/b, promoting an escaped delimiter into a real one. It now matches JS's decodeURI and leaves reserved-set escapes alone

Documentation

  • Clarify that CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNT is enforced only on the buffered Server::read_content() path; the streaming ContentReader path was never in scope but this wasn't documented. The README now shows how to bound the part count from inside a ContentReader handler
  • README/cookbook updates for the WebSocketClient/SSLClient alignment and the new CustomRoute() recipe

Development

  • CI: install Windows OpenSSL from slproweb's own manifest instead of Chocolatey, which hardcoded a versioned URL that 404s every time OpenSSL cuts a new release; also quote the installer's /DIR argument, which was previously split on its embedded space and silently installed to the wrong path
  • CI: only post a flaky-failure comment to #2533 when the test step itself failed, not any step in the job
  • Add static-file, large-body, and TLS workloads to the A/B benchmark harness, so a change to the write path can be measured outside the one case where the response line, headers, and body already share a single write()
  • meson: always use the non_blocking_getaddrinfo option (#2537)

Full Changelog: v0.53.1...v0.54.0

Don't miss a new cpp-httplib release

NewReleases is sending notifications on new releases.