Version in base suite: 2.2.2-3 Base version: python-werkzeug_2.2.2-3 Target version: python-werkzeug_2.2.2-3+deb12u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-werkzeug/python-werkzeug_2.2.2-3.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-werkzeug/python-werkzeug_2.2.2-3+deb12u1.dsc changelog | 14 ++++ patches/CVE-2023-46136.patch | 35 ++++++++++ patches/CVE-2024-34069-1.patch | 142 +++++++++++++++++++++++++++++++++++++++++ patches/CVE-2024-34069-2.patch | 116 +++++++++++++++++++++++++++++++++ patches/CVE-2024-49767.patch | 80 +++++++++++++++++++++++ patches/series | 4 + 6 files changed, 391 insertions(+) diff -Nru python-werkzeug-2.2.2/debian/changelog python-werkzeug-2.2.2/debian/changelog --- python-werkzeug-2.2.2/debian/changelog 2023-04-21 11:37:22.000000000 +0000 +++ python-werkzeug-2.2.2/debian/changelog 2024-12-07 08:44:56.000000000 +0000 @@ -1,3 +1,17 @@ +python-werkzeug (2.2.2-3+deb12u1) bookworm; urgency=high + + * Backport upstream fix for CVE-2023-46136 + (denial of service when file upload begins with CR or LF) + (Closes: #1054553). + * Backport upstream fixes for CVE-2024-34069 + (arbitrary code execution on developer's machine via the debugger) + (Closes: #1070711). + * Backport upstream fix for CVE-2024-49767 + (denial of service when processing multipart/form-data requests) + (Closes: #1086062). + + -- Sean Whitton Sat, 07 Dec 2024 16:44:56 +0800 + python-werkzeug (2.2.2-3) unstable; urgency=medium [ Robin Gustafsson ] diff -Nru python-werkzeug-2.2.2/debian/patches/CVE-2023-46136.patch python-werkzeug-2.2.2/debian/patches/CVE-2023-46136.patch --- python-werkzeug-2.2.2/debian/patches/CVE-2023-46136.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-werkzeug-2.2.2/debian/patches/CVE-2023-46136.patch 2024-12-07 08:44:56.000000000 +0000 @@ -0,0 +1,35 @@ +From: =?utf-8?q?Pawe=C5=82_Srokosz?= +Date: Thu, 12 Oct 2023 18:50:04 +0200 +Subject: Fix: slow multipart parsing for huge files with few CR/LF characters + +(cherry picked from commit b1916c0c083e0be1c9d887ee2f3d696922bfc5c1) +--- + src/werkzeug/sansio/multipart.py | 10 +++++++++- + 1 file changed, 9 insertions(+), 1 deletion(-) + +diff --git a/src/werkzeug/sansio/multipart.py b/src/werkzeug/sansio/multipart.py +index 2684e5d..2c0947d 100644 +--- a/src/werkzeug/sansio/multipart.py ++++ b/src/werkzeug/sansio/multipart.py +@@ -206,12 +206,20 @@ class MultipartDecoder: + self._search_position = max(0, len(self.buffer) - SEARCH_EXTRA_LENGTH) + + elif self.state == State.DATA: +- if self.buffer.find(b"--" + self.boundary) == -1: ++ boundary = b"--" + self.boundary ++ ++ if self.buffer.find(boundary) == -1: + # No complete boundary in the buffer, but there may be + # a partial boundary at the end. As the boundary + # starts with either a nl or cr find the earliest and + # return up to that as data. + data_length = del_index = self.last_newline() ++ # If amount of data after last newline is far from ++ # possible length of partial boundary, we should ++ # assume that there is no partial boundary in the buffer ++ # and return all pending data. ++ if (len(self.buffer) - data_length) > len(b"\n" + boundary): ++ data_length = del_index = len(self.buffer) + more_data = True + else: + match = self.boundary_re.search(self.buffer) diff -Nru python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-1.patch python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-1.patch --- python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-1.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-1.patch 2024-12-07 08:44:56.000000000 +0000 @@ -0,0 +1,142 @@ +From: David Lord +Date: Thu, 2 May 2024 11:55:52 -0700 +Subject: restrict debugger trusted hosts + +Add a list of `trusted_hosts` to the `DebuggedApplication` middleware. It defaults to only allowing `localhost`, `.localhost` subdomains, and `127.0.0.1`. `run_simple(use_debugger=True)` adds its `hostname` argument to the trusted list as well. The middleware can be used directly to further modify the trusted list in less common development scenarios. + +The debugger UI uses the full `document.location` instead of only `document.location.pathname`. + +Either of these fixes on their own mitigates the reported vulnerability. + +(cherry picked from commit 71b69dfb7df3d912e66bab87fbb1f21f83504967) +--- + docs/debug.rst | 35 ++++++++++++++++++++++++++++++----- + src/werkzeug/debug/__init__.py | 10 ++++++++++ + src/werkzeug/debug/shared/debugger.js | 4 ++-- + src/werkzeug/serving.py | 3 +++ + 4 files changed, 45 insertions(+), 7 deletions(-) + +diff --git a/docs/debug.rst b/docs/debug.rst +index 25a9f0b..d842135 100644 +--- a/docs/debug.rst ++++ b/docs/debug.rst +@@ -16,7 +16,8 @@ interactive debug console to execute code in any frame. + The debugger allows the execution of arbitrary code which makes it a + major security risk. **The debugger must never be used on production + machines. We cannot stress this enough. Do not enable the debugger +- in production.** ++ in production.** Production means anything that is not development, ++ and anything that is publicly accessible. + + .. note:: + +@@ -72,10 +73,9 @@ argument to get a detailed list of all the attributes it has. + Debugger PIN + ------------ + +-Starting with Werkzeug 0.11 the debug console is protected by a PIN. +-This is a security helper to make it less likely for the debugger to be +-exploited if you forget to disable it when deploying to production. The +-PIN based authentication is enabled by default. ++The debug console is protected by a PIN. This is a security helper to make it ++less likely for the debugger to be exploited if you forget to disable it when ++deploying to production. The PIN based authentication is enabled by default. + + The first time a console is opened, a dialog will prompt for a PIN that + is printed to the command line. The PIN is generated in a stable way +@@ -92,6 +92,31 @@ intended to make it harder for an attacker to exploit the debugger. + Never enable the debugger in production.** + + ++Allowed Hosts ++------------- ++ ++The debug console will only be served if the request comes from a trusted host. ++If a request comes from a browser page that is not served on a trusted URL, a ++400 error will be returned. ++ ++By default, ``localhost``, any ``.localhost`` subdomain, and ``127.0.0.1`` are ++trusted. ``run_simple`` will trust its ``hostname`` argument as well. To change ++this further, use the debug middleware directly rather than through ++``use_debugger=True``. ++ ++.. code-block:: python ++ ++ if os.environ.get("USE_DEBUGGER") in {"1", "true"}: ++ app = DebuggedApplication(app, evalex=True) ++ app.trusted_hosts = [...] ++ ++ run_simple("localhost", 8080, app) ++ ++**This feature is not meant to entirely secure the debugger. It is ++intended to make it harder for an attacker to exploit the debugger. ++Never enable the debugger in production.** ++ ++ + Pasting Errors + -------------- + +diff --git a/src/werkzeug/debug/__init__.py b/src/werkzeug/debug/__init__.py +index e0dcc65..9579f2c 100644 +--- a/src/werkzeug/debug/__init__.py ++++ b/src/werkzeug/debug/__init__.py +@@ -296,6 +296,14 @@ class DebuggedApplication: + else: + self.pin = None + ++ self.trusted_hosts: list[str] = [".localhost", "127.0.0.1"] ++ """List of domains to allow requests to the debugger from. A leading dot ++ allows all subdomains. This only allows ``".localhost"`` domains by ++ default. ++ ++ .. versionadded:: 3.0.3 ++ """ ++ + @property + def pin(self) -> t.Optional[str]: + if not hasattr(self, "_pin"): +@@ -504,6 +512,8 @@ class DebuggedApplication: + # form data! Otherwise the application won't have access to that data + # any more! + request = Request(environ) ++ request.trusted_hosts = self.trusted_hosts ++ assert request.host # will raise 400 error if not trusted + response = self.debug_application + if request.args.get("__debugger__") == "yes": + cmd = request.args.get("cmd") +diff --git a/src/werkzeug/debug/shared/debugger.js b/src/werkzeug/debug/shared/debugger.js +index 2354f03..bee079f 100644 +--- a/src/werkzeug/debug/shared/debugger.js ++++ b/src/werkzeug/debug/shared/debugger.js +@@ -48,7 +48,7 @@ function initPinBox() { + btn.disabled = true; + + fetch( +- `${document.location.pathname}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}` ++ `${document.location}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}` + ) + .then((res) => res.json()) + .then(({auth, exhausted}) => { +@@ -79,7 +79,7 @@ function promptForPin() { + if (!EVALEX_TRUSTED) { + const encodedSecret = encodeURIComponent(SECRET); + fetch( +- `${document.location.pathname}?__debugger__=yes&cmd=printpin&s=${encodedSecret}` ++ `${document.location}?__debugger__=yes&cmd=printpin&s=${encodedSecret}` + ); + const pinPrompt = document.getElementsByClassName("pin-prompt")[0]; + fadeIn(pinPrompt); +diff --git a/src/werkzeug/serving.py b/src/werkzeug/serving.py +index c482469..2db07be 100644 +--- a/src/werkzeug/serving.py ++++ b/src/werkzeug/serving.py +@@ -1057,6 +1057,9 @@ def run_simple( + from .debug import DebuggedApplication + + application = DebuggedApplication(application, evalex=use_evalex) ++ # Allow the specified hostname to use the debugger, in addition to ++ # localhost domains. ++ application.trusted_hosts.append(hostname) + + if not is_running_from_reloader(): + s = prepare_socket(hostname, port) diff -Nru python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-2.patch python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-2.patch --- python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-werkzeug-2.2.2/debian/patches/CVE-2024-34069-2.patch 2024-12-07 08:44:56.000000000 +0000 @@ -0,0 +1,116 @@ +From: David Lord +Date: Fri, 3 May 2024 14:49:43 -0700 +Subject: only require trusted host for evalex + +(cherry picked from commit 890b6b62634fa61224222aee31081c61b054ff01) +--- + src/werkzeug/debug/__init__.py | 25 ++++++++++++++++++++----- + src/werkzeug/sansio/utils.py | 2 +- + 2 files changed, 21 insertions(+), 6 deletions(-) + +diff --git a/src/werkzeug/debug/__init__.py b/src/werkzeug/debug/__init__.py +index 9579f2c..1bba5c0 100644 +--- a/src/werkzeug/debug/__init__.py ++++ b/src/werkzeug/debug/__init__.py +@@ -18,7 +18,9 @@ from zlib import adler32 + + from .._internal import _log + from ..exceptions import NotFound ++from ..exceptions import SecurityError + from ..http import parse_cookie ++from ..sansio.utils import host_is_trusted + from ..security import gen_salt + from ..utils import send_file + from ..wrappers.request import Request +@@ -350,7 +352,7 @@ class DebuggedApplication: + + is_trusted = bool(self.check_pin_trust(environ)) + html = tb.render_debugger_html( +- evalex=self.evalex, ++ evalex=self.evalex and self.check_host_trust(environ), + secret=self.secret, + evalex_trusted=is_trusted, + ) +@@ -378,6 +380,9 @@ class DebuggedApplication: + frame: t.Union[DebugFrameSummary, _ConsoleFrame], + ) -> Response: + """Execute a command in a console.""" ++ if not self.check_host_trust(request.environ): ++ return SecurityError() # type: ignore[return-value] ++ + contexts = self.frame_contexts.get(id(frame), []) + + with ExitStack() as exit_stack: +@@ -388,6 +393,9 @@ class DebuggedApplication: + + def display_console(self, request: Request) -> Response: + """Display a standalone shell.""" ++ if not self.check_host_trust(request.environ): ++ return SecurityError() # type: ignore[return-value] ++ + if 0 not in self.frames: + if self.console_init_func is None: + ns = {} +@@ -440,12 +448,18 @@ class DebuggedApplication: + return None + return (time.time() - PIN_TIME) < ts + ++ def check_host_trust(self, environ: "WSGIEnvironment") -> bool: ++ return host_is_trusted(environ.get("HTTP_HOST"), self.trusted_hosts) ++ + def _fail_pin_auth(self) -> None: + time.sleep(5.0 if self._failed_pin_auth > 5 else 0.5) + self._failed_pin_auth += 1 + + def pin_auth(self, request: Request) -> Response: + """Authenticates with the pin.""" ++ if not self.check_host_trust(request.environ): ++ return SecurityError() # type: ignore[return-value] ++ + exhausted = False + auth = False + trust = self.check_pin_trust(request.environ) +@@ -495,8 +509,11 @@ class DebuggedApplication: + rv.delete_cookie(self.pin_cookie_name) + return rv + +- def log_pin_request(self) -> Response: ++ def log_pin_request(self, request: Request) -> Response: + """Log the pin if needed.""" ++ if not self.check_host_trust(request.environ): ++ return SecurityError() # type: ignore[return-value] ++ + if self.pin_logging and self.pin is not None: + _log( + "info", " * To enable the debugger you need to enter the security pin:" +@@ -512,8 +529,6 @@ class DebuggedApplication: + # form data! Otherwise the application won't have access to that data + # any more! + request = Request(environ) +- request.trusted_hosts = self.trusted_hosts +- assert request.host # will raise 400 error if not trusted + response = self.debug_application + if request.args.get("__debugger__") == "yes": + cmd = request.args.get("cmd") +@@ -525,7 +540,7 @@ class DebuggedApplication: + elif cmd == "pinauth" and secret == self.secret: + response = self.pin_auth(request) # type: ignore + elif cmd == "printpin" and secret == self.secret: +- response = self.log_pin_request() # type: ignore ++ response = self.log_pin_request(request) # type: ignore + elif ( + self.evalex + and cmd is not None +diff --git a/src/werkzeug/sansio/utils.py b/src/werkzeug/sansio/utils.py +index e639dcb..cc85927 100644 +--- a/src/werkzeug/sansio/utils.py ++++ b/src/werkzeug/sansio/utils.py +@@ -6,7 +6,7 @@ from ..urls import uri_to_iri + from ..urls import url_quote + + +-def host_is_trusted(hostname: str, trusted_list: t.Iterable[str]) -> bool: ++def host_is_trusted(hostname: t.Optional[str], trusted_list: t.Iterable[str]) -> bool: + """Check if a host matches a list of trusted names. + + :param hostname: The name to check. diff -Nru python-werkzeug-2.2.2/debian/patches/CVE-2024-49767.patch python-werkzeug-2.2.2/debian/patches/CVE-2024-49767.patch --- python-werkzeug-2.2.2/debian/patches/CVE-2024-49767.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-werkzeug-2.2.2/debian/patches/CVE-2024-49767.patch 2024-12-07 08:44:56.000000000 +0000 @@ -0,0 +1,80 @@ +From: David Lord +Date: Fri, 25 Oct 2024 06:46:50 -0700 +Subject: apply max_form_memory_size another level up in the parser + +(cherry picked from commit 8760275afb72bd10b57d92cb4d52abf759b2f3a7) +--- + src/werkzeug/formparser.py | 11 +++++++++++ + src/werkzeug/sansio/multipart.py | 2 ++ + tests/test_formparser.py | 12 ++++++++++++ + 3 files changed, 25 insertions(+) + +diff --git a/src/werkzeug/formparser.py b/src/werkzeug/formparser.py +index bebb2fc..fc458cd 100644 +--- a/src/werkzeug/formparser.py ++++ b/src/werkzeug/formparser.py +@@ -405,6 +405,7 @@ class MultiPartParser: + def parse( + self, stream: t.IO[bytes], boundary: bytes, content_length: t.Optional[int] + ) -> t.Tuple[MultiDict, MultiDict]: ++ field_size: t.Optional[int] = None + container: t.Union[t.IO[bytes], t.List[bytes]] + _write: t.Callable[[bytes], t.Any] + +@@ -431,13 +432,23 @@ class MultiPartParser: + while not isinstance(event, (Epilogue, NeedData)): + if isinstance(event, Field): + current_part = event ++ field_size = 0 + container = [] + _write = container.append + elif isinstance(event, File): + current_part = event ++ field_size = None + container = self.start_file_streaming(event, content_length) + _write = container.write + elif isinstance(event, Data): ++ if self.max_form_memory_size is not None and field_size is not None: ++ # Ensure that accumulated data events do not exceed limit. ++ # Also checked within single event in MultipartDecoder. ++ field_size += len(event.data) ++ ++ if field_size > self.max_form_memory_size: ++ raise RequestEntityTooLarge() ++ + _write(event.data) + if not event.more_data: + if isinstance(current_part, Field): +diff --git a/src/werkzeug/sansio/multipart.py b/src/werkzeug/sansio/multipart.py +index 2c0947d..99eb6ea 100644 +--- a/src/werkzeug/sansio/multipart.py ++++ b/src/werkzeug/sansio/multipart.py +@@ -142,6 +142,8 @@ class MultipartDecoder: + self.max_form_memory_size is not None + and len(self.buffer) + len(data) > self.max_form_memory_size + ): ++ # Ensure that data within single event does not exceed limit. ++ # Also checked across accumulated events in MultiPartParser. + raise RequestEntityTooLarge() + else: + self.buffer.extend(data) +diff --git a/tests/test_formparser.py b/tests/test_formparser.py +index 4c518b1..05fa84e 100644 +--- a/tests/test_formparser.py ++++ b/tests/test_formparser.py +@@ -455,3 +455,15 @@ class TestMultiPartParser: + ) as request: + assert request.files["rfc2231"].filename == "a b c d e f.txt" + assert request.files["rfc2231"].read() == b"file contents" ++ ++ ++def test_multipart_max_form_memory_size() -> None: ++ """max_form_memory_size is tracked across multiple data events.""" ++ data = b"--bound\r\nContent-Disposition: form-field; name=a\r\n\r\n" ++ data += b"a" * 15 + b"\r\n--bound--" ++ # The buffer size is less than the max size, so multiple data events will be ++ # returned. The field size is greater than the max. ++ parser = formparser.MultiPartParser(max_form_memory_size=10, buffer_size=5) ++ ++ with pytest.raises(RequestEntityTooLarge): ++ parser.parse(io.BytesIO(data), b"bound", None) diff -Nru python-werkzeug-2.2.2/debian/patches/series python-werkzeug-2.2.2/debian/patches/series --- python-werkzeug-2.2.2/debian/patches/series 2023-04-21 11:37:22.000000000 +0000 +++ python-werkzeug-2.2.2/debian/patches/series 2024-12-07 08:44:56.000000000 +0000 @@ -2,3 +2,7 @@ remove-test_exclude_patterns-test.patch 0003-don-t-strip-leading-when-parsing-cookie.patch 0004-limit-the-maximum-number-of-multipart-form-parts.patch +CVE-2023-46136.patch +CVE-2024-34069-1.patch +CVE-2024-34069-2.patch +CVE-2024-49767.patch