Skip to content

Commit 4ef0e01

Browse files
marfiretimgraham
authored andcommitted
Fixed #27083 -- Added support for weak ETags.
1 parent e7abb5b commit 4ef0e01

File tree

11 files changed

+128
-66
lines changed

11 files changed

+128
-66
lines changed

django/middleware/common.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
)
1111
from django.utils.deprecation import MiddlewareMixin
1212
from django.utils.encoding import force_text
13-
from django.utils.http import unquote_etag
1413
from django.utils.six.moves.urllib.parse import urlparse
1514

1615

@@ -122,7 +121,7 @@ def process_response(self, request, response):
122121
if response.has_header('ETag'):
123122
return get_conditional_response(
124123
request,
125-
etag=unquote_etag(response['ETag']),
124+
etag=response['ETag'],
126125
response=response,
127126
)
128127
# Add the Content-Length header to non-streaming responses if not

django/middleware/http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.utils.cache import get_conditional_response
22
from django.utils.deprecation import MiddlewareMixin
3-
from django.utils.http import http_date, parse_http_date_safe, unquote_etag
3+
from django.utils.http import http_date, parse_http_date_safe
44

55

66
class ConditionalGetMiddleware(MiddlewareMixin):
@@ -24,7 +24,7 @@ def process_response(self, request, response):
2424
if etag or last_modified:
2525
return get_conditional_response(
2626
request,
27-
etag=unquote_etag(etag),
27+
etag=etag,
2828
last_modified=last_modified,
2929
response=response,
3030
)

django/utils/http.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@
2121
urlparse,
2222
)
2323

24-
ETAG_MATCH = re.compile(r'(?:W/)?"((?:\\.|[^"])*)"')
24+
# based on RFC 7232, Appendix C
25+
ETAG_MATCH = re.compile(r'''
26+
\A( # start of string and capture group
27+
(?:W/)? # optional weak indicator
28+
" # opening quote
29+
[^"]* # any sequence of non-quote characters
30+
" # end quote
31+
)\Z # end of string and capture group
32+
''', re.X)
2533

2634
MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split()
2735
__D = r'(?P<day>\d{2})'
@@ -234,30 +242,27 @@ def urlsafe_base64_decode(s):
234242

235243
def parse_etags(etag_str):
236244
"""
237-
Parses a string with one or several etags passed in If-None-Match and
238-
If-Match headers by the rules in RFC 2616. Returns a list of etags
239-
without surrounding double quotes (") and unescaped from \<CHAR>.
245+
Parse a string of ETags given in an If-None-Match or If-Match header as
246+
defined by RFC 7232. Return a list of quoted ETags, or ['*'] if all ETags
247+
should be matched.
240248
"""
241-
etags = ETAG_MATCH.findall(etag_str)
242-
if not etags:
243-
# etag_str has wrong format, treat it as an opaque string then
244-
return [etag_str]
245-
etags = [e.encode('ascii').decode('unicode_escape') for e in etags]
246-
return etags
247-
248-
249-
def quote_etag(etag):
250-
"""
251-
Wraps a string in double quotes escaping contents as necessary.
252-
"""
253-
return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
249+
if etag_str.strip() == '*':
250+
return ['*']
251+
else:
252+
# Parse each ETag individually, and return any that are valid.
253+
etag_matches = (ETAG_MATCH.match(etag.strip()) for etag in etag_str.split(','))
254+
return [match.group(1) for match in etag_matches if match]
254255

255256

256-
def unquote_etag(etag):
257+
def quote_etag(etag_str):
257258
"""
258-
Unquote an ETag string; i.e. revert quote_etag().
259+
If the provided string is already a quoted ETag, return it. Otherwise, wrap
260+
the string in quotes, making it a strong ETag.
259261
"""
260-
return etag.strip('"').replace('\\"', '"').replace('\\\\', '\\') if etag else etag
262+
if ETAG_MATCH.match(etag_str):
263+
return etag_str
264+
else:
265+
return '"%s"' % etag_str
261266

262267

263268
def is_same_domain(host, pattern):

