diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 3f390b6..a4d0c9d 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -72,9 +72,16 @@ def content_disposition(filename): """ if not filename: return "attachment" - ascii_filename = encode_basename_ascii(filename) + # ASCII filenames are quoted and must ensure escape sequences + # in the filename won't break out of the quoted header value + # which can permit a reflected file download attack. The UTF-8 + # version is immune because it's not quoted. + ascii_filename = ( + encode_basename_ascii(filename).replace("\\", "\\\\").replace('"', r'\"') + ) utf8_filename = encode_basename_utf8(filename) if ascii_filename == utf8_filename: # ASCII only. + return f'attachment; filename="{ascii_filename}"' else: return ( diff --git a/tests/response.py b/tests/response.py index d87ce2b..738d364 100644 --- a/tests/response.py +++ b/tests/response.py @@ -19,3 +19,16 @@ def test_content_disposition_encoding(self): self.assertIn( "filename*=UTF-8''espac%C3%A9%20.txt", headers["Content-Disposition"] ) + + def test_content_disposition_escaping(self): + """Content-Disposition headers escape special characters.""" + response = DownloadResponse( + "fake file", + attachment=True, + basename=r'"malicious\file.exe' + ) + headers = response.default_headers + self.assertIn( + r'filename="\"malicious\\file.exe"', + headers["Content-Disposition"] + ) \ No newline at end of file