1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
|
From 66587e51009457274cedec28f5fd43000d129e4e Mon Sep 17 00:00:00 2001
From: Ben Darnell <ben@bendarnell.com>
Date: Fri, 6 Mar 2026 14:50:25 -0500
Subject: [PATCH] web: Validate characters in all cookie attributes.
Our previous control character check was missing a check for
U+007F, and also semicolons, which are only allowed in quoted
parts of values. This commit checks all attributes and
updates the set of disallowed characters.
CVE: CVE-2026-35536
Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/24a2d96ea115f663b223887deb0060f13974c104]
Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com>
---
tornado/test/web_test.py | 65 ++++++++++++++++++++++++++++++++++++++++
tornado/web.py | 27 +++++++++++++++--
2 files changed, 89 insertions(+), 3 deletions(-)
diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
index 801a80ed..ae39e8fc 100644
--- a/tornado/test/web_test.py
+++ b/tornado/test/web_test.py
@@ -1,3 +1,5 @@
+import http
+
from tornado.concurrent import Future
from tornado import gen
from tornado.escape import (
@@ -291,11 +293,67 @@ class CookieTest(WebTestCase):
self.set_cookie("unicode_args", "blah", domain="foo.com", path="/foo")
class SetCookieSpecialCharHandler(RequestHandler):
+ # "Special" characters are allowed in cookie values, but trigger special quoting.
def get(self):
self.set_cookie("equals", "a=b")
self.set_cookie("semicolon", "a;b")
self.set_cookie("quote", 'a"b')
+ class SetCookieForbiddenCharHandler(RequestHandler):
+ def get(self):
+ # Control characters and semicolons raise errors in cookie names and attributes
+ # (but not values, which are tested in SetCookieSpecialCharHandler)
+ for char in list(map(chr, range(0x20))) + [chr(0x7F), ";"]:
+ try:
+ self.set_cookie("foo" + char, "bar")
+ self.write(
+ "Didn't get expected exception for char %r in name\n" % char
+ )
+ except http.cookies.CookieError as e:
+ if "Invalid cookie attribute name" not in str(e):
+ self.write(
+ "unexpected exception for char %r in name: %s\n"
+ % (char, e)
+ )
+
+ try:
+ self.set_cookie("foo", "bar", domain="example" + char + ".com")
+ self.write(
+ "Didn't get expected exception for char %r in domain\n"
+ % char
+ )
+ except http.cookies.CookieError as e:
+ if "Invalid cookie attribute domain" not in str(e):
+ self.write(
+ "unexpected exception for char %r in domain: %s\n"
+ % (char, e)
+ )
+
+ try:
+ self.set_cookie("foo", "bar", path="/" + char)
+ self.write(
+ "Didn't get expected exception for char %r in path\n" % char
+ )
+ except http.cookies.CookieError as e:
+ if "Invalid cookie attribute path" not in str(e):
+ self.write(
+ "unexpected exception for char %r in path: %s\n"
+ % (char, e)
+ )
+
+ try:
+ self.set_cookie("foo", "bar", samesite="a" + char)
+ self.write(
+ "Didn't get expected exception for char %r in samesite\n"
+ % char
+ )
+ except http.cookies.CookieError as e:
+ if "Invalid cookie attribute samesite" not in str(e):
+ self.write(
+ "unexpected exception for char %r in samesite: %s\n"
+ % (char, e)
+ )
+
class SetCookieOverwriteHandler(RequestHandler):
def get(self):
self.set_cookie("a", "b", domain="example.com")
@@ -329,6 +387,7 @@ class CookieTest(WebTestCase):
("/get", GetCookieHandler),
("/set_domain", SetCookieDomainHandler),
("/special_char", SetCookieSpecialCharHandler),
+ ("/forbidden_char", SetCookieForbiddenCharHandler),
("/set_overwrite", SetCookieOverwriteHandler),
("/set_max_age", SetCookieMaxAgeHandler),
("/set_expires_days", SetCookieExpiresDaysHandler),
@@ -385,6 +444,12 @@ class CookieTest(WebTestCase):
response = self.fetch("/get", headers={"Cookie": header})
self.assertEqual(response.body, utf8(expected))
+ def test_set_cookie_forbidden_char(self):
+ response = self.fetch("/forbidden_char")
+ self.assertEqual(response.code, 200)
+ self.maxDiff = 10000
+ self.assertMultiLineEqual(to_unicode(response.body), "")
+
def test_set_cookie_overwrite(self):
response = self.fetch("/set_overwrite")
headers = response.headers.get_list("Set-Cookie")
diff --git a/tornado/web.py b/tornado/web.py
index 8a740504..4b70ea93 100644
--- a/tornado/web.py
+++ b/tornado/web.py
@@ -643,9 +643,30 @@ class RequestHandler(object):
# The cookie library only accepts type str, in both python 2 and 3
name = escape.native_str(name)
value = escape.native_str(value)
- if re.search(r"[\x00-\x20]", name + value):
- # Don't let us accidentally inject bad stuff
- raise ValueError("Invalid cookie %r: %r" % (name, value))
+ if re.search(r"[\x00-\x20]", value):
+ # Legacy check for control characters in cookie values. This check is no longer needed
+ # since the cookie library escapes these characters correctly now. It will be removed
+ # in the next feature release.
+ raise ValueError(f"Invalid cookie {name!r}: {value!r}")
+ for attr_name, attr_value in [
+ ("name", name),
+ ("domain", domain),
+ ("path", path),
+ ("samesite", samesite),
+ ]:
+ # Cookie attributes may not contain control characters or semicolons (except when
+ # escaped in the value). A check for control characters was added to the http.cookies
+ # library in a Feb 2026 security release; as of March it still does not check for
+ # semicolons.
+ #
+ # When a semicolon check is added to the standard library (and the release has had time
+ # for adoption), this check may be removed, but be mindful of the fact that this may
+ # change the timing of the exception (to the generation of the Set-Cookie header in
+ # flush()). We m
+ if attr_value is not None and re.search(r"[\x00-\x20\x3b\x7f]", attr_value):
+ raise http.cookies.CookieError(
+ f"Invalid cookie attribute {attr_name}={attr_value!r} for cookie {name!r}"
+ )
if not hasattr(self, "_new_cookie"):
self._new_cookie = (
http.cookies.SimpleCookie()
|