django/views/decorators/http.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,16 @@ def condition(etag_func=None, last_modified_func=None):
6262
None if the resource doesn't exist), while the last_modified function
6363
should return a datetime object (or None if the resource doesn't exist).
6464
65-
If both parameters are provided, all the preconditions must be met before
66-
the view is processed.
65+
The ETag function should return a complete ETag, including quotes (e.g.
66+
'"etag"'), since that's the only way to distinguish between weak and strong
67+
ETags. If an unquoted ETag is returned (e.g. 'etag'), it will be converted
68+
to a strong ETag by adding quotes.
6769
6870
This decorator will either pass control to the wrapped view function or
69-
return an HTTP 304 response (unmodified) or 412 response (preconditions
70-
failed), depending upon the request method.
71-
72-
Any behavior marked as "undefined" in the HTTP spec (e.g. If-none-match
73-
plus If-modified-since headers) will result in the view function being
74-
called.
71+
return an HTTP 304 response (unmodified) or 412 response (precondition
72+
failed), depending upon the request method. In either case, it will add the
73+
generated ETag and Last-Modified headers to the response if it doesn't
74+
already have them.
7575
"""
7676
def decorator(func):
7777
@wraps(func, assigned=available_attrs(func))
@@ -83,7 +83,9 @@ def get_last_modified():
8383
if dt:
8484
return timegm(dt.utctimetuple())
8585

86+
# The value from etag_func() could be quoted or unquoted.
8687
res_etag = etag_func(request, *args, **kwargs) if etag_func else None
88+
res_etag = quote_etag(res_etag) if res_etag is not None else None
8789
res_last_modified = get_last_modified()
8890

8991
response = get_conditional_response(
@@ -99,7 +101,7 @@ def get_last_modified():
99101
if res_last_modified and not response.has_header('Last-Modified'):
100102
response['Last-Modified'] = http_date(res_last_modified)
101103
if res_etag and not response.has_header('ETag'):
102-
response['ETag'] = quote_etag(res_etag)
104+
response['ETag'] = res_etag
103105

104106
return response
105107

docs/releases/1.11.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,9 @@ Miscellaneous
491491
* The admin's widget for ``IntegerField`` uses ``type="number"`` rather than
492492
``type="text"``.
493493

494+
* ETags are now parsed according to the :rfc:`7232` Conditional Requests
495+
specification rather than the syntax from :rfc:`2616`.
496+
494497
.. _deprecated-features-1.11:
495498

496499
Features deprecated in 1.11

docs/topics/conditional-view-processing.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ last time the resource was modified, or ``None`` if the resource doesn't
6666
exist. The function passed to the ``etag`` decorator should return a string
6767
representing the `ETag`_ for the resource, or ``None`` if it doesn't exist.
6868

69+
.. versionchanged:: 1.11
70+
71+
In older versions, the return value from ``etag_func()`` was interpreted as
72+
the unquoted part of the ETag. That prevented the use of weak ETags, which
73+
have the format ``W/"<string>"``. The return value is now expected to be
74+
an ETag as defined by the specification (including the quotes), although
75+
the unquoted format is also accepted for backwards compatibility.
76+
6977
Using this feature usefully is probably best explained with an example.
7078
Suppose you have this pair of models, representing a simple blog system::
7179

tests/conditional_processing/tests.py

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
LAST_MODIFIED_NEWER_STR = 'Mon, 18 Oct 2010 16:56:23 GMT'
1212
LAST_MODIFIED_INVALID_STR = 'Mon, 32 Oct 2010 16:56:23 GMT'
1313
EXPIRED_LAST_MODIFIED_STR = 'Sat, 20 Oct 2007 23:21:47 GMT'
14-
ETAG = 'b4246ffc4f62314ca13147c9d4f76974'
15-
EXPIRED_ETAG = '7fae4cd4b0f81e7d2914700043aa8ed6'
14+
ETAG = '"b4246ffc4f62314ca13147c9d4f76974"'
15+
EXPIRED_ETAG = '"7fae4cd4b0f81e7d2914700043aa8ed6"'
1616

1717

1818
@override_settings(ROOT_URLCONF='conditional_processing.urls')
@@ -24,7 +24,7 @@ def assertFullResponse(self, response, check_last_modified=True, check_etag=True
2424
if check_last_modified:
2525
self.assertEqual(response['Last-Modified'], LAST_MODIFIED_STR)
2626
if check_etag:
27-
self.assertEqual(response['ETag'], '"%s"' % ETAG)
27+
self.assertEqual(response['ETag'], ETAG)
2828

2929
def assertNotModified(self, response):
3030
self.assertEqual(response.status_code, 304)
@@ -63,66 +63,66 @@ def test_if_unmodified_since(self):
6363
self.assertEqual(response.status_code, 412)
6464

6565
def test_if_none_match(self):
66-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
66+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
6767
response = self.client.get('/condition/')
6868
self.assertNotModified(response)
69-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
69+
self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
7070
response = self.client.get('/condition/')
7171
self.assertFullResponse(response)
7272

7373
# Several etags in If-None-Match is a bit exotic but why not?
74-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s", "%s"' % (ETAG, EXPIRED_ETAG)
74+
self.client.defaults['HTTP_IF_NONE_MATCH'] = '%s, %s' % (ETAG, EXPIRED_ETAG)
7575
response = self.client.get('/condition/')
7676
self.assertNotModified(response)
7777

7878
def test_if_match(self):
79-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % ETAG
79+
self.client.defaults['HTTP_IF_MATCH'] = ETAG
8080
response = self.client.put('/condition/etag/')
8181
self.assertEqual(response.status_code, 200)
82-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % EXPIRED_ETAG
82+
self.client.defaults['HTTP_IF_MATCH'] = EXPIRED_ETAG
8383
response = self.client.put('/condition/etag/')
8484
self.assertEqual(response.status_code, 412)
8585

8686
def test_both_headers(self):
8787
# see http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4
8888
self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
89-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
89+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
9090
response = self.client.get('/condition/')
9191
self.assertNotModified(response)
9292

9393
self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
94-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
94+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
9595
response = self.client.get('/condition/')
9696
self.assertFullResponse(response)
9797

9898
self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = LAST_MODIFIED_STR
99-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
99+
self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
100100
response = self.client.get('/condition/')
101101
self.assertFullResponse(response)
102102

103103
self.client.defaults['HTTP_IF_MODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
104-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
104+
self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
105105
response = self.client.get('/condition/')
106106
self.assertFullResponse(response)
107107

108108
def test_both_headers_2(self):
109109
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = LAST_MODIFIED_STR
110-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % ETAG
110+
self.client.defaults['HTTP_IF_MATCH'] = ETAG
111111
response = self.client.get('/condition/')
112112
self.assertFullResponse(response)
113113

114114
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
115-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % EXPIRED_ETAG
115+
self.client.defaults['HTTP_IF_MATCH'] = ETAG
116116
response = self.client.get('/condition/')
117117
self.assertEqual(response.status_code, 412)
118118

119-
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = LAST_MODIFIED_STR
120-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % EXPIRED_ETAG
119+
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
120+
self.client.defaults['HTTP_IF_MATCH'] = EXPIRED_ETAG
121121
response = self.client.get('/condition/')
122122
self.assertEqual(response.status_code, 412)
123123

124-
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = EXPIRED_LAST_MODIFIED_STR
125-
self.client.defaults['HTTP_IF_MATCH'] = '"%s"' % ETAG
124+
self.client.defaults['HTTP_IF_UNMODIFIED_SINCE'] = LAST_MODIFIED_STR
125+
self.client.defaults['HTTP_IF_MATCH'] = EXPIRED_ETAG
126126
response = self.client.get('/condition/')
127127
self.assertEqual(response.status_code, 412)
128128

@@ -134,7 +134,7 @@ def test_single_condition_1(self):
134134
self.assertFullResponse(response, check_last_modified=False)
135135

136136
def test_single_condition_2(self):
137-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
137+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
138138
response = self.client.get('/condition/etag/')
139139
self.assertNotModified(response)
140140
response = self.client.get('/condition/last_modified/')
@@ -146,7 +146,7 @@ def test_single_condition_3(self):
146146
self.assertFullResponse(response, check_etag=False)
147147

148148
def test_single_condition_4(self):
149-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % EXPIRED_ETAG
149+
self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
150150
response = self.client.get('/condition/etag/')
151151
self.assertFullResponse(response, check_last_modified=False)
152152

@@ -158,7 +158,7 @@ def test_single_condition_5(self):
158158
self.assertFullResponse(response, check_last_modified=False)
159159

160160
def test_single_condition_6(self):
161-
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"%s"' % ETAG
161+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
162162
response = self.client.get('/condition/etag2/')
163163
self.assertNotModified(response)
164164
response = self.client.get('/condition/last_modified2/')
@@ -188,7 +188,34 @@ def test_single_condition_head(self):
188188
response = self.client.head('/condition/')
189189
self.assertNotModified(response)
190190

191+
def test_unquoted(self):
192+
"""
193+
The same quoted ETag should be set on the header regardless of whether
194+
etag_func() in condition() returns a quoted or an unquoted ETag.
195+
"""
196+
response_quoted = self.client.get('/condition/etag/')
197+
response_unquoted = self.client.get('/condition/unquoted_etag/')
198+
self.assertEqual(response_quoted['ETag'], response_unquoted['ETag'])
199+
200+
# It's possible that the matching algorithm could use the wrong value even
201+
# if the ETag header is set correctly correctly (as tested by
202+
# test_unquoted()), so check that the unquoted value is matched.
203+
def test_unquoted_if_none_match(self):
204+
self.client.defaults['HTTP_IF_NONE_MATCH'] = ETAG
205+
response = self.client.get('/condition/unquoted_etag/')
206+
self.assertNotModified(response)
207+
self.client.defaults['HTTP_IF_NONE_MATCH'] = EXPIRED_ETAG
208+
response = self.client.get('/condition/unquoted_etag/')
209+
self.assertFullResponse(response, check_last_modified=False)
210+
211+
def test_all_if_none_match(self):
212+
self.client.defaults['HTTP_IF_NONE_MATCH'] = '*'
213+
response = self.client.get('/condition/etag/')
214+
self.assertNotModified(response)
215+
response = self.client.get('/condition/no_etag/')
216+
self.assertFullResponse(response, check_last_modified=False, check_etag=False)
217+
191218
def test_invalid_etag(self):
192-
self.client.defaults['HTTP_IF_NONE_MATCH'] = r'"\"'
219+
self.client.defaults['HTTP_IF_NONE_MATCH'] = '"""'
193220
response = self.client.get('/condition/etag/')
194221
self.assertFullResponse(response, check_last_modified=False)

tests/conditional_processing/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@
88
url('^condition/last_modified2/$', views.last_modified_view2),
99
url('^condition/etag/$', views.etag_view1),
1010
url('^condition/etag2/$', views.etag_view2),
11+
url('^condition/unquoted_etag/$', views.etag_view_unquoted),
12+
url('^condition/no_etag/$', views.etag_view_none),
1113
]

