From 1634652d37818f6e3041ba4f832a157fddfcafe4 Mon Sep 17 00:00:00 2001 From: Sayed Kaif Date: Tue, 29 Sep 2026 14:07:57 +0530 Subject: [PATCH 1/3] reject an unoffered subprotocol in the ws client handshake --- httplib.h | 35 ++++++++++++++++++++++++++---- test/test.cc | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/httplib.h b/httplib.h index 7622b7402e..bc62fb1c7d 100644 --- a/httplib.h +++ b/httplib.h @@ -8238,9 +8238,11 @@ struct WebSocketUpgradeResponse { std::string selected_subprotocol; }; -inline bool read_websocket_upgrade_response(Stream &strm, - const std::string &expected_accept, - WebSocketUpgradeResponse &upgrade) { +inline bool +read_websocket_upgrade_response(Stream &strm, + const std::string &expected_accept, + const std::string &offered_subprotocols, + WebSocketUpgradeResponse &upgrade) { // Read status line const auto bufsiz = 2048; char buf[bufsiz]; @@ -8297,6 +8299,28 @@ inline bool read_websocket_upgrade_response(Stream &strm, upgrade.selected_subprotocol = proto_it->second; } + // RFC 6455 4.1: the server may name at most one subprotocol, and it has to + // be one the client offered. A value the client never listed (including any + // value at all when the client offered none) means the server steered the + // connection to a subprotocol that was not agreed, so the client must fail + // the handshake rather than hand it to application code via subprotocol(). + // Sec-WebSocket-Accept is already checked strictly just above; this closes + // the matching gap for the negotiated subprotocol. + if (!upgrade.selected_subprotocol.empty()) { + auto was_offered = false; + split(offered_subprotocols.data(), + offered_subprotocols.data() + offered_subprotocols.size(), ',', + [&](const char *b, const char *e) { + if (std::string(b, e) == upgrade.selected_subprotocol) { + was_offered = true; + } + }); + if (!was_offered) { + upgrade.error = Error::WebSocketHandshake; + return false; + } + } + return true; } @@ -10278,7 +10302,10 @@ inline bool perform_websocket_handshake(Stream &strm, Request &req, // Verify 101 response and Sec-WebSocket-Accept header auto expected_accept = websocket_accept_key(client_key); - return read_websocket_upgrade_response(strm, expected_accept, upgrade); + auto offered_subprotocols = + get_combined_header_value(req.headers, "Sec-WebSocket-Protocol"); + return read_websocket_upgrade_response(strm, expected_accept, + offered_subprotocols, upgrade); } inline bool is_ip_address(const std::string &host) { diff --git a/test/test.cc b/test/test.cc index 0cdeaa1bbe..e16e61818a 100644 --- a/test/test.cc +++ b/test/test.cc @@ -23362,6 +23362,67 @@ TEST(WebSocketTest, InvalidUTF8TextFrame) { EXPECT_FALSE(ws::impl::is_valid_utf8("\xF4\x90\x80\x80")); // Beyond U+10FFFF } +TEST(WebSocketTest, RejectsUnofferedSubprotocol) { + // RFC 6455 Section 4.1: the client MUST fail the connection if the server's + // Sec-WebSocket-Protocol names a subprotocol the client did not offer. + const std::string accept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + + auto response = [&](const std::string &protocol_line) { + std::string r = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + + accept + "\r\n"; + r += protocol_line; + r += "\r\n"; + return r; + }; + + // Server selects a subprotocol that was offered: accepted. + { + detail::BufferStream strm; + auto r = response("Sec-WebSocket-Protocol: superchat\r\n"); + strm.write(r.data(), r.size()); + detail::WebSocketUpgradeResponse upgrade; + EXPECT_TRUE(detail::read_websocket_upgrade_response( + strm, accept, "chat, superchat", upgrade)); + EXPECT_EQ("superchat", upgrade.selected_subprotocol); + } + + // Server selects a subprotocol the client never offered: rejected. + { + detail::BufferStream strm; + auto r = response("Sec-WebSocket-Protocol: admin\r\n"); + strm.write(r.data(), r.size()); + detail::WebSocketUpgradeResponse upgrade; + EXPECT_FALSE( + detail::read_websocket_upgrade_response(strm, accept, "chat", upgrade)); + EXPECT_EQ(Error::WebSocketHandshake, upgrade.error); + } + + // Server selects no subprotocol: accepted (the server may decline). + { + detail::BufferStream strm; + auto r = response(""); + strm.write(r.data(), r.size()); + detail::WebSocketUpgradeResponse upgrade; + EXPECT_TRUE( + detail::read_websocket_upgrade_response(strm, accept, "chat", upgrade)); + EXPECT_TRUE(upgrade.selected_subprotocol.empty()); + } + + // Client offered none but the server named one anyway: rejected. + { + detail::BufferStream strm; + auto r = response("Sec-WebSocket-Protocol: chat\r\n"); + strm.write(r.data(), r.size()); + detail::WebSocketUpgradeResponse upgrade; + EXPECT_FALSE( + detail::read_websocket_upgrade_response(strm, accept, "", upgrade)); + EXPECT_EQ(Error::WebSocketHandshake, upgrade.error); + } +} + TEST(WebSocketTest, ConnectAndDisconnect) { Server svr; svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) { From f96534011c53bc2c3ec18752b63b868a86bb8dc7 Mon Sep 17 00:00:00 2001 From: Sayed Kaif Date: Sun, 4 Oct 2026 19:34:08 +0530 Subject: [PATCH 2/3] test the subprotocol check through WebSocketClient so the split build compiles --- test/test.cc | 111 +++++++++++++++++++++++---------------------------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/test/test.cc b/test/test.cc index e16e61818a..df03fc3fb0 100644 --- a/test/test.cc +++ b/test/test.cc @@ -23362,67 +23362,6 @@ TEST(WebSocketTest, InvalidUTF8TextFrame) { EXPECT_FALSE(ws::impl::is_valid_utf8("\xF4\x90\x80\x80")); // Beyond U+10FFFF } -TEST(WebSocketTest, RejectsUnofferedSubprotocol) { - // RFC 6455 Section 4.1: the client MUST fail the connection if the server's - // Sec-WebSocket-Protocol names a subprotocol the client did not offer. - const std::string accept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; - - auto response = [&](const std::string &protocol_line) { - std::string r = "HTTP/1.1 101 Switching Protocols\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - "Sec-WebSocket-Accept: " + - accept + "\r\n"; - r += protocol_line; - r += "\r\n"; - return r; - }; - - // Server selects a subprotocol that was offered: accepted. - { - detail::BufferStream strm; - auto r = response("Sec-WebSocket-Protocol: superchat\r\n"); - strm.write(r.data(), r.size()); - detail::WebSocketUpgradeResponse upgrade; - EXPECT_TRUE(detail::read_websocket_upgrade_response( - strm, accept, "chat, superchat", upgrade)); - EXPECT_EQ("superchat", upgrade.selected_subprotocol); - } - - // Server selects a subprotocol the client never offered: rejected. - { - detail::BufferStream strm; - auto r = response("Sec-WebSocket-Protocol: admin\r\n"); - strm.write(r.data(), r.size()); - detail::WebSocketUpgradeResponse upgrade; - EXPECT_FALSE( - detail::read_websocket_upgrade_response(strm, accept, "chat", upgrade)); - EXPECT_EQ(Error::WebSocketHandshake, upgrade.error); - } - - // Server selects no subprotocol: accepted (the server may decline). - { - detail::BufferStream strm; - auto r = response(""); - strm.write(r.data(), r.size()); - detail::WebSocketUpgradeResponse upgrade; - EXPECT_TRUE( - detail::read_websocket_upgrade_response(strm, accept, "chat", upgrade)); - EXPECT_TRUE(upgrade.selected_subprotocol.empty()); - } - - // Client offered none but the server named one anyway: rejected. - { - detail::BufferStream strm; - auto r = response("Sec-WebSocket-Protocol: chat\r\n"); - strm.write(r.data(), r.size()); - detail::WebSocketUpgradeResponse upgrade; - EXPECT_FALSE( - detail::read_websocket_upgrade_response(strm, accept, "", upgrade)); - EXPECT_EQ(Error::WebSocketHandshake, upgrade.error); - } -} - TEST(WebSocketTest, ConnectAndDisconnect) { Server svr; svr.WebSocket("/ws", [](const Request &, ws::WebSocket &ws) { @@ -24664,6 +24603,56 @@ TEST(WebSocketTest, ClientRejectsResponseWithoutUpgradeToken) { EXPECT_FALSE(client.is_open()); } +TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) { + // RFC 6455 Section 4.1: the client MUST fail the connection if the server's + // Sec-WebSocket-Protocol names a subprotocol the client did not offer. The + // peer answers with an otherwise valid 101, so the subprotocol is the only + // thing left for the client to reject. + Server svr; + svr.Get("/ws", [](const Request &req, Response &res) { + res.status = StatusCode::SwitchingProtocol_101; + res.set_header("Upgrade", "websocket"); + res.set_header("Connection", "Upgrade"); + res.set_header("Sec-WebSocket-Accept", + detail::websocket_accept_key( + req.get_header_value("Sec-WebSocket-Key"))); + res.set_header("Sec-WebSocket-Protocol", "admin"); + }); + + auto port = svr.bind_to_any_port("localhost"); + std::thread t([&]() { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + const auto url = "ws://localhost:" + std::to_string(port) + "/ws"; + + // Server selects a subprotocol the client never offered: rejected. + { + Headers headers = {{"Sec-WebSocket-Protocol", "chat"}}; + ws::WebSocketClient client(url, headers); + + auto res = client.connect(); + EXPECT_FALSE(res); + EXPECT_EQ(Error::WebSocketHandshake, res.error()); + EXPECT_FALSE(client.is_open()); + EXPECT_TRUE(client.subprotocol().empty()); + } + + // Client offered none but the server named one anyway: rejected. + { + ws::WebSocketClient client(url); + + auto res = client.connect(); + EXPECT_FALSE(res); + EXPECT_EQ(Error::WebSocketHandshake, res.error()); + EXPECT_FALSE(client.is_open()); + EXPECT_TRUE(client.subprotocol().empty()); + } +} + TEST(WebSocketTest, HostHeaderOverUnixSocket) { // The socket path doubles as the URL host, so it must not contain '/'. const char *shard = getenv("GTEST_SHARD_INDEX"); From 0e06a98bb19a5759844f5aa3ba4b521288d73cf6 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 4 Oct 2026 18:01:17 -0400 Subject: [PATCH 3/3] Trim comments in the subprotocol check --- httplib.h | 8 +------- test/test.cc | 8 ++------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/httplib.h b/httplib.h index bc62fb1c7d..16b6048375 100644 --- a/httplib.h +++ b/httplib.h @@ -8299,13 +8299,7 @@ read_websocket_upgrade_response(Stream &strm, upgrade.selected_subprotocol = proto_it->second; } - // RFC 6455 4.1: the server may name at most one subprotocol, and it has to - // be one the client offered. A value the client never listed (including any - // value at all when the client offered none) means the server steered the - // connection to a subprotocol that was not agreed, so the client must fail - // the handshake rather than hand it to application code via subprotocol(). - // Sec-WebSocket-Accept is already checked strictly just above; this closes - // the matching gap for the negotiated subprotocol. + // Verify the subprotocol is one the client offered (RFC 6455 4.1) if (!upgrade.selected_subprotocol.empty()) { auto was_offered = false; split(offered_subprotocols.data(), diff --git a/test/test.cc b/test/test.cc index df03fc3fb0..8926a475f7 100644 --- a/test/test.cc +++ b/test/test.cc @@ -24604,10 +24604,6 @@ TEST(WebSocketTest, ClientRejectsResponseWithoutUpgradeToken) { } TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) { - // RFC 6455 Section 4.1: the client MUST fail the connection if the server's - // Sec-WebSocket-Protocol names a subprotocol the client did not offer. The - // peer answers with an otherwise valid 101, so the subprotocol is the only - // thing left for the client to reject. Server svr; svr.Get("/ws", [](const Request &req, Response &res) { res.status = StatusCode::SwitchingProtocol_101; @@ -24629,7 +24625,7 @@ TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) { const auto url = "ws://localhost:" + std::to_string(port) + "/ws"; - // Server selects a subprotocol the client never offered: rejected. + // Server selects a subprotocol the client never offered { Headers headers = {{"Sec-WebSocket-Protocol", "chat"}}; ws::WebSocketClient client(url, headers); @@ -24641,7 +24637,7 @@ TEST(WebSocketTest, ClientRejectsUnofferedSubprotocol) { EXPECT_TRUE(client.subprotocol().empty()); } - // Client offered none but the server named one anyway: rejected. + // Client offered none but the server named one anyway { ws::WebSocketClient client(url);