From 811c53260eae3d255e15c8d4b95d24eb6e135ce5 Mon Sep 17 00:00:00 2001 From: dieter Date: Tue, 17 Oct 2023 13:46:38 +0200 Subject: [PATCH] New `paste.filter_app_factory` entry point `content_length` to allow administrators to fix #1171 --- CHANGES.rst | 8 ++ setup.py | 1 + src/ZPublisher/pastefilter.py | 123 +++++++++++++++++++++++ src/ZPublisher/tests/test_paste.py | 87 ++++++++++++++++ src/Zope2/utilities/skel/etc/zope.ini.in | 5 + 5 files changed, 224 insertions(+) create mode 100644 src/ZPublisher/pastefilter.py create mode 100644 src/ZPublisher/tests/test_paste.py diff --git a/CHANGES.rst b/CHANGES.rst index 432f245de8..25407c4d08 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,14 @@ https://github.com/zopefoundation/Zope/blob/4.x/CHANGES.rst - Update to newest compatible versions of dependencies. +- New ``paste.filter_app_factory`` entry point ``content_length``. + This WSGI middleware component can be used with + WSGI servers which do not follow the PEP 3333 recommendation + regarding input handling for requests with + ``Content-Length`` header. + Allows integrators to fix + `#1171 `_. + 5.8.6 (2023-10-04) ------------------ diff --git a/setup.py b/setup.py index af651379bc..bf102dc607 100644 --- a/setup.py +++ b/setup.py @@ -142,6 +142,7 @@ def _read_file(filename): ], 'paste.filter_app_factory': [ 'httpexceptions=ZPublisher.httpexceptions:main', + 'content_length=ZPublisher.pastefilter:filter_content_length', ], 'console_scripts': [ 'addzopeuser=Zope2.utilities.adduser:main', diff --git a/src/ZPublisher/pastefilter.py b/src/ZPublisher/pastefilter.py new file mode 100644 index 0000000000..71368364bd --- /dev/null +++ b/src/ZPublisher/pastefilter.py @@ -0,0 +1,123 @@ +############################################################################## +# +# Copyright (c) 2023 Zope Foundation and Contributors. +# All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +""" ``PasteDeploy`` filters also known as WSGI middleware. + +`The WSGI architecture `_ +consists of a WSGI server, a WSGI application and optionally +WSGI middleware in between. +The server listens for HTTP requests, describes an incoming +request via a WSGI environment, calls the application with +this environment and the function ``start_response`` and sends the +response to the client. +The application is a callable with parameters *environ* and +*start_response*. It processes the request, calls *start_response* +with the response headers and returns an iterable producing the response +body. +A middleware component takes a (base) application and returns an +(enhanced) application. + +``PasteDeploy`` calls a middleware component a "filter". +In order to be able to provide parameters, filters are configured +via filter factories. ``paste.deploy`` knows two filter factory types: +``filter_factory`` and ``filter_app_factory``. +A filter_factory has signature ``global_conf, **local_conf`` and +returns a filter (i.e. a function transforming an application into +an application), +a filter_app_factory has signature ``app, global_conf, **local_conf`` +and returns the enhanced application directly. +For details see the ``PasteDeploy`` documentation linked from +its PyPI page. + +The main content of this module are filter factory definitions. +They are identified by a `filter_` prefix. +Their factory type is determined by the signature. +""" + + +def filter_content_length(app, global_conf): + """Honor a ``Content-Length`` header. + + Use this filter if the WSGI server does not follow + `Note 1 regarding the WSGI input stream + `_ + (such as the ``simple_server`` of Python's ``wsgiref``) + or violates + `section 6.3 of RFC 7230 + `_. + """ + def enhanced_app(env, start_response): + wrapped = None + content_length = env.get("CONTENT_LENGTH") + if content_length: + env["wsgi.input"] = wrapped = LimitedFileReader( + env["wsgi.input"], int(content_length)) + try: + # Note: this does not special case ``wsgiref.util.FileWrapper`` + # or other similar optimazations + yield from app(env, start_response) + finally: + if wrapped is not None: + wrapped.discard_remaining() + + return enhanced_app + + +class LimitedFileReader: + """File wrapper emulating EOF.""" + + # attributes to be delegated to the file + DELEGATE = {"close", "closed", "fileno", "mode", "name"} + + BUFSIZE = 1 << 14 + + def __init__(self, fp, limit): + """emulate EOF after *limit* bytes have been read. + + *fp* is a binary file like object. + """ + self.fp = fp + assert limit >= 0 + self.limit = limit + + def _enforce_limit(self, size): + limit = self.limit + return limit if size is None or size < 0 else min(size, limit) + + def read(self, size=-1): + data = self.fp.read(self._enforce_limit(size)) + self.limit -= len(data) + return data + + def readline(self, size=-1): + data = self.fp.readline(self._enforce_limit(size)) + self.limit -= len(data) + return data + + def __iter__(self): + return self + + def __next__(self): + data = self.readline() + if not data: + raise StopIteration() + return data + + def discard_remaining(self): + while self.read(self.BUFSIZE): + pass + + def __getattr__(self, attr): + if attr not in self.DELEGATE: + raise AttributeError(attr) + return getattr(self.fp, attr) diff --git a/src/ZPublisher/tests/test_paste.py b/src/ZPublisher/tests/test_paste.py new file mode 100644 index 0000000000..91f79b1cf6 --- /dev/null +++ b/src/ZPublisher/tests/test_paste.py @@ -0,0 +1,87 @@ +############################################################################## +# +# Copyright (c) 2023 Zope Foundation and Contributors. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE. +# +############################################################################## +import unittest +from io import BytesIO + +from paste.deploy import loadfilter + +from ..pastefilter import LimitedFileReader + + +class TestLimitedFileReader(unittest.TestCase): + def test_enforce_limit(self): + f = LimitedFileReader(BytesIO(), 10) + enforce = f._enforce_limit + self.assertEqual(enforce(None), 10) + self.assertEqual(enforce(-1), 10) + self.assertEqual(enforce(20), 10) + self.assertEqual(enforce(5), 5) + + def test_read(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + self.assertEqual(len(f.read()), 10) + self.assertEqual(len(f.read()), 0) + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + self.assertEqual(len(f.read(8)), 8) + self.assertEqual(len(f.read(3)), 2) + self.assertEqual(len(f.read(3)), 0) + + def test_readline(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + self.assertEqual(f.readline(), b"123\n") + self.assertEqual(f.readline(), b"567\n") + self.assertEqual(f.readline(), b"90") + self.assertEqual(f.readline(), b"") + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + self.assertEqual(f.readline(1), b"1") + + def test_iteration(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + self.assertEqual(list(f), [b"123\n", b"567\n", b"90"]) + + def test_discard_remaining(self): + fp = BytesIO(b"123\n567\n901\n") + LimitedFileReader(fp, 10).discard_remaining() + self.assertEqual(fp.read(), b"1\n") + + def test_delegation(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + with self.assertRaises(AttributeError): + f.write + f.close() + + +class TestFilters(unittest.TestCase): + def test_content_length(self): + filter = loadfilter("egg:Zope", "content_length") + + def app(env, start_response): + return iter((env["wsgi.input"],)) + + def request(env, app=filter(app)): + return app(env, None) + + fp = BytesIO() + env = {"wsgi.input": fp} + self.assertIs(next(request(env)), fp) + + fp = BytesIO(b"123") + env = {"wsgi.input": fp} + env["CONTENT_LENGTH"] = "3" + response = request(env) + r = next(response) + self.assertIsInstance(r, LimitedFileReader) + self.assertEqual(r.limit, 3) + with self.assertRaises(StopIteration): + next(response) + self.assertFalse(fp.read()) diff --git a/src/Zope2/utilities/skel/etc/zope.ini.in b/src/Zope2/utilities/skel/etc/zope.ini.in index 9c82ae9147..754e455455 100644 --- a/src/Zope2/utilities/skel/etc/zope.ini.in +++ b/src/Zope2/utilities/skel/etc/zope.ini.in @@ -15,6 +15,11 @@ setup_console_handler = False pipeline = egg:Zope#httpexceptions translogger +# uncomment the following line when your WSGI server does +# not honor the recommendation of note 1 +# regarding the WSGI input stream of PEP 3333 +# or violates section 6.3 of RFC 7230 +# egg:Zope#content_length zope [loggers]