tests/conditional_processing/views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,19 @@ def etag_view1(request):
2727
def etag_view2(request):
2828
return HttpResponse(FULL_RESPONSE)
2929
etag_view2 = etag(lambda r: ETAG)(etag_view2)
30+
31+
32+
@condition(etag_func=lambda r: ETAG.strip('"'))
33+
def etag_view_unquoted(request):
34+
"""
35+
Use an etag_func() that returns an unquoted ETag.
36+
"""
37+
return HttpResponse(FULL_RESPONSE)
38+
39+
40+
@condition(etag_func=lambda r: None)
41+
def etag_view_none(request):
42+
"""
43+
Use an etag_func() that returns None, as opposed to setting etag_func=None.
44+
"""
45+
return HttpResponse(FULL_RESPONSE)

tests/middleware/tests.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -513,11 +513,6 @@ def test_no_if_none_match_and_etag(self):
513513
self.assertEqual(self.resp.status_code, 200)
514514

515515
def test_if_none_match_and_same_etag(self):
516-
self.req.META['HTTP_IF_NONE_MATCH'] = self.resp['ETag'] = 'spam'
517-
self.resp = ConditionalGetMiddleware().process_response(self.req, self.resp)
518-
self.assertEqual(self.resp.status_code, 304)
519-
520-
def test_if_none_match_and_same_etag_with_quotes(self):
521516
self.req.META['HTTP_IF_NONE_MATCH'] = self.resp['ETag'] = '"spam"'
522517
self.resp = ConditionalGetMiddleware().process_response(self.req, self.resp)
523518
self.assertEqual(self.resp.status_code, 304)

