diff options
| author | Tom Carrick <tom@carrick.eu> | 2020-07-14 13:32:24 +0200 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2020-09-14 08:41:59 +0200 |
| commit | bcc2befd0e9c1885e45b46d0b0bcdc11def8b249 (patch) | |
| tree | 59fab69a3182286da87fcd6fe05a8ce0f4277a5a /django | |
| parent | 71ae1ab0123582cc5bfe0f7d5f4cc19a9412f396 (diff) | |
Fixed #31789 -- Added a new headers interface to HttpResponse.
Diffstat (limited to 'django')
| -rw-r--r-- | django/contrib/admin/tests.py | 2 | ||||
| -rw-r--r-- | django/contrib/admindocs/middleware.py | 2 | ||||
| -rw-r--r-- | django/contrib/sitemaps/views.py | 4 | ||||
| -rw-r--r-- | django/contrib/syndication/views.py | 2 | ||||
| -rw-r--r-- | django/http/response.py | 128 | ||||
| -rw-r--r-- | django/middleware/clickjacking.py | 6 | ||||
| -rw-r--r-- | django/middleware/common.py | 2 | ||||
| -rw-r--r-- | django/middleware/gzip.py | 8 | ||||
| -rw-r--r-- | django/middleware/locale.py | 2 | ||||
| -rw-r--r-- | django/middleware/security.py | 8 | ||||
| -rw-r--r-- | django/utils/cache.py | 22 | ||||
| -rw-r--r-- | django/views/decorators/http.py | 4 | ||||
| -rw-r--r-- | django/views/generic/base.py | 4 | ||||
| -rw-r--r-- | django/views/static.py | 4 |
14 files changed, 114 insertions, 84 deletions
diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py index 941d030d30..482027b1ae 100644 --- a/django/contrib/admin/tests.py +++ b/django/contrib/admin/tests.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext as _ class CSPMiddleware(MiddlewareMixin): """The admin's JavaScript should be compatible with CSP.""" def process_response(self, request, response): - response['Content-Security-Policy'] = "default-src 'self'" + response.headers['Content-Security-Policy'] = "default-src 'self'" return response diff --git a/django/contrib/admindocs/middleware.py b/django/contrib/admindocs/middleware.py index 77d77af715..4779db8366 100644 --- a/django/contrib/admindocs/middleware.py +++ b/django/contrib/admindocs/middleware.py @@ -24,5 +24,5 @@ class XViewMiddleware(MiddlewareMixin): if request.method == 'HEAD' and (request.META.get('REMOTE_ADDR') in settings.INTERNAL_IPS or (request.user.is_active and request.user.is_staff)): response = HttpResponse() - response['X-View'] = get_view_name(view_func) + response.headers['X-View'] = get_view_name(view_func) return response diff --git a/django/contrib/sitemaps/views.py b/django/contrib/sitemaps/views.py index ab6b9a6d24..bffdebb082 100644 --- a/django/contrib/sitemaps/views.py +++ b/django/contrib/sitemaps/views.py @@ -14,7 +14,7 @@ def x_robots_tag(func): @wraps(func) def inner(request, *args, **kwargs): response = func(request, *args, **kwargs) - response['X-Robots-Tag'] = 'noindex, noodp, noarchive' + response.headers['X-Robots-Tag'] = 'noindex, noodp, noarchive' return response return inner @@ -88,5 +88,5 @@ def sitemap(request, sitemaps, section=None, if all_sites_lastmod and lastmod is not None: # if lastmod is defined for all sites, set header so as # ConditionalGetMiddleware is able to send 304 NOT MODIFIED - response['Last-Modified'] = http_date(timegm(lastmod)) + response.headers['Last-Modified'] = http_date(timegm(lastmod)) return response diff --git a/django/contrib/syndication/views.py b/django/contrib/syndication/views.py index df97103318..6d567dd7db 100644 --- a/django/contrib/syndication/views.py +++ b/django/contrib/syndication/views.py @@ -42,7 +42,7 @@ class Feed: if hasattr(self, 'item_pubdate') or hasattr(self, 'item_updateddate'): # if item_pubdate or item_updateddate is defined for the feed, set # header so as ConditionalGetMiddleware is able to send 304 NOT MODIFIED - response['Last-Modified'] = http_date( + response.headers['Last-Modified'] = http_date( timegm(feedgen.latest_post_date().utctimetuple())) feedgen.write(response, 'utf-8') return response diff --git a/django/http/response.py b/django/http/response.py index 64ac205087..e679c856c0 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -5,6 +5,7 @@ import os import re import sys import time +from collections.abc import Mapping from email.header import Header from http.client import responses from urllib.parse import quote, urlparse @@ -15,6 +16,7 @@ from django.core.exceptions import DisallowedRedirect from django.core.serializers.json import DjangoJSONEncoder from django.http.cookie import SimpleCookie from django.utils import timezone +from django.utils.datastructures import CaseInsensitiveMapping from django.utils.encoding import iri_to_uri from django.utils.http import http_date from django.utils.regex_helper import _lazy_re_compile @@ -22,6 +24,65 @@ from django.utils.regex_helper import _lazy_re_compile _charset_from_content_type_re = _lazy_re_compile(r';\s*charset=(?P<charset>[^\s;]+)', re.I) +class ResponseHeaders(CaseInsensitiveMapping): + def __init__(self, data): + """ + Populate the initial data using __setitem__ to ensure values are + correctly encoded. + """ + if not isinstance(data, Mapping): + data = { + k: v + for k, v in CaseInsensitiveMapping._destruct_iterable_mapping_values(data) + } + self._store = {} + for header, value in data.items(): + self[header] = value + + def _convert_to_charset(self, value, charset, mime_encode=False): + """ + Convert headers key/value to ascii/latin-1 native strings. + `charset` must be 'ascii' or 'latin-1'. If `mime_encode` is True and + `value` can't be represented in the given charset, apply MIME-encoding. + """ + if not isinstance(value, (bytes, str)): + value = str(value) + if ( + (isinstance(value, bytes) and (b'\n' in value or b'\r' in value)) or + (isinstance(value, str) and ('\n' in value or '\r' in value)) + ): + raise BadHeaderError("Header values can't contain newlines (got %r)" % value) + try: + if isinstance(value, str): + # Ensure string is valid in given charset + value.encode(charset) + else: + # Convert bytestring using given charset + value = value.decode(charset) + except UnicodeError as e: + if mime_encode: + value = Header(value, 'utf-8', maxlinelen=sys.maxsize).encode() + else: + e.reason += ', HTTP response headers must be in %s format' % charset + raise + return value + + def __delitem__(self, key): + self.pop(key) + + def __setitem__(self, key, value): + key = self._convert_to_charset(key, 'ascii') + value = self._convert_to_charset(value, 'latin-1', mime_encode=True) + self._store[key.lower()] = (key, value) + + def pop(self, key, default=None): + return self._store.pop(key.lower(), default) + + def setdefault(self, key, value): + if key not in self: + self[key] = value + + class BadHeaderError(ValueError): pass @@ -37,10 +98,7 @@ class HttpResponseBase: status_code = 200 def __init__(self, content_type=None, status=None, reason=None, charset=None): - # _headers is a mapping of the lowercase name to the original case of - # the header (required for working with legacy systems) and the header - # value. Both the name of the header and its value are ASCII strings. - self._headers = {} + self.headers = ResponseHeaders({}) self._resource_closers = [] # This parameter is set by the handler. It's necessary to preserve the # historical behavior of request_finished. @@ -95,7 +153,7 @@ class HttpResponseBase: headers = [ (to_bytes(key, 'ascii') + b': ' + to_bytes(value, 'latin-1')) - for key, value in self._headers.values() + for key, value in self.headers.items() ] return b'\r\n'.join(headers) @@ -103,57 +161,28 @@ class HttpResponseBase: @property def _content_type_for_repr(self): - return ', "%s"' % self['Content-Type'] if 'Content-Type' in self else '' - - def _convert_to_charset(self, value, charset, mime_encode=False): - """ - Convert headers key/value to ascii/latin-1 native strings. - - `charset` must be 'ascii' or 'latin-1'. If `mime_encode` is True and - `value` can't be represented in the given charset, apply MIME-encoding. - """ - if not isinstance(value, (bytes, str)): - value = str(value) - if ((isinstance(value, bytes) and (b'\n' in value or b'\r' in value)) or - isinstance(value, str) and ('\n' in value or '\r' in value)): - raise BadHeaderError("Header values can't contain newlines (got %r)" % value) - try: - if isinstance(value, str): - # Ensure string is valid in given charset - value.encode(charset) - else: - # Convert bytestring using given charset - value = value.decode(charset) - except UnicodeError as e: - if mime_encode: - value = Header(value, 'utf-8', maxlinelen=sys.maxsize).encode() - else: - e.reason += ', HTTP response headers must be in %s format' % charset - raise - return value + return ', "%s"' % self.headers['Content-Type'] if 'Content-Type' in self.headers else '' def __setitem__(self, header, value): - header = self._convert_to_charset(header, 'ascii') - value = self._convert_to_charset(value, 'latin-1', mime_encode=True) - self._headers[header.lower()] = (header, value) + self.headers[header] = value def __delitem__(self, header): - self._headers.pop(header.lower(), False) + del self.headers[header] def __getitem__(self, header): - return self._headers[header.lower()][1] + return self.headers[header] def has_header(self, header): """Case-insensitive check for a header.""" - return header.lower() in self._headers + return header in self.headers __contains__ = has_header def items(self): - return self._headers.values() + return self.headers.items() def get(self, header, alternate=None): - return self._headers.get(header.lower(), (None, alternate))[1] + return self.headers.get(header, alternate) def set_cookie(self, key, value='', max_age=None, expires=None, path='/', domain=None, secure=False, httponly=False, samesite=None): @@ -203,8 +232,7 @@ class HttpResponseBase: def setdefault(self, key, value): """Set a header unless it has already been set.""" - if key not in self: - self[key] = value + self.headers.setdefault(key, value) def set_signed_cookie(self, key, value, salt='', **kwargs): value = signing.get_cookie_signer(salt=key + salt).sign(value) @@ -430,19 +458,19 @@ class FileResponse(StreamingHttpResponse): filename = getattr(filelike, 'name', None) filename = filename if (isinstance(filename, str) and filename) else self.filename if os.path.isabs(filename): - self['Content-Length'] = os.path.getsize(filelike.name) + self.headers['Content-Length'] = os.path.getsize(filelike.name) elif hasattr(filelike, 'getbuffer'): - self['Content-Length'] = filelike.getbuffer().nbytes + self.headers['Content-Length'] = filelike.getbuffer().nbytes - if self.get('Content-Type', '').startswith('text/html'): + if self.headers.get('Content-Type', '').startswith('text/html'): if filename: content_type, encoding = mimetypes.guess_type(filename) # Encoding isn't set to prevent browsers from automatically # uncompressing files. content_type = encoding_map.get(encoding, content_type) - self['Content-Type'] = content_type or 'application/octet-stream' + self.headers['Content-Type'] = content_type or 'application/octet-stream' else: - self['Content-Type'] = 'application/octet-stream' + self.headers['Content-Type'] = 'application/octet-stream' filename = self.filename or os.path.basename(filename) if filename: @@ -452,9 +480,9 @@ class FileResponse(StreamingHttpResponse): file_expr = 'filename="{}"'.format(filename) except UnicodeEncodeError: file_expr = "filename*=utf-8''{}".format(quote(filename)) - self['Content-Disposition'] = '{}; {}'.format(disposition, file_expr) + self.headers['Content-Disposition'] = '{}; {}'.format(disposition, file_expr) elif self.as_attachment: - self['Content-Disposition'] = 'attachment' + self.headers['Content-Disposition'] = 'attachment' class HttpResponseRedirectBase(HttpResponse): diff --git a/django/middleware/clickjacking.py b/django/middleware/clickjacking.py index 478ed3cd7e..0161f8eb8f 100644 --- a/django/middleware/clickjacking.py +++ b/django/middleware/clickjacking.py @@ -30,8 +30,10 @@ class XFrameOptionsMiddleware(MiddlewareMixin): if getattr(response, 'xframe_options_exempt', False): return response - response['X-Frame-Options'] = self.get_xframe_options_value(request, - response) + response.headers['X-Frame-Options'] = self.get_xframe_options_value( + request, + response, + ) return response def get_xframe_options_value(self, request, response): diff --git a/django/middleware/common.py b/django/middleware/common.py index 7e75e81303..e6f30f44ad 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -110,7 +110,7 @@ class CommonMiddleware(MiddlewareMixin): # Add the Content-Length header to non-streaming responses if not # already set. if not response.streaming and not response.has_header('Content-Length'): - response['Content-Length'] = str(len(response.content)) + response.headers['Content-Length'] = str(len(response.content)) return response diff --git a/django/middleware/gzip.py b/django/middleware/gzip.py index 14346c5b12..350466151d 100644 --- a/django/middleware/gzip.py +++ b/django/middleware/gzip.py @@ -31,21 +31,21 @@ class GZipMiddleware(MiddlewareMixin): # Delete the `Content-Length` header for streaming content, because # we won't know the compressed size until we stream it. response.streaming_content = compress_sequence(response.streaming_content) - del response['Content-Length'] + del response.headers['Content-Length'] else: # Return the compressed content only if it's actually shorter. compressed_content = compress_string(response.content) if len(compressed_content) >= len(response.content): return response response.content = compressed_content - response['Content-Length'] = str(len(response.content)) + response.headers['Content-Length'] = str(len(response.content)) # If there is a strong ETag, make it weak to fulfill the requirements # of RFC 7232 section-2.1 while also allowing conditional request # matches on ETags. etag = response.get('ETag') if etag and etag.startswith('"'): - response['ETag'] = 'W/' + etag - response['Content-Encoding'] = 'gzip' + response.headers['ETag'] = 'W/' + etag + response.headers['Content-Encoding'] = 'gzip' return response diff --git a/django/middleware/locale.py b/django/middleware/locale.py index e4f3537320..0bbdda3309 100644 --- a/django/middleware/locale.py +++ b/django/middleware/locale.py @@ -57,5 +57,5 @@ class LocaleMiddleware(MiddlewareMixin): if not (i18n_patterns_used and language_from_path): patch_vary_headers(response, ('Accept-Language',)) - response.setdefault('Content-Language', language) + response.headers.setdefault('Content-Language', language) return response diff --git a/django/middleware/security.py b/django/middleware/security.py index 44921cd22b..d923893dc5 100644 --- a/django/middleware/security.py +++ b/django/middleware/security.py @@ -38,18 +38,18 @@ class SecurityMiddleware(MiddlewareMixin): sts_header = sts_header + "; includeSubDomains" if self.sts_preload: sts_header = sts_header + "; preload" - response['Strict-Transport-Security'] = sts_header + response.headers['Strict-Transport-Security'] = sts_header if self.content_type_nosniff: - response.setdefault('X-Content-Type-Options', 'nosniff') + response.headers.setdefault('X-Content-Type-Options', 'nosniff') if self.xss_filter: - response.setdefault('X-XSS-Protection', '1; mode=block') + response.headers.setdefault('X-XSS-Protection', '1; mode=block') if self.referrer_policy: # Support a comma-separated string or iterable of values to allow # fallback. - response.setdefault('Referrer-Policy', ','.join( + response.headers.setdefault('Referrer-Policy', ','.join( [v.strip() for v in self.referrer_policy.split(',')] if isinstance(self.referrer_policy, str) else self.referrer_policy )) diff --git a/django/utils/cache.py b/django/utils/cache.py index 72f017f38a..0541d373ee 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -62,7 +62,7 @@ def patch_cache_control(response, **kwargs): cc = defaultdict(set) if response.get('Cache-Control'): - for field in cc_delim_re.split(response['Cache-Control']): + for field in cc_delim_re.split(response.headers['Cache-Control']): directive, value = dictitem(field) if directive == 'no-cache': # no-cache supports multiple field names. @@ -100,7 +100,7 @@ def patch_cache_control(response, **kwargs): else: directives.append(dictvalue(directive, values)) cc = ', '.join(directives) - response['Cache-Control'] = cc + response.headers['Cache-Control'] = cc def get_max_age(response): @@ -110,7 +110,7 @@ def get_max_age(response): """ if not response.has_header('Cache-Control'): return - cc = dict(_to_tuple(el) for el in cc_delim_re.split(response['Cache-Control'])) + cc = dict(_to_tuple(el) for el in cc_delim_re.split(response.headers['Cache-Control'])) try: return int(cc['max-age']) except (ValueError, TypeError, KeyError): @@ -119,7 +119,7 @@ def get_max_age(response): def set_response_etag(response): if not response.streaming and response.content: - response['ETag'] = quote_etag(hashlib.md5(response.content).hexdigest()) + response.headers['ETag'] = quote_etag(hashlib.md5(response.content).hexdigest()) return response @@ -140,7 +140,7 @@ def _not_modified(request, response=None): # Last-Modified. for header in ('Cache-Control', 'Content-Location', 'Date', 'ETag', 'Expires', 'Last-Modified', 'Vary'): if header in response: - new_response[header] = response[header] + new_response.headers[header] = response.headers[header] # Preserve cookies as per the cookie specification: "If a proxy server # receives a response which contains a Set-cookie header, it should @@ -261,7 +261,7 @@ def patch_response_headers(response, cache_timeout=None): if cache_timeout < 0: cache_timeout = 0 # Can't have max-age negative if not response.has_header('Expires'): - response['Expires'] = http_date(time.time() + cache_timeout) + response.headers['Expires'] = http_date(time.time() + cache_timeout) patch_cache_control(response, max_age=cache_timeout) @@ -284,7 +284,7 @@ def patch_vary_headers(response, newheaders): # implementations may rely on the order of the Vary contents in, say, # computing an MD5 hash. if response.has_header('Vary'): - vary_headers = cc_delim_re.split(response['Vary']) + vary_headers = cc_delim_re.split(response.headers['Vary']) else: vary_headers = [] # Use .lower() here so we treat headers as case-insensitive. @@ -293,9 +293,9 @@ def patch_vary_headers(response, newheaders): if newheader.lower() not in existing_headers] vary_headers += additional_headers if '*' in vary_headers: - response['Vary'] = '*' + response.headers['Vary'] = '*' else: - response['Vary'] = ', '.join(vary_headers) + response.headers['Vary'] = ', '.join(vary_headers) def has_vary_header(response, header_query): @@ -304,7 +304,7 @@ def has_vary_header(response, header_query): """ if not response.has_header('Vary'): return False - vary_headers = cc_delim_re.split(response['Vary']) + vary_headers = cc_delim_re.split(response.headers['Vary']) existing_headers = {header.lower() for header in vary_headers} return header_query.lower() in existing_headers @@ -391,7 +391,7 @@ def learn_cache_key(request, response, cache_timeout=None, key_prefix=None, cach # in that case and would result in storing the same content under # multiple keys in the cache. See #18191 for details. headerlist = [] - for header in cc_delim_re.split(response['Vary']): + for header in cc_delim_re.split(response.headers['Vary']): header = header.upper().replace('-', '_') if header != 'ACCEPT_LANGUAGE' or not is_accept_language_redundant: headerlist.append('HTTP_' + header) diff --git a/django/views/decorators/http.py b/django/views/decorators/http.py index 673302be83..5caf13e341 100644 --- a/django/views/decorators/http.py +++ b/django/views/decorators/http.py @@ -102,9 +102,9 @@ def condition(etag_func=None, last_modified_func=None): # and if the request method is safe. if request.method in ('GET', 'HEAD'): if res_last_modified and not response.has_header('Last-Modified'): - response['Last-Modified'] = http_date(res_last_modified) + response.headers['Last-Modified'] = http_date(res_last_modified) if res_etag: - response.setdefault('ETag', res_etag) + response.headers.setdefault('ETag', res_etag) return response diff --git a/django/views/generic/base.py b/django/views/generic/base.py index 3dd957d8f8..ab800ebce8 100644 --- a/django/views/generic/base.py +++ b/django/views/generic/base.py @@ -107,8 +107,8 @@ class View: def options(self, request, *args, **kwargs): """Handle responding to requests for the OPTIONS HTTP verb.""" response = HttpResponse() - response['Allow'] = ', '.join(self._allowed_methods()) - response['Content-Length'] = '0' + response.headers['Allow'] = ', '.join(self._allowed_methods()) + response.headers['Content-Length'] = '0' return response def _allowed_methods(self): diff --git a/django/views/static.py b/django/views/static.py index 18e137ce7b..1d4900b1da 100644 --- a/django/views/static.py +++ b/django/views/static.py @@ -48,9 +48,9 @@ def serve(request, path, document_root=None, show_indexes=False): content_type, encoding = mimetypes.guess_type(str(fullpath)) content_type = content_type or 'application/octet-stream' response = FileResponse(fullpath.open('rb'), content_type=content_type) - response["Last-Modified"] = http_date(statobj.st_mtime) + response.headers["Last-Modified"] = http_date(statobj.st_mtime) if encoding: - response["Content-Encoding"] = encoding + response.headers["Content-Encoding"] = encoding return response |
