Skip to content

Commit

Permalink
1.7.1dev: merge [17786:17787] from 1.6-stable (fix for #13701)
Browse files Browse the repository at this point in the history
git-svn-id: http://trac.edgewall.org/intertrac/log:/trunk@17788 af82e41b-90c4-0310-8c96-b1721e28e2e2
  • Loading branch information
jomae committed Apr 29, 2024
2 parents 103f8a6 + a557413 commit 1d02ea7
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 7 deletions.
25 changes: 19 additions & 6 deletions trac/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from http.cookies import CookieError, BaseCookie, SimpleCookie
from http.server import BaseHTTPRequestHandler
from datetime import datetime
import base64
import hashlib
import io
import mimetypes
Expand All @@ -38,7 +39,7 @@
from trac.core import Interface, TracBaseError, TracError
from trac.util import as_bool, as_int, get_last_traceback, lazy, \
normalize_filename
from trac.util.datefmt import http_date, localtz
from trac.util.datefmt import http_date, localtz, to_datetime, utc
from trac.util.html import Fragment, tag
from trac.util.text import empty, exception_to_unicode, to_unicode
from trac.util.translation import _, N_, tag_
Expand Down Expand Up @@ -814,12 +815,24 @@ def check_modified(self, datetime, extra=''):
Otherwise, it adds the entity tag as an "ETag" header to the response
so that consecutive requests can be cached.
"""
if isinstance(extra, list):

# In <RFC9110 8.8.3. ETag>, the value enclosed with double quotes
# allows %x21 / %x23-7E / %x80-FF bytes (except SPACE, <">, DEL).
# However, WSGI requires latin-1 encoding in the headers. We use sha1
# and urlsafe-base64 encoded for the value.
def digest_base64(iterable):
m = hashlib.sha1()
for elt in extra:
m.update(repr(elt).encode('utf-8'))
extra = m.hexdigest()
etag = 'W/"%s/%s/%s"' % (self.authname, http_date(datetime), extra)
for item in iterable:
m.update(item.encode('utf-8'))
digest = m.digest()
encoded = base64.urlsafe_b64encode(digest).rstrip(b'=')
return str(encoded, 'ascii')

if isinstance(extra, list):
extra = digest_base64(map(repr, extra))
authname = digest_base64([self.authname])
ts = to_datetime(datetime, utc).isoformat().replace('+00:00', 'Z')
etag = 'W/"%s/%s/%s"' % (authname, ts, extra)
inm = self.get_header('If-None-Match')
if not inm or inm != etag:
self.send_header('ETag', etag)
Expand Down
72 changes: 71 additions & 1 deletion trac/web/tests/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# individuals. For the exact contribution history, see the revision
# history and logs, available at https://trac.edgewall.org/log/.

from datetime import datetime
import io
import os.path
import textwrap
Expand All @@ -20,7 +21,7 @@
from trac.core import TracError
from trac.test import EnvironmentStub, MockPerm, makeSuite, mkdtemp, rmtree
from trac.util import create_file
from trac.util.datefmt import utc
from trac.util.datefmt import timezone, utc
from trac.util.html import tag
from trac.web.api import HTTPBadRequest, HTTPInternalServerError, Request, \
RequestDone, parse_arg_list
Expand Down Expand Up @@ -632,6 +633,75 @@ def test_is_valid_header(self):
self.assertTrue(Request.is_valid_header('Aa0-!#$%&\'*+.^_`|~',
'custom2'))

def _test_check_modified_etag(self, expected, authname, *args, **kwargs):
req = _make_req(_make_environ(), authname=authname)
req.check_modified(*args, **kwargs)
with self.assertRaises(RequestDone):
req.send(b'')
self.assertEqual(expected, req.headers_sent['ETag'])

def test_check_modified_authname(self):
t = datetime(2024, 4, 22, 12, 34, 56, 12345, utc)
self._test_check_modified_etag(
'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-22T12:34:56.012345Z/"',
'admin', datetime=t)
self._test_check_modified_etag(
'W/"3sAdZcyug-g4CG4Hw22qbDsNFZg/2024-04-22T12:34:56.012345Z/"',
'föøbär', datetime=t)
self._test_check_modified_etag(
'W/"XbsQF2sFKvX58cq-6LFkEsrM7x8/2024-04-22T12:34:56.012345Z/"',
'ad"min', datetime=t)
self._test_check_modified_etag(
'W/"9KeuwlhgoBlSlcYC2HT5CioCp6A/2024-04-22T12:34:56.012345Z/"',
'adm\x7fin', datetime=t)
self._test_check_modified_etag(
'W/"N3PeplFWkJg4-mwiglyv4JD_gDA/2024-04-22T12:34:56.012345Z/"',
'foo bar', datetime=t)

def test_check_modified_datetime(self):
tz = timezone('GMT -11:00')
self._test_check_modified_etag(
'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-22T23:34:56Z/"',
'admin', datetime=datetime(2024, 4, 22, 12, 34, 56, 0, tz))
self._test_check_modified_etag(
'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-22T22:34:56.012345Z/"',
'admin', datetime=datetime(2024, 4, 22, 11, 34, 56, 12345, tz))
self._test_check_modified_etag(
'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-22T21:34:56.987000Z/"',
'admin', datetime=datetime(2024, 4, 22, 10, 34, 56, 987000, tz))

def test_check_modified_extra(self):
t = datetime(2024, 4, 21, 13, 45, 34, 98765, utc)
self._test_check_modified_etag(
'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-21T13:45:34.098765Z'
'/x9K8LITGtvCTPiKASe2O827raFs"',
'admin', datetime=t, extra=[None, 42, [42], {42: 42}])

def test_check_modified_if_none_match(self):
etag = 'W/"0DPiKuNIrrVmD8IUCuw1hQxNqZc/2024-04-19T15:12:23.012345Z/"'
t = datetime(2024, 4, 19, 15, 12, 23, 12345, utc)

req = _make_req(_make_environ(HTTP_IF_NONE_MATCH=etag),
authname='admin')
with self.assertRaises(RequestDone):
req.check_modified(t)
self.assertEqual(['304 Not Modified'], req.status_sent)
self.assertEqual('0', req.headers_sent['Content-Length'])

req = _make_req(_make_environ(HTTP_IF_NONE_MATCH='XXXXX'),
authname='admin')
req.check_modified(t)
with self.assertRaises(RequestDone):
req.send(b'')
self.assertEqual(etag, req.headers_sent['ETag'])

# No If-None-Match header
req = _make_req(_make_environ(), authname='admin')
req.check_modified(t)
with self.assertRaises(RequestDone):
req.send(b'')
self.assertEqual(etag, req.headers_sent['ETag'])


class RequestSendFileTestCase(unittest.TestCase):

Expand Down
18 changes: 18 additions & 0 deletions trac/web/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from abc import ABCMeta, abstractmethod
import errno
import re
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
Expand Down Expand Up @@ -140,6 +141,23 @@ def _start_response(self, status, headers, exc_info=None):
else:
assert not self.headers_set, 'Response already started'

def check_header(item, label):
if not isinstance(item, str):
raise TypeError('Expected str instance in %s' % label)
try:
item.encode('iso-8859-1')
except UnicodeEncodeError:
raise ValueError('Non latin-1 characters are used in %s' %
label) from None
if control_re.search(item):
raise ValueError('Control characters are used in %s' % label)

control_re = re.compile(r'[\x00-\x1f\x7f]')
check_header(status, 'status')
for name, value in headers:
check_header(name, 'headers')
check_header(value, 'headers')

self.headers_set = [status, headers]
return self._write

Expand Down

0 comments on commit 1d02ea7

Please sign in to comment.