Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/13206.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed leading path separators in ``Content-Disposition`` not being stripped in some circumstances -- by :user:`arshsmith1`.
1 change: 1 addition & 0 deletions CHANGES/13274.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed the WebSocket reader rejecting a compressed data frame with close code 1002 when a control frame arrived before the first data frame (regression in 3.14.2) -- by :user:`Dreamsorcerer`.
34 changes: 21 additions & 13 deletions aiohttp/_websocket/reader_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,22 +402,30 @@ def _feed_data(self, data: bytes) -> None:
"Control frame payload cannot be larger than 125 bytes",
)

# Set compress status if last package is FIN
# OR set compress status if this is first fragment
# Raise error if not first fragment with rsv1 = 0x1
if self._frame_fin or self._compressed == COMPRESSED_NOT_SET:
self._compressed = COMPRESSED_TRUE if rsv1 else COMPRESSED_FALSE
elif rsv1:
raise WebSocketError(
WSCloseCode.PROTOCOL_ERROR,
"Received frame with non-zero reserved bits",
)

# Control frames (opcode > 0x7) may be interleaved between the
# fragments of a data message.
# fragments of a data message and never carry the per-message
# compressed bit, so they must not touch the compression state.
# https://datatracker.ietf.org/doc/html/rfc6455#section-5.4
if opcode <= 0x7:
# https://datatracker.ietf.org/doc/html/rfc7692#section-6.1
if opcode > 0x7:
if rsv1:
raise WebSocketError(
WSCloseCode.PROTOCOL_ERROR,
"Received frame with non-zero reserved bits",
)
else:
# Set compress status if last package is FIN
# OR set compress status if this is first fragment
# Raise error if not first fragment with rsv1 = 0x1
if self._frame_fin or self._compressed == COMPRESSED_NOT_SET:
self._compressed = COMPRESSED_TRUE if rsv1 else COMPRESSED_FALSE
elif rsv1:
raise WebSocketError(
WSCloseCode.PROTOCOL_ERROR,
"Received frame with non-zero reserved bits",
)
self._frame_fin = bool(fin)

self._frame_opcode = opcode
self._has_mask = bool(has_mask)
self._payload_len_flag = length
Expand Down
6 changes: 3 additions & 3 deletions aiohttp/multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def unescape(text: str, *, chars: str = "".join(map(re.escape, CHAR))) -> str:
continue

try:
value = unquote(value, encoding, "strict")
value = unquote(value, encoding, "strict").lstrip("\\/")
except (builtins.LookupError, UnicodeDecodeError):
# The charset is attacker-controlled here; an unknown name
# raises the builtin LookupError (the bare name is shadowed in
Expand Down Expand Up @@ -214,14 +214,14 @@ def content_disposition_filename(
encoding, _, value = value.split("'", 2)
encoding = encoding or "utf-8"
try:
return unquote(value, encoding, "strict")
return unquote(value, encoding, "strict").lstrip("\\/")
except (builtins.LookupError, UnicodeDecodeError):
# Both the charset name and the octets are attacker-controlled
# here; an unknown encoding raises the builtin LookupError
# (shadowed in this module by payload.LookupError) and
# undecodable bytes raise UnicodeDecodeError.
return None
return value
return value.lstrip("\\/")


class MultipartResponseWrapper:
Expand Down
46 changes: 45 additions & 1 deletion tests/test_multipart_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,23 @@ def test_attwithfn2231abspathdisguised(self) -> None:
"attachment; filename*=UTF-8''%5cfoo.html"
)
assert "attachment" == disptype
assert {"filename*": "\\foo.html"} == params
assert {"filename*": "foo.html"} == params

def test_attwithfn2231abspath(self) -> None:
disptype, params = parse_content_disposition(
"attachment; filename*=UTF-8''%2Ffoo.html"
)
assert "attachment" == disptype
assert {"filename*": "foo.html"} == params

