diff --git a/CHANGES.rst b/CHANGES.rst index 432f245de8..cbcaf0c708 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,9 @@ https://github.com/zopefoundation/Zope/blob/4.x/CHANGES.rst - Update to newest compatible versions of dependencies. +- Honor a request's `Content-Length` + (`#1171 `_). + 5.8.6 (2023-10-04) ------------------ diff --git a/src/ZPublisher/HTTPRequest.py b/src/ZPublisher/HTTPRequest.py index 20421a14b5..a0159df593 100644 --- a/src/ZPublisher/HTTPRequest.py +++ b/src/ZPublisher/HTTPRequest.py @@ -1427,9 +1427,17 @@ class ZopeFieldStorage(ValueAccessor): VALUE_LIMIT = Global("FORM_MEMORY_LIMIT") def __init__(self, fp, environ): - self.file = fp method = environ.get("REQUEST_METHOD", "GET").upper() url_qs = environ.get("QUERY_STRING", "") + content_length = environ.get("CONTENT_LENGTH") + if content_length: + try: + fp.tell() + except Exception: + # potentially not preprocessed by the WSGI server + # enforce ``Content-Length`` specified body length limit + fp = LimitedFileReader(fp, int(content_length)) + self.file = fp post_qs = "" hl = [] content_type = environ.get("CONTENT_TYPE", @@ -1493,6 +1501,53 @@ def __init__(self, fp, environ): add_field(field) +class LimitedFileReader: + """File wrapper emulating EOF.""" + + # attributes to be delegated to the file + DELEGATE = set("close closed fileno mode name".split()) + + def __init__(self, fp, limit): + """emulate EOF after *limit* bytes have been read. + + *fp* is a binary file like object without ``seek`` support. + """ + 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 __del__(self): + return self.fp.__del__() + + def __getattr__(self, attr): + if attr not in self.DELEGATE: + raise AttributeError(attr) + return getattr(self.fp, attr) + + def _mp_charset(part): """the charset of *part*.""" content_type = part.headers.get("Content-Type", "") diff --git a/src/ZPublisher/tests/testHTTPRequest.py b/src/ZPublisher/tests/testHTTPRequest.py index d7fe18e4d3..0dafba669f 100644 --- a/src/ZPublisher/tests/testHTTPRequest.py +++ b/src/ZPublisher/tests/testHTTPRequest.py @@ -31,6 +31,7 @@ from zope.testing.cleanup import cleanUp from ZPublisher.HTTPRequest import BadRequest from ZPublisher.HTTPRequest import FileUpload +from ZPublisher.HTTPRequest import LimitedFileReader from ZPublisher.HTTPRequest import search_type from ZPublisher.interfaces import IXmlrpcChecker from ZPublisher.tests.testBaseRequest import TestRequestViewsBase @@ -1514,6 +1515,15 @@ def test_form_charset(self): self.assertEqual(req["x"], "äöü") self.assertEqual(req["y"], "äöü") + def test_content_length_limitation(self): + body = b"123abc" + env = self._makePostEnviron(body) + env["CONTENT_TYPE"] = "application/octed-stream" + env["CONTENT_LENGTH"] = "3" + req = self._makeOne(_Unseekable(BytesIO(body)), env) + req.processInputs() + self.assertEqual(req["BODY"], b"123") + class TestHTTPRequestZope3Views(TestRequestViewsBase): @@ -1570,6 +1580,48 @@ def test_special(self): self.check("abc:a-_0b", ":a-_0b") +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_del(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + del f + + def test_delegation(self): + f = LimitedFileReader(BytesIO(b"123\n567\n901\n"), 10) + with self.assertRaises(AttributeError): + f.write + f.close() + + class _Unseekable: """Auxiliary class emulating an unseekable file like object""" def __init__(self, file):