Skip to content

Commit ab62d42

Browse files
committed
websocket: Tighten frame decoding to RFC 6455 requirements
Reject WebSocket frames that violate RFC 6455 instead of accepting them or treating malformed input as an orderly tunneled TCP shutdown. Require every client-to-server frame to be masked and every server-to-client frame to be unmasked. Require control frames to be final and limit their payloads to 125 bytes. Validate CLOSE payload structure by rejecting one-byte payloads and requiring the optional reason following the status-code bytes to be valid UTF-8. Keep the status-code bytes opaque so the codec remains compatible with future protocol revisions. Distinguish a valid WebSocket CLOSE from termination of the underlying transport. Treat a transport FIN received without CLOSE, as well as other frame protocol errors, as an abort that closes both tunnel directions immediately. Add integration coverage showing that unmasked client frames, malformed CLOSE payloads, and transport termination without CLOSE are rejected. Signed-off-by: Jarno Rajahalme <jarno@isovalent.com>
1 parent 33cd0eb commit ab62d42

6 files changed

Lines changed: 128 additions & 1 deletion

cilium/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ envoy_cc_library(
160160
"//cilium:accesslog_lib",
161161
"//cilium:filter_state_lib",
162162
"//cilium/api:websocket_cc_proto",
163+
"@com_google_protobuf//third_party/utf8_range:utf8_validity",
163164
"@envoy//bazel/external/http_parser",
164165
"@envoy//envoy/common/crypto:crypto_interface",
165166
"@envoy//source/common/common:base64_lib",

cilium/websocket_codec.cc

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
#include "absl/strings/string_view.h"
3838
#include "cilium/websocket_config.h"
3939
#include "cilium/websocket_protocol.h"
40+
#include "third_party/utf8_range/utf8_validity.h"
4041

4142
namespace Envoy {
4243
namespace Cilium {
@@ -682,6 +683,12 @@ void Codec::decode(Buffer::Instance& data, bool end_stream) {
682683
// Handshake done, process data.
683684
decoder_.decode(data, end_stream);
684685

686+
if (decoder_.protocol_error_) {
687+
config->stats_.protocol_error_.inc();
688+
decoder_.drain();
689+
return closeOnError("invalid WebSocket control frame");
690+
}
691+
685692
if (end_stream && !decoder_.close_received_) {
686693
decoder_.drain();
687694
return closeOnError("websocket transport closed without CLOSE");
@@ -863,6 +870,7 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
863870

864871
TRY_READ_NETWORK(&frame_header);
865872
const uint8_t opcode = frame_header[0] & OPCODE_MASK;
873+
const bool final_frame = (frame_header[0] & FIN_MASK) != 0;
866874
const bool masked = (frame_header[1] & MASK_MASK) != 0;
867875
uint64_t payload_len = frame_header[1] & PAYLOAD_LEN_MASK;
868876

@@ -884,6 +892,14 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
884892
// Whole header received and decoded
885893
//
886894

895+
// RFC 6455 section 5.1 requires client to mask all frames it sends to the server,
896+
// and prohibits server from ever masking any frames is sends to the client.
897+
const bool expected_masked = !parent_.config()->client_;
898+
if (masked != expected_masked) {
899+
ENVOY_LOG(debug, "websocket decoder: invalid frame masking");
900+
goto protocol_error;
901+
}
902+
887903
if (opcode < OPCODE_CLOSE) {
888904
// Unframe and forward all non-control frames
889905
ENVOY_LOG(trace, "websocket decoder: received websocket data: header {} bytes, data {} bytes",
@@ -895,6 +911,11 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
895911

896912
// Terminate and respond to any control frames
897913

914+
// control frames are always final
915+
if (!final_frame) {
916+
ENVOY_LOG(debug, "websocket decoder: invalid control frame");
917+
goto protocol_error;
918+
}
898919

899920
// Protect against too large control frames that could happen if the decoder ever loses
900921
// sync with the data stream.
@@ -924,6 +945,21 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
924945

925946
// validate CLOSE payload if any
926947
if (payload_len > 0) {
948+
if (payload_len == 1) {
949+
ENVOY_LOG(debug, "websocket decoder: invalid CLOSE payload");
950+
goto protocol_error;
951+
}
952+
953+
// we do not interpret the status code to remain compatible with future revisions of the
954+
// WebSocket protocol specification, but we validate that any reason is UTF-8 encoded as
955+
// required.
956+
const absl::string_view reason{reinterpret_cast<const char*>(payload + 2),
957+
static_cast<size_t>(payload_len - 2)};
958+
if (!utf8_range::IsStructurallyValid(reason)) {
959+
ENVOY_LOG(debug, "websocket decoder: invalid CLOSE reason");
960+
goto protocol_error;
961+
}
962+
927963
// store for sending the frame back
928964
if (!close_received_) {
929965
close_payload_.assign(reinterpret_cast<const char*>(payload), payload_len);
@@ -953,6 +989,7 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
953989

954990
protocol_error:
955991
buffer_.drain(buffer_.length());
992+
protocol_error_ = true;
956993
}
957994

958995
} // namespace WebSocket

cilium/websocket_codec.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class Codec : Logger::Loggable<Logger::Id::filter> {
7676

7777
Codec& parent_;
7878
bool close_received_{false};
79+
bool protocol_error_{false};
7980
std::string close_payload_;
8081
Buffer::OwnedImpl buffer_; // Buffer for partial websocket frames
8182
Buffer::OwnedImpl decoded_; // Buffer for decoded websocket frames

cilium/websocket_protocol.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
// Some sensible limits to protect against excess resource use
66
#define WEBSOCKET_HANDSHAKE_MAX_SIZE 4096
7-
#define WEBSOCKET_CONTROL_FRAME_MAX_SIZE 256
7+
#define WEBSOCKET_CONTROL_FRAME_MAX_SIZE 125 // RFC 6455 §5.5
88

99
/* Ref. RFC 6455 */
1010

tests/cilium_websocket_decap_integration_test.cc

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,39 @@ TEST_P(CiliumWebSocketIntegrationTest, AcceptedWebSocket) {
388388
codec_client_->close();
389389
}
390390

391+
TEST_P(CiliumWebSocketIntegrationTest, UnmaskedClientFrameRejected) {
392+
initialize();
393+
auto request_headers = Http::TestRequestHeaderMapImpl{
394+
{":method", "GET"},
395+
{":path", "/"},
396+
{":authority", "host"},
397+
{"Upgrade", "websocket"},
398+
{"Connection", "Upgrade"},
399+
{"Origin", "jarno.cilium.rocks"},
400+
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
401+
{"Sec-WebSocket-Version", "13"},
402+
{"x-request-id", "000000ff-0000-0000-0000-000000000001"},
403+
{"x-envoy-original-dst-host", original_dst_address->asString()}};
404+
codec_client_ = makeHttpConnection(lookupPort("http"));
405+
406+
IntegrationStreamDecoderPtr response = codec_client_->makeHeaderOnlyRequest(request_headers);
407+
FakeRawConnectionPtr fake_upstream_connection;
408+
ASSERT_TRUE(fake_upstreams_[0]->waitForRawConnection(fake_upstream_connection));
409+
response->waitForHeaders();
410+
ASSERT_EQ("101", response->headers().getStatusValue());
411+
412+
// RFC 6455 section 5.1 requires every client-to-server frame to be masked.
413+
const uint8_t unmasked_frame[] = {0x82, 0x05, 'h', 'e', 'l', 'l', 'o'};
414+
Buffer::OwnedImpl frame_buffer(unmasked_frame, sizeof(unmasked_frame));
415+
auto* client_connection = codec_client_->connection();
416+
client_connection->write(frame_buffer, false);
417+
client_connection->dispatcher().run(Event::Dispatcher::RunType::NonBlock);
418+
419+
test_server_->waitForCounterGe("websocket.protocol_error", 1);
420+
ASSERT_TRUE(fake_upstream_connection->waitForDisconnect());
421+
ASSERT_TRUE(codec_client_->waitForDisconnect());
422+
}
423+
391424
TEST_P(CiliumWebSocketIntegrationTest, CloseResponseWaitsForReverseFin) {
392425
enableHalfClose(true);
393426
initialize();
@@ -434,4 +467,37 @@ TEST_P(CiliumWebSocketIntegrationTest, CloseResponseWaitsForReverseFin) {
434467
codec_client_->close();
435468
}
436469

470+
TEST_P(CiliumWebSocketIntegrationTest, InvalidCloseAbortsImmediately) {
471+
initialize();
472+
auto request_headers = Http::TestRequestHeaderMapImpl{
473+
{":method", "GET"},
474+
{":path", "/"},
475+
{":authority", "host"},
476+
{"Upgrade", "websocket"},
477+
{"Connection", "Upgrade"},
478+
{"Origin", "jarno.cilium.rocks"},
479+
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
480+
{"Sec-WebSocket-Version", "13"},
481+
{"x-request-id", "000000ff-0000-0000-0000-000000000001"},
482+
{"x-envoy-original-dst-host", original_dst_address->asString()}};
483+
codec_client_ = makeHttpConnection(lookupPort("http"));
484+
485+
IntegrationStreamDecoderPtr response = codec_client_->makeHeaderOnlyRequest(request_headers);
486+
FakeRawConnectionPtr fake_upstream_connection;
487+
ASSERT_TRUE(fake_upstreams_[0]->waitForRawConnection(fake_upstream_connection));
488+
response->waitForHeaders();
489+
ASSERT_EQ("101", response->headers().getStatusValue());
490+
491+
// RFC 6455 forbids a CLOSE payload of exactly one byte. A protocol error aborts both TCP sides
492+
// instead of waiting for the upstream FIN used by the normal tunnel half-close path.
493+
const uint8_t invalid_masked_close[] = {0x88, 0x81, 0, 0, 0, 0, 0};
494+
Buffer::OwnedImpl close_buffer(invalid_masked_close, sizeof(invalid_masked_close));
495+
auto* client_connection = codec_client_->connection();
496+
client_connection->write(close_buffer, false);
497+
client_connection->dispatcher().run(Event::Dispatcher::RunType::NonBlock);
498+
499+
ASSERT_TRUE(fake_upstream_connection->waitForDisconnect());
500+
ASSERT_TRUE(codec_client_->waitForDisconnect());
501+
}
502+
437503
} // namespace Envoy

tests/cilium_websocket_encap_integration_test.cc

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,28 @@ TEST_P(CiliumWebSocketIntegrationTest, ControlFramesAfterReceivingClose) {
432432
ASSERT_TRUE(fake_upstream_connection->waitForDisconnect());
433433
}
434434

435+
TEST_P(CiliumWebSocketIntegrationTest, WebSocketTransportFinWithoutCloseAborts) {
436+
initialize();
437+
IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("tcp_proxy"));
438+
FakeRawConnectionPtr fake_upstream_connection;
439+
ASSERT_TRUE(fake_upstreams_[0]->waitForRawConnection(fake_upstream_connection));
440+
441+
std::string expected_handshake =
442+
fmt::format(fmt::runtime(EXPECTED_HANDSHAKE_FMT), original_dst_address->asString());
443+
ASSERT_TRUE(fake_upstream_connection->waitForData(expected_handshake.length()));
444+
445+
std::string handshake_response =
446+
fmt::format(fmt::runtime(HANDSHAKE_RESPONSE_FMT), "GjgmQ9MzNsn3h7+vuIzY25rbQ9M=");
447+
ASSERT_TRUE(fake_upstream_connection->write(handshake_response));
448+
449+
// An outer transport FIN without a WebSocket CLOSE is an abort, not a tunneled TCP FIN.
450+
ASSERT_TRUE(fake_upstream_connection->write("", true));
451+
// The raw downstream observes EOF under half-close even though the WebSocket transport was
452+
// aborted immediately.
453+
tcp_client->waitForHalfClose();
454+
ASSERT_TRUE(fake_upstream_connection->waitForDisconnect());
455+
tcp_client->close();
456+
}
435457

436458
// Test proxying data in both directions, and that all data is flushed properly
437459
// when the client disconnects.

0 commit comments

Comments
 (0)