def test_attfncontabspath(self) -> None:
# The continuation parts are normalised once they are joined, so the
# separator survives parsing.
disptype, params = parse_content_disposition(
'attachment; filename*0="/foo."; filename*1="html"'
)
assert "attachment" == disptype
assert {"filename*0": "/foo.", "filename*1": "html"} == params

def test_attfncont(self) -> None:
disptype, params = parse_content_disposition(
Expand Down Expand Up @@ -711,10 +727,28 @@ def test_filename_ext(self) -> None:
params = {"filename*": "файл.html"}
assert "файл.html" == content_disposition_filename(params)

def test_filename_ext_abspath(self) -> None:
_, params = parse_content_disposition(
'form-data; name="f"; filename="/etc/evil"; filename*=UTF-8\'\'%2Fetc%2Fevil'
)
assert "etc/evil" == content_disposition_filename(params)

def test_attfncont(self) -> None:
params = {"filename*0": "foo.", "filename*1": "html"}
assert "foo.html" == content_disposition_filename(params)

def test_attfncontabspath(self) -> None:
_, params = parse_content_disposition(
'attachment; filename*0="/foo."; filename*1="html"'
)
assert "foo.html" == content_disposition_filename(params)

def test_attfncontinnerpath(self) -> None:
_, params = parse_content_disposition(
'attachment; filename*0="dir"; filename*1="/foo.html"'
)
assert "dir/foo.html" == content_disposition_filename(params)

def test_attfncontqs(self) -> None:
params = {"filename*0": "foo", "filename*1": "bar.html"}
assert "foobar.html" == content_disposition_filename(params)
Expand All @@ -723,6 +757,16 @@ def test_attfncontenc(self) -> None:
params = {"filename*0*": "UTF-8''foo-%c3%a4", "filename*1": ".html"}
assert "foo-ä.html" == content_disposition_filename(params)

@pytest.mark.parametrize(
"params",
(
{"filename*0*": "UTF-8''%2Ffoo-%c3%a4", "filename*1": ".html"},
{"filename*0*": "UTF-8''%5cfoo-%c3%a4", "filename*1": ".html"},
),
)
def test_attfncontencabspath(self, params: dict[str, str]) -> None:
assert "foo-ä.html" == content_disposition_filename(params)

@pytest.mark.parametrize(
"params",
(
Expand Down
25 changes: 25 additions & 0 deletions tests/test_websocket_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,31 @@ def test_compressed_continuation_with_ping(
assert out._buffer[1] == WSMessageBinary(data=message, size=len(message), extra="")


def test_compressed_frame_after_control_frame(
out: WebSocketDataQueue, parser: PatchableWebSocketReader
) -> None:
# A control frame arriving before the first data frame must not
# latch the per-message compression state.
# https://github.com/aio-libs/aiohttp/issues/13274
parser.feed_data(PACK_LEN1(0x80 | WSMsgType.PONG, 0))
parser.feed_data(build_frame(b"hello", WSMsgType.TEXT, ZLibBackend=ZLibBackend))

assert out._buffer[0] == WSMessagePong(data=b"", size=0, extra="")
assert out._buffer[1] == WSMessageText(data="hello", size=5, extra="")


@pytest.mark.parametrize("opcode", (WSMsgType.PING, WSMsgType.PONG, WSMsgType.CLOSE))
def test_control_frame_with_rsv1(
parser: PatchableWebSocketReader, opcode: WSMsgType
) -> None:
# Control frames never carry the per-message compressed bit.
# https://datatracker.ietf.org/doc/html/rfc7692#section-6.1
with pytest.raises(WebSocketError) as ctx:
parser._feed_data(PACK_LEN1(0xC0 | opcode, 0))

assert ctx.value.code == WSCloseCode.PROTOCOL_ERROR


def test_parse_compress_error_frame(parser: PatchableWebSocketReader) -> None:
parser.parse_frame(struct.pack("!BB", 0b01000001, 0b00000001))
parser.parse_frame(b"1")
Expand Down
Loading