What's Changed
New features
- Add
Server::CustomRoute()for HTTP methods outside the built-in set (#2553).parse_request_linerejects 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'sSUBSCRIBE(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 andreq.bodywork the same as forGet()/Post(), and aHandlerWithContentReaderoverload is available for methods likePROPPATCH/LOCKthat require a body. Method names are validated as RFC 9110 tokens; the ten built-in methods are refused, and a refused registration makesis_valid()return false solisten()fails rather than silently ignoring it. Servers that never callCustomRoute()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 theres.body-is-non-empty branch, so a response served from a file (viaset_mount_point()orResponse::set_file_content()) never got compressed, unlikeset_content(). Turning the option on runs the file-backed provider through the compressor ahead of the rest ofapply_ranges(); ranges are still answered from the identity representation (RFC 9110 appliesRangeafter content coding), and theETagcarries 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) andset_static_file_compression_max_length()(default 4MB, since the compressed bytes stay in memory until the response is written) bound the feature. Providers registered withset_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_boundaryonly 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).
FormDataParseraccumulated 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). Awss://connection touches one TLS session from several threads (read loop,send()/close(), heartbeat ping), and the existingwrite_mutex_only serialized writers, so a reader'sSSL_readand a writer'sSSL_writeran 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. Plainws://was unaffected. A newWebSocketSSLStreamserializes 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,TrailerandX-Forwarded-For, so a value spread across multiple lines of the same field name is no longer silently truncated to the first occurrence - Match
Connectionoptions,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 bareUpgradetoken,"Connection: keep-alive, close"matched neither option, an HTTP/1.0 request asking for100-continuestill got a forbidden interim response, and"fibre"/"librarian"were read as Brotli (#2542, #2544) - Parse
WWW-Authenticate/Proxy-Authenticateas an RFC 9110 challenge list rather than reading only the first challenge, so aBasicchallenge listed beforeDigestno longer hides theDigestone, and twoDigestchallenges with different parameters no longer mix params - Ignore empty list elements in
Acceptrather 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()andextract_media_type()split on every;and=with no awareness that a parameter value can be a quoted-string, sofilename="report=v2.pdf"came out asv2.pdf"and a MIME boundary likeboundary="----=_NextPart_000_0000_01D9"parsed as_NextPart_000_0000_01D9". A newsplit_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 bothprocess_and_close_socket()overloads; the exception is reported via the error logger asError::UserCallbackExceptionand 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). Onlywritewas assigned by every writer; a provider calling one of the other three on a sink where it was left unassigned threwstd::bad_function_callfrom 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 readingerrno, but Winsock reports them throughWSAGetLastError(), so every retry branch was dead code on Windows and any transientaccept()failure (e.g., a peer resetting a pending connection) took downlisten()entirely - Clear
svr_sock_before closing it on the accept loop's fatal path (#2560), matching whatstop()already does — otherwise a laterstop()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 concurrentread()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 insideread(), silently corrupting the in-flight message's content while keeping its declared length. A newread_mutex_, onlytry_lock'd byclose(), leaves the stream entirely to whichever side already owns it - Make
decode_urithe inverse ofencode_uri(#2540). It was previously a byte-for-byte copy ofdecode_uri_component, decoding reserved-character escapes thatencode_urileaves literal —decode_uri("http://h/a%2Fb")returned.../a/b, promoting an escaped delimiter into a real one. It now matches JS'sdecodeURIand leaves reserved-set escapes alone
Documentation
- Clarify that
CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNTis enforced only on the bufferedServer::read_content()path; the streamingContentReaderpath was never in scope but this wasn't documented. The README now shows how to bound the part count from inside aContentReaderhandler - README/cookbook updates for the
WebSocketClient/SSLClientalignment and the newCustomRoute()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
/DIRargument, 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_getaddrinfooption (#2537)
Full Changelog: v0.53.1...v0.54.0