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/11173.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed cookie unquoting to properly handle octal escape sequences in cookie values (e.g., ``\012`` for newline) by vendoring the correct ``_unquote`` implementation from Python's ``http.cookies`` module -- by :user:`bdraco`.
1 change: 1 addition & 0 deletions CHANGES/11178.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ``Cookie`` header parsing to treat attribute names as regular cookies per :rfc:`6265#section-5.4` -- by :user:`bdraco`.
110 changes: 99 additions & 11 deletions aiohttp/_cookie_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

from .log import internal_logger

__all__ = ("parse_cookie_headers", "preserve_morsel_with_coded_value")
__all__ = (
"parse_set_cookie_headers",
"parse_cookie_header",
"preserve_morsel_with_coded_value",
)

# Cookie parsing constants
# Allow more characters in cookie names to handle real-world cookies
Expand Down Expand Up @@ -108,23 +112,107 @@ def preserve_morsel_with_coded_value(cookie: Morsel[str]) -> Morsel[str]:
return mrsl_val


def _unquote(text: str) -> str:
_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub


def _unquote_replace(m: re.Match[str]) -> str:
"""
Replace function for _unquote_sub regex substitution.

Handles escaped characters in cookie values:
- Octal sequences are converted to their character representation
- Other escaped characters are unescaped by removing the backslash
"""
if m[1]:
return chr(int(m[1], 8))
return m[2]


def _unquote(value: str) -> str:
"""
Unquote a cookie value.

Vendored from http.cookies._unquote to ensure compatibility.

Note: The original implementation checked for None, but we've removed
that check since all callers already ensure the value is not None.
"""
# If there aren't any doublequotes,
# then there can't be any special characters. See RFC 2109.
if len(value) < 2:
return value
if value[0] != '"' or value[-1] != '"':
return value

# We have to assume that we must decode this string.
# Down to work.

# Remove the "s
value = value[1:-1]

# Check for special sequences. Examples:
# \012 --> \n
# \" --> "
#
return _unquote_sub(_unquote_replace, value)


def parse_cookie_header(header: str) -> List[Tuple[str, Morsel[str]]]:
"""
# If there are no quotes, return as-is
if len(text) < 2 or text[0] != '"' or text[-1] != '"':
return text
# Remove quotes and handle escaped characters
text = text[1:-1]
# Replace escaped quotes and backslashes
text = text.replace('\\"', '"').replace("\\\\", "\\")
return text
Parse a Cookie header according to RFC 6265 Section 5.4.

Cookie headers contain only name-value pairs separated by semicolons.
There are no attributes in Cookie headers - even names that match
attribute names (like 'path' or 'secure') should be treated as cookies.

This parser uses the same regex-based approach as parse_set_cookie_headers
to properly handle quoted values that may contain semicolons.

Args:
header: The Cookie header value to parse

Returns:
List of (name, Morsel) tuples for compatibility with SimpleCookie.update()
"""
if not header:
return []

cookies: List[Tuple[str, Morsel[str]]] = []
i = 0
n = len(header)

while i < n:
# Use the same pattern as parse_set_cookie_headers to find cookies
match = _COOKIE_PATTERN.match(header, i)
if not match:
break

key = match.group("key")
value = match.group("val") or ""
i = match.end(0)

# Validate the name
if not key or not _COOKIE_NAME_RE.match(key):
internal_logger.warning("Can not load cookie: Illegal cookie name %r", key)
continue

# Create new morsel
morsel: Morsel[str] = Morsel()
# Preserve the original value as coded_value (with quotes if present)
# We use __setstate__ instead of the public set() API because it allows us to
# bypass validation and set already validated state. This is more stable than
# setting protected attributes directly and unlikely to change since it would
# break pickling.
morsel.__setstate__( # type: ignore[attr-defined]
{"key": key, "value": _unquote(value), "coded_value": value}
)

cookies.append((key, morsel))

return cookies


