-
Notifications
You must be signed in to change notification settings - Fork 423
Add HTTP 413 response when incoming request is too large #19212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 11 commits
40d60c8
1e8eaa1
27b5918
c3be074
26a2c8f
5cf7b96
85d84f5
9c4536b
5f4a097
21b114c
426b676
296ed42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add HTTP 413 response when incoming request is too large. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |||||
| # | ||||||
| # | ||||||
| import contextlib | ||||||
| import json | ||||||
| import logging | ||||||
| import time | ||||||
| from http import HTTPStatus | ||||||
|
|
@@ -36,6 +37,7 @@ | |||||
| from twisted.web.resource import IResource, Resource | ||||||
| from twisted.web.server import Request | ||||||
|
|
||||||
| from synapse.api.errors import Codes | ||||||
| from synapse.config.server import ListenerConfig | ||||||
| from synapse.http import get_request_user_agent, redact_uri | ||||||
| from synapse.http.proxy import ProxySite | ||||||
|
|
@@ -146,34 +148,93 @@ def __repr__(self) -> str: | |||||
|
|
||||||
| # Twisted machinery: this method is called by the Channel once the full request has | ||||||
| # been received, to dispatch the request to a resource. | ||||||
| # | ||||||
| # We're patching Twisted to bail/abort early when we see someone trying to upload | ||||||
| # `multipart/form-data` so we can avoid Twisted parsing the entire request body into | ||||||
| # in-memory (specific problem of this specific `Content-Type`). This protects us | ||||||
| # from an attacker uploading something bigger than the available RAM and crashing | ||||||
| # the server with a `MemoryError`, or carefully block just enough resources to cause | ||||||
| # all other requests to fail. | ||||||
| # | ||||||
| # FIXME: This can be removed once we Twisted releases a fix and we update to a | ||||||
| # version that is patched | ||||||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| def requestReceived(self, command: bytes, path: bytes, version: bytes) -> None: | ||||||
| # In the case of a Content-Length header being present, and it's value being too | ||||||
| # large, this will make debugging issues due to overly large requests much | ||||||
| # easier. Currently we handle such cases in `handleContentChunk` and abort the | ||||||
| # connection without providing a proper HTTP response. | ||||||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| # | ||||||
| # Attempting to write an HTTP response from within `handleContentChunk` does not | ||||||
| # work, so the code here has been added to at least provide a response in the | ||||||
| # case of the Content-Length header being present. | ||||||
| content_length_headers = self.requestHeaders.getRawHeaders(b"Content-Length") | ||||||
| if content_length_headers is not None: | ||||||
| if len(content_length_headers) != 1: | ||||||
| logger.warning( | ||||||
| "Dropping request from %s because it contains multiple Content-Length headers: %s %s", | ||||||
| self.client, | ||||||
| command.decode("ascii", errors="replace"), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To align with this comment that says they are the same: #19212 (comment)
Suggested change
|
||||||
| self.get_redacted_uri(), | ||||||
| ) | ||||||
| self.loseConnection() | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be nice to respond with an actual HTTP status code and a good error explaining why. In the case of multiple In the case of no
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, in the case of multiple headers we can do that. We could bake in all these additional checks if you think it doesn't blow this PR up. I'm not sure if twisted already handles these cases or not. |
||||||
| return | ||||||
|
Comment on lines
+162
to
+170
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Explain why we do this. In the SBG we say this type thing:
|
||||||
|
|
||||||
| try: | ||||||
| content_length = int(content_length_headers[0]) | ||||||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+160
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be nicer if this kind of thing was a bit more contained in a block instead of separately validating and then doing a cheeky lookup again. As an example of how we do this in Rust: It could be pulled out into a nested function here 🤷 - Since we should be using this kind of pattern wherever we're looking at headers, it could also be a nice helper utility which raises a certain exception type which can be caught here and we react as we do now here. |
||||||
| if content_length > self._max_request_body_size: | ||||||
| self.method, self.uri = command, path | ||||||
| self.clientproto = version | ||||||
| logger.warning( | ||||||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| "Rejecting request from %s because Content-Length %d exceeds maximum size %d: %s %s", | ||||||
| self.client, | ||||||
| content_length, | ||||||
| self._max_request_body_size, | ||||||
| self.get_method(), | ||||||
| self.get_redacted_uri(), | ||||||
|
||||||
| ) | ||||||
|
|
||||||
| self.code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE.value | ||||||
| self.code_message = bytes( | ||||||
| HTTPStatus.REQUEST_ENTITY_TOO_LARGE.phrase, "ascii" | ||||||
| ) | ||||||
|
|
||||||
| error_response_json = { | ||||||
| "errcode": Codes.TOO_LARGE, | ||||||
| "error": "Request content is too large", | ||||||
| } | ||||||
| error_response_bytes = (json.dumps(error_response_json)).encode() | ||||||
|
|
||||||
| self.responseHeaders.setRawHeaders( | ||||||
| b"Content-Type", [b"application/json"] | ||||||
| ) | ||||||
| self.responseHeaders.setRawHeaders( | ||||||
| b"content-length", [f"{len(error_response_bytes)}"] | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ) | ||||||
| self.write(error_response_bytes) | ||||||
| self.loseConnection() | ||||||
| return | ||||||
| except ValueError: | ||||||
| # Invalid Content-Length header, let it proceed | ||||||
| pass | ||||||
|
Comment on lines
+207
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? |
||||||
|
|
||||||
| # We're patching Twisted to bail/abort early when we see someone trying to upload | ||||||
| # `multipart/form-data` so we can avoid Twisted parsing the entire request body into | ||||||
| # in-memory (specific problem of this specific `Content-Type`). This protects us | ||||||
| # from an attacker uploading something bigger than the available RAM and crashing | ||||||
| # the server with a `MemoryError`, or carefully block just enough resources to cause | ||||||
| # all other requests to fail. | ||||||
| # | ||||||
| # FIXME: This can be removed once Twisted releases a fix and we update to a | ||||||
| # version that is patched | ||||||
| # See: https://github.com/element-hq/synapse/security/advisories/GHSA-rfq8-j7rh-8hf2 | ||||||
| if command == b"POST": | ||||||
| ctype = self.requestHeaders.getRawHeaders(b"content-type") | ||||||
| if ctype and b"multipart/form-data" in ctype[0]: | ||||||
| self.method, self.uri = command, path | ||||||
| self.clientproto = version | ||||||
| logger.warning( | ||||||
| "Aborting connection from %s because `content-type: multipart/form-data` is unsupported: %s %s", | ||||||
| self.client, | ||||||
| self.get_method(), | ||||||
| self.get_redacted_uri(), | ||||||
|
||||||
| ) | ||||||
|
|
||||||
| self.code = HTTPStatus.UNSUPPORTED_MEDIA_TYPE.value | ||||||
| self.code_message = bytes( | ||||||
| HTTPStatus.UNSUPPORTED_MEDIA_TYPE.phrase, "ascii" | ||||||
| ) | ||||||
| self.responseHeaders.setRawHeaders(b"content-length", [b"0"]) | ||||||
|
|
||||||
| logger.warning( | ||||||
| "Aborting connection from %s because `content-type: multipart/form-data` is unsupported: %s %s", | ||||||
| self.client, | ||||||
| command, | ||||||
| path, | ||||||
| ) | ||||||
| self.responseHeaders.setRawHeaders(b"content-length", [b"0"]) | ||||||
| self.write(b"") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should at-least leave a FIXME comment here about returning a better error response like we did above |
||||||
| self.loseConnection() | ||||||
| return | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ | |
| from twisted.internet.address import IPv6Address | ||
| from twisted.internet.testing import MemoryReactor, StringTransport | ||
|
|
||
| from synapse.app._base import max_request_body_size | ||
| from synapse.app.homeserver import SynapseHomeServer | ||
| from synapse.server import HomeServer | ||
| from synapse.util.clock import Clock | ||
|
|
@@ -143,3 +144,39 @@ def test_content_type_multipart(self) -> None: | |
|
|
||
| # we should get a 415 | ||
| self.assertRegex(transport.value().decode(), r"^HTTP/1\.1 415 ") | ||
|
|
||
| def test_content_length_too_large(self) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also have a test for
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure what the best way to write a test for this is.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (test hasn't been added yet)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Perhaps some request with a basic JSON body that will be cut-off and we expect
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After more testing, it seems that Synapse doesn't care if a I can make that change here (to add the assertion). |
||
| """HTTP requests with Content-Length exceeding max size should be rejected with 413""" | ||
| self.hs.start_listening() | ||
|
|
||
| # find the HTTP server which is configured to listen on port 0 | ||
| (port, factory, _backlog, interface) = self.reactor.tcpServers[0] | ||
| self.assertEqual(interface, "::") | ||
| self.assertEqual(port, 0) | ||
|
|
||
| # complete the connection and wire it up to a fake transport | ||
| client_address = IPv6Address("TCP", "::1", 2345) | ||
| protocol = factory.buildProtocol(client_address) | ||
| transport = StringTransport() | ||
| protocol.makeConnection(transport) | ||
|
|
||
| # Send a request with Content-Length header that exceeds the limit. | ||
| # Default max is 50MB (from media max_upload_size), so send something larger. | ||
| oversized_length = 1 + max_request_body_size(self.hs.config) | ||
| protocol.dataReceived( | ||
| b"POST / HTTP/1.1\r\n" | ||
| b"Connection: close\r\n" | ||
| b"Content-Length: " + str(oversized_length).encode() + b"\r\n" | ||
| b"\r\n" | ||
| b"" + b"x" * oversized_length + b"\r\n" | ||
| b"\r\n" | ||
| ) | ||
|
|
||
| # Advance the reactor to process the request | ||
| while not transport.disconnecting: | ||
| self.reactor.advance(1) | ||
|
|
||
| # We should get a 413 Content Too Large | ||
| response = transport.value().decode() | ||
| self.assertRegex(response, r"^HTTP/1\.1 413 ") | ||
| self.assertSubstring("M_TOO_LARGE", response) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1728,9 +1728,6 @@ def test_username_picker_use_displayname_avatar_and_email(self) -> None: | |
| content_is_form=True, | ||
| custom_headers=[ | ||
| ("Cookie", "username_mapping_session=" + session_id), | ||
| # old versions of twisted don't do form-parsing without a valid | ||
| # content-length header. | ||
| ("Content-Length", str(len(content))), | ||
|
Comment on lines
-1731
to
-1733
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And I'm guessing we caught this thanks to our new asserts 👏
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct. What we had wasn't necessarily incorrect according to the spec (see the last point in that section) |
||
| ], | ||
| ) | ||
| self.assertEqual(chan.code, 302, chan.result) | ||
|
|
@@ -1818,9 +1815,6 @@ def test_username_picker_dont_use_displayname_avatar_or_email(self) -> None: | |
| content_is_form=True, | ||
| custom_headers=[ | ||
| ("Cookie", "username_mapping_session=" + session_id), | ||
| # old versions of twisted don't do form-parsing without a valid | ||
| # content-length header. | ||
| ("Content-Length", str(len(content))), | ||
| ], | ||
| ) | ||
| self.assertEqual(chan.code, 302, chan.result) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,7 @@ | |
| from twisted.web.resource import IResource | ||
| from twisted.web.server import Request, Site | ||
|
|
||
| from synapse.api.constants import MAX_REQUEST_SIZE | ||
| from synapse.config.database import DatabaseConnectionConfig | ||
| from synapse.config.homeserver import HomeServerConfig | ||
| from synapse.events.auto_accept_invites import InviteAutoAccepter | ||
|
|
@@ -241,7 +242,6 @@ def writeSequence(self, data: Iterable[bytes]) -> None: | |
|
|
||
| def loseConnection(self) -> None: | ||
| self.unregisterProducer() | ||
| self.transport.loseConnection() | ||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # Type ignore: mypy doesn't like the fact that producer isn't an IProducer. | ||
| def registerProducer(self, producer: IProducer, streaming: bool) -> None: | ||
|
|
@@ -428,7 +428,13 @@ def make_request( | |
|
|
||
| channel = FakeChannel(site, reactor, ip=client_ip) | ||
|
|
||
| req = request(channel, site, our_server_name="test_server") | ||
| # `max_request_body_size` copied from `synapse/app/_base.py -> max_request_body_size()` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment no longer necessary as we're using |
||
| req = request( | ||
| channel, | ||
| site, | ||
| our_server_name="test_server", | ||
| max_request_body_size=MAX_REQUEST_SIZE, | ||
| ) | ||
| channel.request = req | ||
|
|
||
| req.content = BytesIO(content) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.