I can confirm a bug like this in Safari 17.6 (19618.3.11.11.5).
It seems to happen when permessage-deflate
has been negotiated, and the server sends a TEXT
message followed by a BINARY
message, and the BINARY
message contains specific content and spans multiple frames.
The bug seems to occur when the first frame is at least 2,048 bytes and the original data fed to the compressor is "binary-ish". I was unable to reproduce the bug by compressing only alphanumeric characters. Maybe a compression ratio thing.
The following python code implements a server to demonstrate the bug. Connecting with Safari (e.g. by running var ws = new WebSocket('http://localhost:3000');
in the developer console) yields "The operation couldn’t be completed. Protocol error", whereas it works fine in Chrome.
from base64 import b64encode
import hashlib
import socket
import time
import zlib
PORT = 3000
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
RAND_GENERATOR = None
def rand_byte():
global RAND_GENERATOR
if not RAND_GENERATOR:
# from https://en.wikipedia.org/wiki/Linear_congruential_generator
def lcg(modulus, a, c, seed):
"""Linear congruential generator."""
while True:
seed = (a * seed + c) % modulus
yield seed
# glibc-style config
RAND_GENERATOR = lcg(2**31, 1103515245, 12345, 0)
# take most significant 8 bits
return next(RAND_GENERATOR) >> 23
def handle_conn(conn, msgs):
print("Connected by", addr)
req = b""
while b"\r\n\r\n" not in req:
req += conn.recv(1024)
key = ""
ext = ""
for line in req.decode("utf-8").split("\r\n")[1:]:
if not line:
continue
name, value = line.split(":", 1)
if name == "Sec-WebSocket-Key":
key = value.strip()
if name == "Sec-WebSocket-Extensions":
ext = value.strip()
print(f"ext: {ext}")
if "permessage-deflate" not in ext:
raise ValueError("client did not offer permessage-deflate")
accept = b64encode(hashlib.sha1((key + WS_GUID).encode("utf-8")).digest()).decode(
"utf-8"
)
conn.sendall(
(
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {accept}\r\n"
"Sec-WebSocket-Extensions: permessage-deflate\r\n"
"\r\n"
).encode("utf-8")
)
# fin=true rsv1=true opcode=text
header = bytes([0xC1, len(encoded_msgs[0])])
conn.send(header + encoded_msgs[0])
# fin=false rsv1=true opcode=binary
header = bytes([0x42, 0x7E, 0x08, 0x00])
conn.send(header + encoded_msgs[1][:2048])
# fin=true rsv1=false opcode=continuation
header = bytes([0x80, len(encoded_msgs[1]) - 2048])
conn.send(header + encoded_msgs[1][2048:])
# fin=true rsv1=true opcode=text
header = bytes([0xC1, len(encoded_msgs[2])])
conn.send(header + encoded_msgs[2])
time.sleep(1)
plain_msgs = [
b"hello",
bytes([rand_byte() for _ in range(2100)]),
b"world",
]
encoder = zlib.compressobj(wbits=-15)
encoded_msgs = []
for msg in plain_msgs:
msg_enc = encoder.compress(msg) + encoder.flush(zlib.Z_SYNC_FLUSH)
assert msg_enc[-4:] == b"\x00\x00\xff\xff"
msg_enc = msg_enc[:-4]
encoded_msgs.append(msg_enc)
assert len(encoded_msgs[0]) < 126
assert len(encoded_msgs[1]) >= 2048 and len(encoded_msgs[1]) < 2048 + 126
assert len(encoded_msgs[2]) < 126
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", PORT))
s.listen(1)
print(f"Listening on port {PORT}")
while True:
conn, addr = s.accept()
with conn:
handle_conn(conn, encoded_msgs)