From 1e0018b420da2da7049fa195eba9ac22821add58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Thu, 18 Jun 2026 23:38:05 +0200 Subject: [PATCH 1/3] h2: server: decrement rx_content_remain when draining deferred POST body lws_h2_bind_for_post_before_action() delivers any request-body bytes that were stashed on the stream buflist while the stream was in LRS_DEFERRING_ACTION, decrementing rx_content_length as it goes. But the inline DATA path in lws_read_h1() and the HTTP_BODY_COMPLETION decision both track rx_content_remain (initialized to the full content-length), not rx_content_length. When a request body is split across the DEFERRING_ACTION boundary -- part stashed and dumped here, the rest arriving later on the inline path -- rx_content_remain is never reduced for the stashed part, so it can never reach 0. HTTP_BODY_COMPLETION therefore never fires and the request hangs until it times out. Bodies small enough to be fully stashed complete via the explicit callback at the end of this function, which is why only larger/split POST bodies were affected. Keep rx_content_remain in step with rx_content_length as the stashed body is delivered. --- lib/roles/h2/ops-h2.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/roles/h2/ops-h2.c b/lib/roles/h2/ops-h2.c index 94242557d..111e48091 100644 --- a/lib/roles/h2/ops-h2.c +++ b/lib/roles/h2/ops-h2.c @@ -978,6 +978,21 @@ lws_h2_bind_for_post_before_action(struct lws *wsi) lws_buflist_use_segment(&wsi->buflist, blen); wsi->http.rx_content_length -= blen; + /* + * Keep rx_content_remain in step with the body we just + * delivered from the deferred buflist. The inline DATA path in + * lws_read_h1() and the HTTP_BODY_COMPLETION decision both track + * rx_content_remain (initialized to the full content-length); + * if we only decrement rx_content_length here, a body that is + * split across the DEFERRING_ACTION boundary (some stashed here, + * the rest arriving later inline) leaves rx_content_remain stuck + * at the stashed byte count, so completion never fires and the + * request times out. + */ + if (wsi->http.rx_content_remain >= blen) + wsi->http.rx_content_remain -= blen; + else + wsi->http.rx_content_remain = 0; } if (!wsi->buflist) From bf9b5f709a9d949b7c1c8044865be7fe6cca7d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Fri, 19 Jun 2026 08:21:01 +0200 Subject: [PATCH 2/3] h2: capture unknown (custom) headers into the UHO store The lws_hdr_custom_*() accessors read unknown headers out of the ah's UHO linked list, but that list was only ever populated by the h1 text header parser; for h2 the accessors early-returned on mux_substream and custom headers were invisible (commit da8995bb6 disabled the speculative h1 path for substreams). The h1 path can't be reused as-is for h2: the header name arrives through hpack one character at a time (fed to lws_parse purely for known-header recognition) and the value never passes through the h1 unknown-value collector at all, so neither the name nor the value were being stored. Build the UHO entry directly in the hpack decoder instead, where both the name and value bytes are available: - reserve the UHO entry header before the name (speculatively, per the h1 approach), - collect the name as it decodes, finalizing it (with the trailing ':' the accessors expect) and linking it into unk_ll when lws does not recognize the header, or dropping it if it does, - collect the value bytes that the IGNORE-entry path otherwise discards, - record the value length when the value completes. To keep a single writer of ah->pos during substream name parsing, the h1 text parser no longer also lays the name bytes down for mux substreams. Relax the mux_substream guard on the three lws_hdr_custom_*() accessors so the now-populated list is readable for h2. --- lib/roles/h2/hpack.c | 90 +++++++++++++++++++++++++++++++++++++++- lib/roles/http/parsers.c | 21 +++++++--- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/lib/roles/h2/hpack.c b/lib/roles/h2/hpack.c index fbb6294ab..0a8e628cb 100644 --- a/lib/roles/h2/hpack.c +++ b/lib/roles/h2/hpack.c @@ -1070,6 +1070,27 @@ int lws_hpack_interpret(struct lws *wsi, unsigned char c) ah->parser_state = WSI_TOKEN_NAME_PART; ah->lextable_pos = 0; h2n->unknown_header = 0; +#if defined(LWS_WITH_CUSTOM_HEADERS) + /* + * h2 doesn't use the h1 header text parser to lay down + * the unknown-header storage (the name comes in via + * hpack one char at a time and the value never passes + * through the h1 value collector). So for substreams + * we build the UHO entry ourselves as the name/value + * are decoded: speculatively reserve the entry header + * before the name now, then finalize (or abandon, if it + * turns out to be a header lws knows) at name complete. + */ + if (wsi->mux_substream && !h2n->value) { + ah->unk_pos = 0; + if (ah->pos + UHO_NAME < + wsi->a.context->max_http_header_data) { + ah->unk_pos = ah->pos; + for (n = 0; n < UHO_NAME; n++) + ah->data[ah->pos++] = 0; + } + } +#endif break; } @@ -1181,8 +1202,14 @@ int lws_hpack_interpret(struct lws *wsi, unsigned char c) __func__); return 1; } - } //else - //lwsl_header("ignoring %c\n", c1); + } +#if defined(LWS_WITH_CUSTOM_HEADERS) + else if (wsi->mux_substream && ah->unk_pos && + ah->unk_value_pos && ah->pos + 1 < + wsi->a.context->max_http_header_data) + /* collect unknown-header value byte */ + ah->data[ah->pos++] = (char)c1; +#endif } else { /* * Convert name using existing parser, @@ -1206,6 +1233,19 @@ int lws_hpack_interpret(struct lws *wsi, unsigned char c) "Uppercase literal hpack hdr"); return 1; } +#if defined(LWS_WITH_CUSTOM_HEADERS) + /* + * Speculatively collect the name into the UHO + * entry we reserved. We must do this before + * lws_parse() below: if it recognizes a known + * header it does lws_frag_start() using the + * current ah->pos, which has to already point + * past the name we are stashing here. + */ + if (ah->unk_pos && + ah->pos + 1 < wsi->a.context->max_http_header_data) + ah->data[ah->pos++] = (char)c1; +#endif plen = 1; if (!h2n->unknown_header && lws_parse(wsi, &c1, &plen)) @@ -1259,6 +1299,38 @@ int lws_hpack_interpret(struct lws *wsi, unsigned char c) } } +#if defined(LWS_WITH_CUSTOM_HEADERS) + /* + * The name part just completed. If lws didn't recognize it, + * finalize the UHO entry we have been building (the name is + * stored with a trailing ':' to match the h1 storage format, + * which is what the lws_hdr_custom_*() accessors expect) and + * link it into the unknown-header list. Otherwise drop the + * speculative name (the recognized header's value frag was + * already located past it by the lws header parser). + */ + if (wsi->mux_substream && ah->unk_pos && !h2n->value) { + if (h2n->unknown_header && + ah->pos + 1 < wsi->a.context->max_http_header_data) { + ah->data[ah->pos++] = ':'; + lws_ser_wu16be((uint8_t *)&ah->data[ah->unk_pos + + UHO_NLEN], + (uint16_t)((ah->pos - ah->unk_pos) - + UHO_NAME)); + if (!ah->unk_ll_head) + ah->unk_ll_head = ah->unk_pos; + if (ah->unk_ll_tail) + lws_ser_wu32be((uint8_t *)&ah->data[ + ah->unk_ll_tail + UHO_LL], + ah->unk_pos); + ah->unk_ll_tail = ah->unk_pos; + ah->unk_value_pos = ah->pos; + } else + /* known header, or no room: drop capture */ + ah->unk_pos = 0; + } +#endif + /* we have the header */ if (!h2n->value) { h2n->value = 1; @@ -1355,6 +1427,20 @@ int lws_hpack_interpret(struct lws *wsi, unsigned char c) if (lws_hpack_handle_pseudo_rules(nwsi, wsi, m)) return 1; +#if defined(LWS_WITH_CUSTOM_HEADERS) + /* + * Value part complete: if we were collecting an unknown + * header, record the value length to finish the UHO entry. + */ + if (wsi->mux_substream && ah->unk_pos && ah->unk_value_pos) { + lws_ser_wu16be((uint8_t *)&ah->data[ah->unk_pos + + UHO_VLEN], + (uint16_t)(ah->pos - ah->unk_value_pos)); + ah->unk_pos = 0; + ah->unk_value_pos = 0; + } +#endif + h2n->is_first_header_char = 1; h2n->hpack = HPKS_TYPE; break; diff --git a/lib/roles/http/parsers.c b/lib/roles/http/parsers.c index 18919a1c4..be0caf237 100644 --- a/lib/roles/http/parsers.c +++ b/lib/roles/http/parsers.c @@ -96,6 +96,7 @@ _lws_header_table_reset(struct allocated_headers *ah) ah->lextable_pos = 0; ah->unk_pos = 0; #if defined(LWS_WITH_CUSTOM_HEADERS) + ah->unk_value_pos = 0; ah->unk_ll_head = 0; ah->unk_ll_tail = 0; #endif @@ -612,7 +613,7 @@ lws_hdr_custom_length(struct lws *wsi, const char *name, int nlen) { ah_data_idx_t ll; - if (!wsi->http.ah || wsi->mux_substream) + if (!wsi->http.ah) return -1; ll = wsi->http.ah->unk_ll_head; @@ -638,7 +639,7 @@ lws_hdr_custom_copy(struct lws *wsi, char *dst, int len, const char *name, ah_data_idx_t ll; int n; - if (!wsi->http.ah || wsi->mux_substream) + if (!wsi->http.ah) return -1; *dst = '\0'; @@ -671,7 +672,7 @@ lws_hdr_custom_name_foreach(struct lws *wsi, lws_hdr_custom_fe_cb_t cb, { ah_data_idx_t ll; - if (!wsi->http.ah || wsi->mux_substream) + if (!wsi->http.ah) return -1; ll = wsi->http.ah->unk_ll_head; @@ -1221,10 +1222,18 @@ lws_parse(struct lws *wsi, unsigned char *buf, int *len) #endif } - if (lws_pos_in_bounds(wsi)) - return LPR_FAIL; + /* + * For mux (h2) substreams the hpack decoder captures + * the header name itself (including building the + * unknown-header storage), so we must not also lay the + * name bytes down here and double-advance ah->pos. + */ + if (!wsi->mux_substream) { + if (lws_pos_in_bounds(wsi)) + return LPR_FAIL; - ah->data[ah->pos++] = (char)c; + ah->data[ah->pos++] = (char)c; + } pos = ah->lextable_pos; #if defined(LWS_WITH_CUSTOM_HEADERS) From bf64032565631fab4db80ba18ee508d8426be456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Fri, 19 Jun 2026 09:47:21 +0200 Subject: [PATCH 3/3] h2: server: complete empty request body signalled by END_STREAM on HEADERS A client sends "no request body" over HTTP/2 by setting END_STREAM on the HEADERS frame and omitting Content-Length (e.g. an empty POST). lws_http_action() re-derives the body expectation from the Content-Length header and the method: for a body-bearing method with no Content-Length it defaults rx_content_length to 100MB and waits for DATA frames that, after END_STREAM, will never arrive -- so the request stalls until it times out. (An explicit "Content-Length: 0" works, because that takes the explicit-zero path.) For a mux (h2) substream whose END_STREAM already arrived with the HEADERS and that carried no Content-Length, treat the body as an explicit zero-length body, exactly as "Content-Length: 0" would. h3 already does the equivalent in ops-h3.c. Fixes empty-body POST/PUT/PATCH over h2 hanging server-side (reproduces with `curl --http2 -X POST` against any lws h2 server). --- lib/roles/http/server/server.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/roles/http/server/server.c b/lib/roles/http/server/server.c index efa48d487..466283b06 100644 --- a/lib/roles/http/server/server.c +++ b/lib/roles/http/server/server.c @@ -1910,6 +1910,24 @@ lws_http_action(struct lws *wsi) lwsl_debug("%s: explicit 0 content-length\n", __func__); } } +#if defined(LWS_ROLE_H2) + else if (lwsi_role_h2(wsi) && wsi->mux_substream && wsi->h2.END_STREAM) { + /* + * h2 with no Content-Length, but END_STREAM already arrived on + * the HEADERS: the request body is empty and complete. Without + * this, a body-bearing method (POST/PUT/PATCH) would be left + * waiting on the 100MB default above for a body that will never + * come, stalling the request until it times out. Treat it as an + * explicit zero-length body, exactly as an explicit + * "Content-Length: 0" would (h3 does the equivalent in ops-h3.c). + */ + wsi->http.rx_content_remain = wsi->http.rx_content_length = 0; + wsi->http.content_length_given = 1; + wsi->http.content_length_explicitly_zero = 1; + lwsl_debug("%s: h2 END_STREAM, no content-length: empty body\n", + __func__); + } +#endif if (wsi->mux_substream) { wsi->http.request_version = HTTP_VERSION_2;