From 345408d2044fcd4338892cc6a578d01ae1fafe65 Mon Sep 17 00:00:00 2001 From: "William T. Nelson" <35801+wtn@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:38:22 -0600 Subject: [PATCH] Truncate close frame reasons to fit in a control frame. Co-authored-by: Claude Opus 5.5 --- lib/protocol/websocket/close_frame.rb | 6 ++++++ test/protocol/websocket/close_frame.rb | 19 +++++++++++++++++++ test/protocol/websocket/connection.rb | 11 +++++++++++ 3 files changed, 36 insertions(+) diff --git a/lib/protocol/websocket/close_frame.rb b/lib/protocol/websocket/close_frame.rb index f718f22..ad53caa 100644 --- a/lib/protocol/websocket/close_frame.rb +++ b/lib/protocol/websocket/close_frame.rb @@ -12,6 +12,7 @@ module WebSocket class CloseFrame < Frame OPCODE = 0x8 FORMAT = "na*" + MAXIMUM_REASON_LENGTH = 123 # Unpack the frame data into a close code and reason. # @returns [Tuple(Integer, String)] The close code and reason. @@ -45,6 +46,7 @@ def unpack # Pack a close code and reason into the frame data. # If code is missing, reason is ignored. + # The reason is truncated to 123 bytes. # @parameter code [Integer | Nil] The close code. # @parameter reason [String | Nil] The close reason. def pack(code = nil, reason = nil) @@ -53,6 +55,10 @@ def pack(code = nil, reason = nil) reason = reason.encode(Encoding::UTF_8) end + if reason and reason.bytesize > MAXIMUM_REASON_LENGTH + reason = reason.byteslice(0, MAXIMUM_REASON_LENGTH).scrub("") + end + super([code, reason].pack(FORMAT)) else super() diff --git a/test/protocol/websocket/close_frame.rb b/test/protocol/websocket/close_frame.rb index c70b46e..4402cd4 100644 --- a/test/protocol/websocket/close_frame.rb +++ b/test/protocol/websocket/close_frame.rb @@ -30,6 +30,25 @@ expect(frame.payload).to be == "\x03\xE8Hello World".b end + + it "truncates the reason to fit in a control frame" do + frame.pack(1000, "x" * 200) + + expect(frame.payload.bytesize).to be == 125 + + code, reason = frame.unpack + expect(code).to be == 1000 + expect(reason).to be == "x" * 123 + end + + it "doesn't split a multibyte character when truncating the reason" do + frame.pack(1000, ("x" * 122) + "é") + + expect(frame.payload.bytesize).to be == 124 + + code, reason = frame.unpack + expect(reason).to be == "x" * 122 + end end with "#unpack" do diff --git a/test/protocol/websocket/connection.rb b/test/protocol/websocket/connection.rb index 158bddf..7d3df0e 100644 --- a/test/protocol/websocket/connection.rb +++ b/test/protocol/websocket/connection.rb @@ -69,6 +69,17 @@ expect(connection).to be(:closed?) end + it "truncates a long error message to fit in the close frame" do + connection.close_write(RuntimeError.new("x" * 200)) + + frame = client.read_frame + expect(frame).to be_a(Protocol::WebSocket::CloseFrame) + + code, reason = frame.unpack + expect(code).to be == Protocol::WebSocket::Error::INTERNAL_ERROR + expect(reason).to be == "x" * 123 + end + it "is not closed when close_write succeeds" do connection.close_write