-
Notifications
You must be signed in to change notification settings - Fork 421
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
7e86d3d
e8d8a3b
36973e0
3f9af8d
536bf2d
8c594c9
9e2b2a1
e4f2194
c647a0a
d1b0429
7648b3b
72c3ab3
c2b8d2c
5ab4736
7b0b9b2
b2c219e
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"), | ||
devonh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self.get_redacted_uri(), | ||
|
||
| ) | ||
| self.loseConnection() | ||
|
||
| return | ||
devonh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| try: | ||
| content_length = int(content_length_headers[0]) | ||
devonh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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)}"] | ||
devonh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
| self.write(error_response_bytes) | ||
| self.loseConnection() | ||
| return | ||
| except ValueError: | ||
| # Invalid Content-Length header, let it proceed | ||
| pass | ||
devonh marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| # 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"") | ||
devonh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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).
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. Alright - there is a test for this now. I added it in |
||
| """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) | ||
Uh oh!
There was an error while loading. Please reload this page.