Skip to content

Commit bea9422

Browse files
✅ [#135] Make cookie parsing and serialization more robust
Handle some edge cases around malformed cookie strings that may cause crashes. While our own library is not suspected to generate invalid cookie strings, there are ways to bypass input validation of the models and even the cookie value may be set by javascript or people running fuzzers, leading to the cookie value being untrusted input that must be sanitized properly.
1 parent 603973d commit bea9422

File tree

4 files changed

+78
-10
lines changed

4 files changed

+78
-10
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dist/
2222
htmlcov/
2323
reports/
2424
testapp/*.db
25+
.hypothesis
2526

2627
# frontend tooling / builds
2728
js/node_modules/

cookie_consent/util.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,64 @@
11
# -*- coding: utf-8 -*-
22
import datetime
3-
from typing import Union
3+
import logging
4+
from typing import Dict, Union
45

56
from .cache import all_cookie_groups, get_cookie, get_cookie_group
67
from .conf import settings
78
from .models import ACTION_ACCEPTED, ACTION_DECLINED, LogItem
89

10+
logger = logging.getLogger(__name__)
911

10-
def parse_cookie_str(cookie):
11-
dic = {}
12+
COOKIE_GROUP_SEP = "|"
13+
KEY_VALUE_SEP = "="
14+
15+
16+
def parse_cookie_str(cookie: str) -> Dict[str, str]:
1217
if not cookie:
13-
return dic
14-
for c in cookie.split("|"):
15-
key, value = c.split("=")
16-
dic[key] = value
17-
return dic
18+
return {}
19+
20+
bits = cookie.split(COOKIE_GROUP_SEP)
21+
22+
def _gen_pairs():
23+
for possible_pair in bits:
24+
parts = possible_pair.split(KEY_VALUE_SEP)
25+
if len(parts) == 2:
26+
yield parts
27+
else:
28+
logger.debug("cookie_value_discarded", extra={"value": possible_pair})
29+
30+
return dict(_gen_pairs())
31+
32+
33+
def _contains_invalid_characters(*inputs: str) -> bool:
34+
# = and | are special separators. They are unexpected characters in both
35+
# keys and values.
36+
for separator in (COOKIE_GROUP_SEP, KEY_VALUE_SEP):
37+
for value in inputs:
38+
if separator in value:
39+
logger.debug("skip_separator", extra={"value": value, "sep": separator})
40+
return True
41+
return False
42+
43+
44+
def dict_to_cookie_str(dic) -> str:
45+
"""
46+
Serialize a dictionary of cookie-group metadata to a string.
47+
48+
The result is stored in a cookie itself. Note that the dictionary keys are expected
49+
to be cookie group ``varname`` fields, which are validated against a slug regex. The
50+
values are supposed to be ISO-8601 timestamps.
51+
52+
Invalid key/value pairs are dropped.
53+
"""
1854

55+
def _gen_pairs():
56+
for key, value in dic.items():
57+
if _contains_invalid_characters(key, value):
58+
continue
59+
yield f"{key}={value}"
1960

20-
def dict_to_cookie_str(dic):
21-
return "|".join(["%s=%s" % (k, v) for k, v in dic.items() if v])
61+
return "|".join(_gen_pairs())
2262

2363

2464
def get_cookie_dict_from_request(request):
@@ -171,6 +211,7 @@ def is_cookie_consent_enabled(request):
171211
return enabled
172212

173213

214+
# Deprecated
174215
def get_cookie_string(cookie_dic):
175216
"""
176217
Returns cookie in format suitable for use in javascript.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ tests = [
4848
"pytest",
4949
"pytest-django",
5050
"pytest-playwright",
51+
"hypothesis",
5152
"tox",
5253
"isort",
5354
"black",

tests/test_util.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# -*- coding: utf-8 -*-
22
from datetime import datetime
33

4+
from django.http import parse_cookie
45
from django.test import TestCase
56
from django.test.client import RequestFactory
67
from django.test.utils import override_settings
78

9+
from hypothesis import example, given, strategies as st
10+
811
from cookie_consent.conf import settings
912
from cookie_consent.models import Cookie, CookieGroup
1013
from cookie_consent.util import (
@@ -127,3 +130,25 @@ def test_get_accepted_cookies(self):
127130
self.request.COOKIES[settings.COOKIE_CONSENT_NAME] = cookie_str
128131
cookies = get_accepted_cookies(self.request)
129132
self.assertIn(self.cookie, cookies)
133+
134+
135+
@example({"": "|"})
136+
@example({"": "="})
137+
@given(
138+
cookie_dict=st.dictionaries(
139+
keys=st.text(min_size=0),
140+
values=st.text(min_size=0),
141+
)
142+
)
143+
def test_serialize_and_parse_cookie_str(cookie_dict):
144+
serialized = dict_to_cookie_str(cookie_dict)
145+
parsed = parse_cookie_str(serialized)
146+
147+
assert len(parsed.keys()) <= len(cookie_dict.keys())
148+
149+
150+
@given(cookie_str=st.text(min_size=0))
151+
def test_parse_cookie_str(cookie_str: str):
152+
parsed = parse_cookie_str(cookie_str)
153+
154+
assert isinstance(parsed, dict)

0 commit comments

Comments
 (0)