def parse_cookie_headers(headers: Sequence[str]) -> List[Tuple[str, Morsel[str]]]:
def parse_set_cookie_headers(headers: Sequence[str]) -> List[Tuple[str, Morsel[str]]]:
"""
Parse cookie headers using a vendored version of SimpleCookie parsing.

Expand Down
4 changes: 2 additions & 2 deletions aiohttp/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from multidict import CIMultiDict
from yarl import URL

from ._cookie_helpers import parse_cookie_headers
from ._cookie_helpers import parse_set_cookie_headers
from .typedefs import LooseCookies

if TYPE_CHECKING:
Expand Down Expand Up @@ -194,7 +194,7 @@ def update_cookies_from_headers(
self, headers: Sequence[str], response_url: URL
) -> None:
"""Update cookies from raw Set-Cookie headers."""
if headers and (cookies_to_update := parse_cookie_headers(headers)):
if headers and (cookies_to_update := parse_set_cookie_headers(headers)):
self.update_cookies(cookies_to_update, response_url)

@abstractmethod
Expand Down
14 changes: 9 additions & 5 deletions aiohttp/client_reqrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
from yarl import URL

from . import hdrs, helpers, http, multipart, payload
from ._cookie_helpers import parse_cookie_headers, preserve_morsel_with_coded_value
from ._cookie_helpers import (
parse_cookie_header,
parse_set_cookie_headers,
preserve_morsel_with_coded_value,
)
from .abc import AbstractStreamWriter
from .client_exceptions import (
ClientConnectionError,
Expand Down Expand Up @@ -313,9 +317,9 @@ def cookies(self) -> SimpleCookie:
if self._raw_cookie_headers is not None:
# Parse cookies for response.cookies (SimpleCookie for backward compatibility)
cookies = SimpleCookie()
# Use parse_cookie_headers for more lenient parsing that handles
# Use parse_set_cookie_headers for more lenient parsing that handles
# malformed cookies better than SimpleCookie.load
cookies.update(parse_cookie_headers(self._raw_cookie_headers))
cookies.update(parse_set_cookie_headers(self._raw_cookie_headers))
self._cookies = cookies
else:
self._cookies = SimpleCookie()
Expand Down Expand Up @@ -1014,8 +1018,8 @@ def update_cookies(self, cookies: Optional[LooseCookies]) -> None:

c = SimpleCookie()
if hdrs.COOKIE in self.headers:
# parse_cookie_headers already preserves coded values
c.update(parse_cookie_headers((self.headers.get(hdrs.COOKIE, ""),)))
# parse_cookie_header for RFC 6265 compliant Cookie header parsing
c.update(parse_cookie_header(self.headers.get(hdrs.COOKIE, "")))
del self.headers[hdrs.COOKIE]

if isinstance(cookies, Mapping):
Expand Down
9 changes: 5 additions & 4 deletions aiohttp/web_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from yarl import URL

from . import hdrs
from ._cookie_helpers import parse_cookie_headers
from ._cookie_helpers import parse_cookie_header
from .abc import AbstractStreamWriter
from .helpers import (
_SENTINEL,
Expand Down Expand Up @@ -556,9 +556,10 @@ def cookies(self) -> Mapping[str, str]:

A read-only dictionary-like object.
"""
# Use parse_cookie_headers for more lenient parsing that accepts
# special characters in cookie names (fixes #2683)
parsed = parse_cookie_headers((self.headers.get(hdrs.COOKIE, ""),))
# Use parse_cookie_header for RFC 6265 compliant Cookie header parsing
# that accepts special characters in cookie names (fixes #2683)
parsed = parse_cookie_header(self.headers.get(hdrs.COOKIE, ""))
# Extract values from Morsel objects
return MappingProxyType({name: morsel.value for name, morsel in parsed})

