Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
40d60c8
Add HTTP 413 response when incoming request is too large
devonh Nov 21, 2025
1e8eaa1
Add changelog entry
devonh Nov 21, 2025
27b5918
Merge branch 'develop' into devon/too-large-http-response
devonh Nov 21, 2025
c3be074
Log redacted uri
devonh Nov 21, 2025
26a2c8f
Log redacted uri in content-type check
devonh Nov 21, 2025
5cf7b96
Address review comments
devonh Nov 26, 2025
85d84f5
Upade comment wording
devonh Nov 26, 2025
9c4536b
Rejig max_request_size usage to use common constants everywhere
devonh Nov 26, 2025
5f4a097
Add ref to upstream twisted changes needed
devonh Nov 26, 2025
21b114c
Don't add duplicate Content-Length header in tests
devonh Nov 26, 2025
426b676
Remove unused variable
devonh Nov 27, 2025
296ed42
Update synapse/http/site.py
devonh Dec 2, 2025
7e86d3d
Log rejection at info level
devonh Dec 1, 2025
e8d8a3b
Temp changes for incorrect Content-Length
devonh Dec 2, 2025
36973e0
Merge branch 'develop' into devon/too-large-http-response
devonh Dec 4, 2025
3f9af8d
Remove invalid comment
devonh Dec 4, 2025
536bf2d
Address review comments
devonh Dec 4, 2025
8c594c9
Add more comments
devonh Dec 4, 2025
9e2b2a1
Add FIXME comment
devonh Dec 4, 2025
e4f2194
Refactor content-length extraction
devonh Dec 4, 2025
c647a0a
Fix exception handling
devonh Dec 4, 2025
d1b0429
Add test for too small Content-Length header
devonh Dec 4, 2025
7648b3b
Fix linter errors
devonh Dec 4, 2025
72c3ab3
Add comment about why we reject multiple content-length headers
devonh Dec 4, 2025
c2b8d2c
Fix mypy errors
devonh Dec 4, 2025
5ab4736
Fix test content-length header addition logic
devonh Dec 4, 2025
7b0b9b2
Refactor error response writing to be reusable
devonh Dec 4, 2025
b2c219e
Add return type
devonh Dec 4, 2025
2a2cd4b
Move log
devonh Dec 4, 2025
485283c
Update log
devonh Dec 4, 2025
b095437
Add changelog entry
devonh Dec 4, 2025
b5b4643
Refactor extracted function to be class member
devonh Dec 5, 2025
e7fe25f
Update synapse/http/site.py
devonh Dec 5, 2025
bea38d4
Update tests/test_server.py
devonh Dec 5, 2025
e07fd8b
Better error messages
devonh Dec 5, 2025
93f58e6
Add validation for content_length too large
devonh Dec 5, 2025
070356d
Merge branch 'develop' into devon/too-large-http-response
devonh Dec 5, 2025
525bdbd
Update tests/test_server.py
devonh Dec 8, 2025
adb4359
Use SynapseError type
devonh Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19212.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add HTTP 413 response when incoming request is too large.
59 changes: 47 additions & 12 deletions synapse/http/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,52 @@

# 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
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.
#
# 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:
try:
content_length = int(content_length_headers[0])
if content_length > self._max_request_body_size:
self.method, self.uri = command, path
self.clientproto = version
self.code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE.value
self.code_message = bytes(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE.phrase, "ascii"
)
self.responseHeaders.setRawHeaders(b"content-length", [b"0"])

logger.warning(
"Rejecting request from %s because Content-Length %d exceeds maximum size %d: %s %s",
self.client,
content_length,
self._max_request_body_size,
command.decode("ascii", errors="replace"),
self.get_redacted_uri(),
)
self.write(b"")
self.loseConnection()
return
except (ValueError, IndexError):
# Invalid Content-Length header, let it proceed
pass

# 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
if command == b"POST":
ctype = self.requestHeaders.getRawHeaders(b"content-type")
if ctype and b"multipart/form-data" in ctype[0]:
Expand All @@ -171,8 +206,8 @@
logger.warning(
"Aborting connection from %s because `content-type: multipart/form-data` is unsupported: %s %s",
self.client,
command,
path,
command.decode("ascii", errors="replace"),
self.get_redacted_uri(),
)
self.write(b"")
self.loseConnection()
Expand Down
34 changes: 34 additions & 0 deletions tests/http/test_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,37 @@ 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also have a test for oversized_length content but small false Content-Length header

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
I did a bunch of testing and Synapse (Twisted) handles this by truncating the HTTP request at the specified size of the Content-Length. So the request will go through to the server correctly but the body will be silently truncated. The rest of the bytes are dropped.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(test hasn't been added yet)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bunch of testing and Synapse (Twisted) handles this by truncating the HTTP request at the specified size of the Content-Length. So the request will go through to the server correctly but the body will be silently truncated. The rest of the bytes are dropped.

Perhaps some request with a basic JSON body that will be cut-off and we expect M_NOT_JSON 🤷

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After more testing, it seems that Synapse doesn't care if a Content-Length is present and the value is smaller than the actual size of the content. It just deals with it.
The HTTP spec isn't exactly clear what is the correct thing to do in this case. And googling didn't give great results.
This section of the spec (see point 4) could be taken to mean we should be responding with 400 Bad Request in this case.

I can make that change here (to add the assertion).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright - there is a test for this now. I added it in tests/test_server.py since I was able to get it working properly in there.

"""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 = 60 * 1024 * 1024
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"
)
protocol.dataReceived(b"x" * oversized_length)

# Advance the reactor to process the request
while not transport.disconnecting:
self.reactor.advance(1)

# we should get a 413 Payload Too Large
response = transport.value().decode()
self.assertRegex(response, r"^HTTP/1\.1 413 ")
10 changes: 8 additions & 2 deletions tests/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
from twisted.web.resource import IResource
from twisted.web.server import Request, Site

from synapse.api.constants import MAX_PDU_SIZE
from synapse.config.database import DatabaseConnectionConfig
from synapse.config.homeserver import HomeServerConfig
from synapse.events.auto_accept_invites import InviteAutoAccepter
Expand Down Expand Up @@ -241,7 +242,6 @@ def writeSequence(self, data: Iterable[bytes]) -> None:

def loseConnection(self) -> None:
self.unregisterProducer()
self.transport.loseConnection()

# Type ignore: mypy doesn't like the fact that producer isn't an IProducer.
def registerProducer(self, producer: IProducer, streaming: bool) -> None:
Expand Down Expand Up @@ -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()`
req = request(
channel,
site,
our_server_name="test_server",
max_request_body_size=200 * MAX_PDU_SIZE,
)
channel.request = req

req.content = BytesIO(content)
Expand Down
Loading