From bd062445cffd3f6cc6dcd20d13e2abed818fa173 Mon Sep 17 00:00:00 2001
From: Carlton Gibson <carlton.gibson@noumenal.es>
Date: Wed, 20 Jul 2022 12:14:45 +0200
Subject: [PATCH] Fixed CVE-2022-36359 -- Escaped filename in
 Content-Disposition header.

Thanks to Motoyasu Saburi for the report.
---
 django/http/response.py              |  4 +++-
 tests/responses/test_fileresponse.py | 35 +++++++++++++++++++++++++++++++++++
 2 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/django/http/response.py b/django/http/response.py
index 5a693b178602..bb80c6264fdb 100644
--- a/django/http/response.py
+++ b/django/http/response.py
@@ -442,7 +442,9 @@ class FileResponse(StreamingHttpResponse):
             if filename:
                 try:
                     filename.encode('ascii')
-                    file_expr = 'filename="{}"'.format(filename)
+                    file_expr = 'filename="{}"'.format(
+                        filename.replace("\\", "\\\\").replace('"', r"\"")
+                    )
                 except UnicodeEncodeError:
                     file_expr = "filename*=utf-8''{}".format(quote(filename))
                 self['Content-Disposition'] = 'attachment; {}'.format(file_expr)
diff --git a/tests/responses/test_fileresponse.py b/tests/responses/test_fileresponse.py
index 5896373d4dc1..94b0f7d89f9b 100644
--- a/tests/responses/test_fileresponse.py
+++ b/tests/responses/test_fileresponse.py
@@ -71,3 +71,38 @@ class FileResponseTests(SimpleTestCase):
             response['Content-Disposition'],
             "attachment; filename*=utf-8''%E7%A5%9D%E6%82%A8%E5%B9%B3%E5%AE%89.odt"
         )
+
+    def test_content_disposition_escaping(self):
+        # fmt: off
+        tests = [
+            (
+                'multi-part-one";\" dummy".txt',
+                r"multi-part-one\";\" dummy\".txt"
+            ),
+        ]
+        # fmt: on
+        # Non-escape sequence backslashes are path segments on Windows, and are
+        # eliminated by an os.path.basename() check in FileResponse.
+        if sys.platform != "win32":
+            # fmt: off
+            tests += [
+                (
+                    'multi-part-one\\";\" dummy".txt',
+                    r"multi-part-one\\\";\" dummy\".txt"
+                ),
+                (
+                    'multi-part-one\\";\\\" dummy".txt',
+                    r"multi-part-one\\\";\\\" dummy\".txt"
+                )
+            ]
+            # fmt: on
+        for filename, escaped in tests:
+            with self.subTest(filename=filename, escaped=escaped):
+                response = FileResponse(
+                    io.BytesIO(b"binary content"), filename=filename, as_attachment=True
+                )
+                response.close()
+                self.assertEqual(
+                    response["Content-Disposition"],
+                    f'attachment; filename="{escaped}"',
+                )