tests/utils_tests/test_http.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,19 @@ def test_is_same_domain_bad(self):
208208

209209
class ETagProcessingTests(unittest.TestCase):
210210
def test_parsing(self):
211-
etags = http.parse_etags(r'"", "etag", "e\"t\"ag", "e\\tag", W/"weak"')
212-
self.assertEqual(etags, ['', 'etag', 'e"t"ag', r'e\tag', 'weak'])
211+
self.assertEqual(
212+
http.parse_etags(r'"" , "etag", "e\\tag", W/"weak"'),
213+
['""', '"etag"', r'"e\\tag"', 'W/"weak"']
214+
)
215+
self.assertEqual(http.parse_etags('*'), ['*'])
216+
217+
# Ignore RFC 2616 ETags that are invalid according to RFC 7232.
218+
self.assertEqual(http.parse_etags(r'"etag", "e\"t\"ag"'), ['"etag"'])
213219

214220
def test_quoting(self):
215-
original_etag = r'e\t"ag'
216-
quoted_etag = http.quote_etag(original_etag)
217-
self.assertEqual(quoted_etag, r'"e\\t\"ag"')
218-
self.assertEqual(http.unquote_etag(quoted_etag), original_etag)
221+
self.assertEqual(http.quote_etag('etag'), '"etag"') # unquoted
222+
self.assertEqual(http.quote_etag('"etag"'), '"etag"') # quoted
223+
self.assertEqual(http.quote_etag('W/"etag"'), 'W/"etag"') # quoted, weak
219224

220225

221226
class HttpDateProcessingTests(unittest.TestCase):

0 commit comments

Comments
 (0)