Skip to content

Commit a2acd73

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 25a0abb commit a2acd73

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 {
@@ -684,6 +685,12 @@ void Codec::decode(Buffer::Instance& data, bool end_stream) {
684685
// Handshake done, process data.
685686
decoder_.decode(data, end_stream);
686687

688+
if (decoder_.protocol_error_) {
689+
config->stats_.protocol_error_.inc();
690+
decoder_.drain();
691+
return closeOnError("invalid WebSocket control frame");
692+
}
693+
687694
if (end_stream && !decoder_.close_received_) {
688695
decoder_.drain();
689696
return closeOnError("websocket transport closed without CLOSE");
@@ -867,6 +874,7 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
867874

868875
TRY_READ_NETWORK(&frame_header);
869876
const uint8_t opcode = frame_header[0] & OPCODE_MASK;
877+
const bool final_frame = (frame_header[0] & FIN_MASK) != 0;
870878
const bool masked = (frame_header[1] & MASK_MASK) != 0;
871879
uint64_t payload_len = frame_header[1] & PAYLOAD_LEN_MASK;
872880

@@ -888,6 +896,14 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
888896
// Whole header received and decoded
889897
//
890898

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

900916
// Terminate and respond to any control frames
901917

918+
// control frames are always final
919+
if (!final_frame) {
920+
ENVOY_LOG(debug, "websocket decoder: invalid control frame");
921+
goto protocol_error;
922+
}
902923

903924
// Protect against too large control frames that could happen if the decoder ever loses
904925
// sync with the data stream.
@@ -928,6 +949,21 @@ void Codec::Decoder::decode(Buffer::Instance& data, bool end_stream) {
928949

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

958994
protocol_error:
959995
buffer_.drain(buffer_.length());
996+
protocol_error_ = true;
960997
}
961998

962999
} // 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)