@reify
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ uvloop
uWSGI
vcvarsall
vendored
vendoring
waituntil
wakeup
wakeups
Expand Down
6 changes: 3 additions & 3 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ brotli==1.1.0 ; platform_python_implementation == "CPython"
# via -r requirements/runtime-deps.in
cffi==1.17.1
# via pycares
frozenlist==1.6.2
frozenlist==1.7.0
# via
# -r requirements/runtime-deps.in
# aiosignal
Expand All @@ -30,7 +30,7 @@ multidict==6.4.4
# yarl
packaging==25.0
# via gunicorn
propcache==0.3.1
propcache==0.3.2
# via
# -r requirements/runtime-deps.in
# yarl
Expand All @@ -42,5 +42,5 @@ typing-extensions==4.13.2
# via multidict
uvloop==0.21.0 ; platform_system != "Windows" and implementation_name == "cpython"
# via -r requirements/base.in
yarl==1.20.0
yarl==1.20.1
# via -r requirements/runtime-deps.in
12 changes: 6 additions & 6 deletions requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ coverage==7.8.2
# via
# -r requirements/test.in
# pytest-cov
cryptography==45.0.3
cryptography==45.0.4
# via
# pyjwt
# trustme
Expand All @@ -78,7 +78,7 @@ freezegun==1.5.2
# via
# -r requirements/lint.in
# -r requirements/test.in
frozenlist==1.6.2
frozenlist==1.7.0
# via
# -r requirements/runtime-deps.in
# aiosignal
Expand Down Expand Up @@ -143,7 +143,7 @@ pluggy==1.6.0
# via pytest
pre-commit==4.2.0
# via -r requirements/lint.in
propcache==0.3.1
propcache==0.3.2
# via
# -r requirements/runtime-deps.in
# yarl
Expand Down Expand Up @@ -201,7 +201,7 @@ python-on-whales==0.77.0
# -r requirements/test.in
pyyaml==6.0.2
# via pre-commit
requests==2.32.3
requests==2.32.4
# via
# cherry-picker
# sphinx
Expand All @@ -214,7 +214,7 @@ six==1.17.0
# via python-dateutil
slotscheck==0.19.1
# via -r requirements/lint.in
snowballstemmer==2.2.0
snowballstemmer==3.0.1
# via sphinx
sphinx==8.1.3
# via
Expand Down Expand Up @@ -289,7 +289,7 @@ wait-for-it==2.3.0
# via -r requirements/test.in
wheel==0.46.0
# via pip-tools
yarl==1.20.0
yarl==1.20.1
# via -r requirements/runtime-deps.in
zlib-ng==0.5.1
# via
Expand Down
12 changes: 6 additions & 6 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ coverage==7.8.2
# via
# -r requirements/test.in
# pytest-cov
cryptography==45.0.3
cryptography==45.0.4
# via
# pyjwt
# trustme
Expand All @@ -76,7 +76,7 @@ freezegun==1.5.2
# via
# -r requirements/lint.in
# -r requirements/test.in
frozenlist==1.6.2
frozenlist==1.7.0
# via
# -r requirements/runtime-deps.in
# aiosignal
Expand Down Expand Up @@ -140,7 +140,7 @@ pluggy==1.6.0
# via pytest
pre-commit==4.2.0
# via -r requirements/lint.in
propcache==0.3.1
propcache==0.3.2
# via
# -r requirements/runtime-deps.in
# yarl
Expand Down Expand Up @@ -196,7 +196,7 @@ python-on-whales==0.77.0
# -r requirements/test.in
pyyaml==6.0.2
# via pre-commit
requests==2.32.3
requests==2.32.4
# via
# cherry-picker
# sphinx
Expand All @@ -208,7 +208,7 @@ six==1.17.0
# via python-dateutil
slotscheck==0.19.1
# via -r requirements/lint.in
snowballstemmer==2.2.0
snowballstemmer==3.0.1
# via sphinx
sphinx==8.1.3
# via
Expand Down Expand Up @@ -280,7 +280,7 @@ wait-for-it==2.3.0
# via -r requirements/test.in
wheel==0.46.0
# via pip-tools
yarl==1.20.0
yarl==1.20.1
# via -r requirements/runtime-deps.in
zlib-ng==0.5.1
# via
Expand Down
4 changes: 2 additions & 2 deletions requirements/doc-spelling.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ pyenchant==3.2.2
# via sphinxcontrib-spelling
pygments==2.19.1
# via sphinx
requests==2.32.3
requests==2.32.4
# via
# sphinx
# sphinxcontrib-spelling
snowballstemmer==2.2.0
snowballstemmer==3.0.1
# via sphinx
sphinx==8.1.3
# via
Expand Down
4 changes: 2 additions & 2 deletions requirements/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ packaging==25.0
# via sphinx
pygments==2.19.1
# via sphinx
requests==2.32.3
requests==2.32.4
# via sphinx
snowballstemmer==2.2.0
snowballstemmer==3.0.1
# via sphinx
sphinx==8.1.3
# via
Expand Down
2 changes: 1 addition & 1 deletion requirements/lint.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ cfgv==3.4.0
# via pre-commit
click==8.1.8
# via slotscheck
cryptography==45.0.3
cryptography==45.0.4
# via trustme
distlib==0.3.9
# via virtualenv
Expand Down
Loading
Loading