From 90fc995876fb7fe102868e29e691642e5622f472 Mon Sep 17 00:00:00 2001 From: Antruly <61441059+Antruly@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:26:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(h2):=20=E5=8F=91=E9=80=81=E5=89=8D?= =?UTF-8?q?=E6=8A=8A=20header=20field=20name=20=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP/2 的 field name 在线上必须全小写(RFC 9113 §8.2.1)。之前的 build_response_nv() / build_trailer_nv() / 请求头构建器直接拿原始名字 过 nghttp2_check_header_name():应用设置 `X-Mixed-Case` 这类 Header 时 返回 false,submit_* 返回 UV_EINVAL 且流不会发送,客户端只能等到超时。 uvscope 的 TLS h2 直连就是这样挂死的。 - 在 h2 响应/尾随头/请求头三条出口统一小写化,再校验、组帧 - 小写后的名字放在调用方的 name_store 里,保证 nghttp2_submit_* 期间 指针有效(不能放 builder 的局部变量) - web_ssl_app_h2_func 增加 /mixed-header 回归用例 验证:本地 1.8.2 构建 + 最小 webapp 用例,修复前混合大小写 Header 挂起, 修复后 HTTP/2 200;h3 的 webapp 响应问题另见 PR 描述。 --- src/http2/uvcpp_h2_session.cpp | 71 ++++++++++++++++-------- tests/functional/web_ssl_app_h2_func.cpp | 26 ++++++++- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/src/http2/uvcpp_h2_session.cpp b/src/http2/uvcpp_h2_session.cpp index 28273e0..3baeef8 100644 --- a/src/http2/uvcpp_h2_session.cpp +++ b/src/http2/uvcpp_h2_session.cpp @@ -1052,8 +1052,40 @@ namespace { * 之类的优化,必须先把 `store` 的生命周期延到该帧真的写出去为止 —— 那和现在 * 这个"提交完就扔"的形状是冲突的。 */ +/// HTTP/2 的 field name 在线上必须全小写(RFC 9113 §8.2.1)。应用层经常设置 +/// 混合大小写的名字;在组帧边界统一规范化,否则 nghttp2 的校验会拒掉整个 +/// 头块,流停在“提交过、一个字节都没发”的状态,客户端只能等到超时。 +std::string lower_header_name(const std::string& name) { + std::string out = name; + for (char& c : out) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return out; +} + +/// 校验并追加一个普通字段。`names` 指向 `name_store`;调用方提前 reserve, +/// 保证这些指针在 `nghttp2_submit_*` 之前一直有效。 +bool append_lower_header(const http_header& h, + std::vector& name_store, + std::vector& names, + std::vector& store) { + std::string lower = lower_header_name(h.name); + if (lower.empty() || + !nghttp2_check_header_name( + reinterpret_cast(lower.data()), lower.size()) || + !nghttp2_check_header_value_rfc9113( + reinterpret_cast(h.value.data()), h.value.size())) { + return false; + } + name_store.push_back(std::move(lower)); + names.push_back(name_store.back().c_str()); + store.push_back(h.value); + return true; +} + bool build_response_nv(const uvcpp_http_response& resp, bool omit_body, std::vector& store, + std::vector& name_store, std::vector& nv) { const int status = static_cast(resp.status_code); @@ -1090,14 +1122,7 @@ bool build_response_nv(const uvcpp_http_response& resp, bool omit_body, has_cl = true; continue; // 值已记下,末尾统一发 } - if (!nghttp2_check_header_name( - reinterpret_cast(h.name.data()), h.name.size()) || - !nghttp2_check_header_value_rfc9113( - reinterpret_cast(h.value.data()), h.value.size())) { - return false; - } - names.push_back(h.name.c_str()); - store.push_back(h.value); + if (!append_lower_header(h, name_store, names, store)) return false; } // body 非空却没有 `content-length`:补一个。h2 里它只是参考值, @@ -1149,6 +1174,7 @@ bool build_response_nv(const uvcpp_http_response& resp, bool omit_body, */ bool build_trailer_nv(const http_headers& trailers, std::vector& store, + std::vector& name_store, std::vector& nv) { store.clear(); store.reserve(trailers.size()); @@ -1160,14 +1186,7 @@ bool build_trailer_nv(const http_headers& trailers, if (h.name.empty()) return false; if (h.name[0] == ':') return false; // 伪首部:拒,不剥 if (is_connection_specific(h.name)) continue; // 连接专属:剥掉 - if (!nghttp2_check_header_name( - reinterpret_cast(h.name.data()), h.name.size()) || - !nghttp2_check_header_value_rfc9113( - reinterpret_cast(h.value.data()), h.value.size())) { - return false; - } - names.push_back(h.name.c_str()); - store.push_back(h.value); + if (!append_lower_header(h, name_store, names, store)) return false; } nv.clear(); @@ -1235,8 +1254,10 @@ int uvcpp_h2_session::submit_response(int32_t stream_id, if (status_is_bodyless(static_cast(resp.status_code))) omit_body = true; std::vector store; + std::vector name_store; std::vector nv; - if (!build_response_nv(resp, omit_body, store, nv)) return UV_EINVAL; + name_store.reserve(resp.headers.size()); + if (!build_response_nv(resp, omit_body, store, name_store, nv)) return UV_EINVAL; // 两条退路都必须在**置 SENT 之前**:置了再退,这条流就停在"说过要发、其实 // 一个字节都没发"的状态上,而 nghttp2 那边连流都没建 —— 对端只会一直等。 if (!header_block_fits(nv)) return UV_EMSGSIZE; @@ -1280,10 +1301,12 @@ int uvcpp_h2_session::submit_headers(int32_t stream_id, if (sit->second.state != h2_stream_state::OPEN) return UV_EALREADY; std::vector store; + std::vector name_store; std::vector nv; + name_store.reserve(resp.headers.size()); // `omit_body = true` 走的是"不补 content-length"那一支,正是流式要的: // 长度此刻不知道,补一个就是在说谎。`resp` 里若已写明就原样发出。 - if (!build_response_nv(resp, /*omit_body=*/true, store, nv)) return UV_EINVAL; + if (!build_response_nv(resp, /*omit_body=*/true, store, name_store, nv)) return UV_EINVAL; // 同上:置位之前退,否则流停在"头部发过了"而实际没发。 if (!header_block_fits(nv)) return UV_EMSGSIZE; sit->second.state = h2_stream_state::HEADERS_SENT; @@ -1414,8 +1437,10 @@ int uvcpp_h2_session::submit_trailer(int32_t stream_id, if (!os.chunks.empty()) return UV_EBUSY; std::vector store; + std::vector name_store; std::vector nv; - if (!build_trailer_nv(trailers, store, nv)) return UV_EINVAL; + name_store.reserve(trailers.size()); + if (!build_trailer_nv(trailers, store, name_store, nv)) return UV_EINVAL; // 与另两条提交一样:超限在**动状态之前**退,且退得同步(理由见 // `header_block_fits()` 那段注释 —— nghttp2 把这个错误处理成"静默丢帧 + 关流")。 if (!header_block_fits(nv)) return UV_EMSGSIZE; @@ -1561,13 +1586,15 @@ int32_t uvcpp_h2_session::submit_request(const uvcpp_http_request& req, const http_header& h = req.headers[i]; if (name_is(h.name, "host")) continue; // 已经进了 `:authority` if (is_connection_specific(h.name)) continue; - if (!nghttp2_check_header_name( - reinterpret_cast(h.name.data()), h.name.size()) || + std::string lower = lower_header_name(h.name); + if (lower.empty() || + !nghttp2_check_header_name( + reinterpret_cast(lower.data()), lower.size()) || !nghttp2_check_header_value_rfc9113( reinterpret_cast(h.value.data()), h.value.size())) { return UV_EINVAL; } - names.push_back(h.name); + names.push_back(std::move(lower)); store.push_back(h.value); } diff --git a/tests/functional/web_ssl_app_h2_func.cpp b/tests/functional/web_ssl_app_h2_func.cpp index 1ef136e..f3f8bda 100644 --- a/tests/functional/web_ssl_app_h2_func.cpp +++ b/tests/functional/web_ssl_app_h2_func.cpp @@ -335,6 +335,15 @@ int build_app(uvcpp_web_app& app, app_probe& st, bool set_flag, bool enable_h2, resp.end(); }); + // 回归:HTTP/2 要求 field name 全小写。应用层设置混合大小写的 Header 时, + // 之前的 h2 响应构建器直接返回 false,流永远不结束,客户端只能等到超时。 + app.get("/mixed-header", [](uvcpp_web_request&, uvcpp_web_response& resp, + uvcpp_web_next) { + resp.set_header("X-Mixed-Case", "ok"); + resp.text("mixed-ok"); + resp.end(); + }); + add_stream_routes(app, st); // 场景 4 才挂静态;路由表在 `start_background()` 之后不再变,所以另外几个场景 @@ -634,8 +643,10 @@ void run_h2_requests(h2_client& c, app_probe& st, const char* tag) { check(s1 > 0, std::string(tag) + ": /hello 没提交上"); const int32_t s2 = c.submit(http_method::HTTP_GET, "/echo/abc"); check(s2 > 0, std::string(tag) + ": /echo/abc 没提交上"); + const int32_t s3 = c.submit(http_method::HTTP_GET, "/mixed-header"); + check(s3 > 0, std::string(tag) + ": /mixed-header 没提交上"); - check(c.wait_responses(2), std::string(tag) + ": 两条响应没在 " + + check(c.wait_responses(3), std::string(tag) + ": 三条响应没在 " + std::to_string(kWaitMs) + "ms 内收齐"); uvcpp_test::pump_for(c.loop(), 80); @@ -663,6 +674,19 @@ void run_h2_requests(h2_client& c, app_probe& st, const char* tag) { std::string(tag) + ": /echo/abc body = \"" + r2.body + "\""); } + // HTTP/2 的 field name 必须全小写。应用设置了 `X-Mixed-Case`,框架必须把它 + // 规范化后发出去,而不是丢掉整条响应。 + seen_response r3; + const bool has3 = c.find_response(s3, r3); + check(has3, std::string(tag) + ": 流 " + std::to_string(s3) + " 没有响应"); + if (has3) { + check(r3.end, std::string(tag) + ": /mixed-header 的响应没收完"); + check(r3.status == 200, + std::string(tag) + ": /mixed-header 状态 " + std::to_string(r3.status)); + check(r3.body == "mixed-ok", + std::string(tag) + ": /mixed-header body = \"" + r3.body + "\""); + } + // 服务端侧同号对座:框架处理函数看到的 `stream_id` 必须就是客户端发出去的那个。 // 少了这一半,"响应体对"可能只是路由恰好命中,而流身份其实是错的。 // 对不上时必须**自报家门**:把两边看到的流号都打出来。只报"没匹配上" From 95a9b9564a827497ab6c060e9895f7c4a4ec32ba Mon Sep 17 00:00:00 2001 From: zhuweiye Date: Tue, 27 Oct 2026 21:35:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E8=A1=8C=E5=8F=B7=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=B7=9F=E7=9D=80=20h2=20=E5=A4=B4=E5=90=8D=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E5=8C=96=E5=90=8E=E7=A7=BB=EF=BC=884=20=E5=A4=84?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uvcpp_h2_session.cpp` 的组帧边界新增约 30 行(`lower_header_name` / `append_lower_header`),两条腿之后的引用整体后移。4 条都是同宽平移 (+19 / +23 / +27),没有歧义;锁文件按 `--update` 重刷。 - doc/http2-guide.md: 1216→1235、1322→1345 - doc/http2-status.md: 1210-1216→1229-1235、1691→1718 --- doc/http2-guide.md | 4 ++-- doc/http2-status.md | 4 ++-- tests/tools/doc_line_refs.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/http2-guide.md b/doc/http2-guide.md index 9bf9601..85d6209 100644 --- a/doc/http2-guide.md +++ b/doc/http2-guide.md @@ -383,9 +383,9 @@ void doc_h2_client_submit(uvcpp::uvcpp_h2_connection* conn) { | 项 | 值 | 位置 | |---|---|---| -| 单个待发头部块 | 64 KiB(`H2_MAX_SEND_HEADER_BLOCK`) | `src/http2/uvcpp_h2_common.h:57`、设置点 `src/http2/uvcpp_h2_session.cpp:914-914`、自查 `:1216-1216` | +| 单个待发头部块 | 64 KiB(`H2_MAX_SEND_HEADER_BLOCK`) | `src/http2/uvcpp_h2_common.h:57`、设置点 `src/http2/uvcpp_h2_session.cpp:914-914`、自查 `:1235-1235` | | 单流 body | 64 MiB(`H2_DEFAULT_MAX_BODY_BYTES`)→ RST | `src/http2/uvcpp_h2_common.h:32`、`src/http2/uvcpp_h2_session.cpp:669-669` | -| 单流**待发**队列 | 4 MiB(`H2_DEFAULT_MAX_OUT_STREAM_BYTES`)→ `submit_data` 同步返 `UV_ENOBUFS` | `src/http2/uvcpp_h2_common.h:79`、`src/http2/uvcpp_h2_session.cpp:1322-1322` | +| 单流**待发**队列 | 4 MiB(`H2_DEFAULT_MAX_OUT_STREAM_BYTES`)→ `submit_data` 同步返 `UV_ENOBUFS` | `src/http2/uvcpp_h2_common.h:79`、`src/http2/uvcpp_h2_session.cpp:1345-1345` | | `content-length` 荒谬值 | `> 1<<40` 判非法 | `src/http2/uvcpp_h2_session.cpp:466-466` | | 控制帧令牌桶 | burst 64、补充 32 个/秒 | `src/http2/uvcpp_h2_session.cpp:75-76`、`control_frame_ok()` `:217-217` | | `:scheme` 与实际传输层一致 | TLS 只接受 `https`、明文只接受 `http`,不符即 RST(两个都收等于给混淆代理开门,RFC 9113 §8.1.1) | `src/http2/uvcpp_h2_session.cpp:384-387` | diff --git a/doc/http2-status.md b/doc/http2-status.md index 441d1ba..fd4a0df 100644 --- a/doc/http2-status.md +++ b/doc/http2-status.md @@ -39,7 +39,7 @@ NGHTTP2_ERR_FRAME_SIZE_ERROR`,而那个错误码是 `is_non_fatal` 的 —— 它在上层 被处理成"丢掉整帧、关掉这条流、继续跑",既不通知我们、也不发 RST_STREAM。 本层在 `submit_*` 里按同一个公式先算一遍,换成同步的 `UV_EMSGSIZE` - (`src/http2/uvcpp_h2_session.cpp:1210-1216`)。 + (`src/http2/uvcpp_h2_session.cpp:1229-1235`)。 - **收到对端 GOAWAY 之后不再接受新流。** `on_frame_recv` 记下 `last_stream_id` 与 错误码,`submit_request` 用 `nghttp2_session_check_request_allowed()` 提前拦, 同步返回 `UV_ENOTCONN`;`peer_goaway_received()` 等三个取值函数把它暴露出去。 @@ -314,7 +314,7 @@ NONE"的实现也能全绿;**m3** h1 不在头完成时记状态码 ⇒ `web_h ### 4.3 盘查时判为缺陷、**核下来不是**的(免得下次再盘一遍) - **"本层可能提交超过对端 `SETTINGS_MAX_CONCURRENT_STREAMS` 的并发流"—— 不成立, - 此处更正。** `peer_max_concurrent_streams()`(`src/http2/uvcpp_h2_session.cpp:1691-1691`)确实 + 此处更正。** `peer_max_concurrent_streams()`(`src/http2/uvcpp_h2_session.cpp:1718-1718`)确实 零生产调用方,但 nghttp2 自己就按这个上限**排队**而不是拒绝:超出的请求 HEADERS 留在 `ob_syn`(`nghttp2:nghttp2_session.c:2315,2346` 上的 `session_is_outgoing_concurrent_streams_max()` 闸门),流一关 diff --git a/tests/tools/doc_line_refs.lock b/tests/tools/doc_line_refs.lock index 60a9a06..d48ebbb 100644 --- a/tests/tools/doc_line_refs.lock +++ b/tests/tools/doc_line_refs.lock @@ -227,10 +227,10 @@ src/http2/uvcpp_h2_connection.h:58-58 ae8b03418f41a26f ae8b03418f41a26f - src/http2/uvcpp_h2_connection.h:70-70 d6e15e85dd93eaf0 d6e15e85dd93eaf0 - src/http2/uvcpp_h2_connection.h:74-74 ba98e21b46a7f3c3 ba98e21b46a7f3c3 - src/http2/uvcpp_h2_session.cpp:1002-1002 8a9f9b8835829f00 8a9f9b8835829f00 - -src/http2/uvcpp_h2_session.cpp:1210-1216 cd15d5a39dc61848 9ec3f4c602e73a0a 48d033810df113d5 -src/http2/uvcpp_h2_session.cpp:1216-1216 9ec3f4c602e73a0a 9ec3f4c602e73a0a - -src/http2/uvcpp_h2_session.cpp:1322-1322 b1e5239df8d0f29d b1e5239df8d0f29d - -src/http2/uvcpp_h2_session.cpp:1691-1691 fec5526e48281d9a fec5526e48281d9a - +src/http2/uvcpp_h2_session.cpp:1229-1235 cd15d5a39dc61848 9ec3f4c602e73a0a 48d033810df113d5 +src/http2/uvcpp_h2_session.cpp:1235-1235 9ec3f4c602e73a0a 9ec3f4c602e73a0a - +src/http2/uvcpp_h2_session.cpp:1345-1345 b1e5239df8d0f29d b1e5239df8d0f29d - +src/http2/uvcpp_h2_session.cpp:1718-1718 fec5526e48281d9a fec5526e48281d9a - src/http2/uvcpp_h2_session.cpp:217-217 18f9128271eb71ab 18f9128271eb71ab - src/http2/uvcpp_h2_session.cpp:234-234 ee8859e452fea4f4 ee8859e452fea4f4 - src/http2/uvcpp_h2_session.cpp:360-360 e4ce298e0aeea921 e4ce298e0aeea921 -