|
19 | 19 | # |
20 | 20 | # |
21 | 21 | import contextlib |
| 22 | +import json |
22 | 23 | import logging |
23 | 24 | import time |
24 | 25 | from http import HTTPStatus |
|
36 | 37 | from twisted.web.resource import IResource, Resource |
37 | 38 | from twisted.web.server import Request |
38 | 39 |
|
| 40 | +from synapse.api.errors import Codes, SynapseError |
39 | 41 | from synapse.config.server import ListenerConfig |
40 | 42 | from synapse.http import get_request_user_agent, redact_uri |
41 | 43 | from synapse.http.proxy import ProxySite |
|
59 | 61 | _next_request_seq = 0 |
60 | 62 |
|
61 | 63 |
|
| 64 | +class ContentLengthError(SynapseError): |
| 65 | + """Raised when content-length validation fails.""" |
| 66 | + |
| 67 | + |
62 | 68 | class SynapseRequest(Request): |
63 | 69 | """Class which encapsulates an HTTP request to synapse. |
64 | 70 |
|
@@ -144,36 +150,150 @@ def __repr__(self) -> str: |
144 | 150 | self.synapse_site.site_tag, |
145 | 151 | ) |
146 | 152 |
|
| 153 | + def _respond_with_error(self, synapse_error: SynapseError) -> None: |
| 154 | + """Send an error response and close the connection.""" |
| 155 | + self.setResponseCode(synapse_error.code) |
| 156 | + error_response_bytes = json.dumps(synapse_error.error_dict(None)).encode() |
| 157 | + |
| 158 | + self.responseHeaders.setRawHeaders(b"Content-Type", [b"application/json"]) |
| 159 | + self.responseHeaders.setRawHeaders( |
| 160 | + b"Content-Length", [f"{len(error_response_bytes)}"] |
| 161 | + ) |
| 162 | + self.write(error_response_bytes) |
| 163 | + self.loseConnection() |
| 164 | + |
| 165 | + def _get_content_length_from_headers(self) -> int | None: |
| 166 | + """Attempts to obtain the `Content-Length` value from the request's headers. |
| 167 | +
|
| 168 | + Returns: |
| 169 | + Content length as `int` if present. Otherwise `None`. |
| 170 | +
|
| 171 | + Raises: |
| 172 | + ContentLengthError: if multiple `Content-Length` headers are present or the |
| 173 | + value is not an `int`. |
| 174 | + """ |
| 175 | + content_length_headers = self.requestHeaders.getRawHeaders(b"Content-Length") |
| 176 | + if content_length_headers is None: |
| 177 | + return None |
| 178 | + |
| 179 | + # If there are multiple `Content-Length` headers return an error. |
| 180 | + # We don't want to even try to pick the right one if there are multiple |
| 181 | + # as we could run into problems similar to request smuggling vulnerabilities |
| 182 | + # which rely on the mismatch of how different systems interpret information. |
| 183 | + if len(content_length_headers) != 1: |
| 184 | + raise ContentLengthError( |
| 185 | + HTTPStatus.BAD_REQUEST, |
| 186 | + "Multiple Content-Length headers received", |
| 187 | + Codes.UNKNOWN, |
| 188 | + ) |
| 189 | + |
| 190 | + try: |
| 191 | + return int(content_length_headers[0]) |
| 192 | + except (ValueError, TypeError): |
| 193 | + raise ContentLengthError( |
| 194 | + HTTPStatus.BAD_REQUEST, |
| 195 | + "Content-Length header value is not a valid integer", |
| 196 | + Codes.UNKNOWN, |
| 197 | + ) |
| 198 | + |
| 199 | + def _validate_content_length(self) -> None: |
| 200 | + """Validate Content-Length header and actual content size. |
| 201 | +
|
| 202 | + Raises: |
| 203 | + ContentLengthError: If validation fails. |
| 204 | + """ |
| 205 | + # we should have a `content` by now. |
| 206 | + assert self.content, "_validate_content_length() called before gotLength()" |
| 207 | + content_length = self._get_content_length_from_headers() |
| 208 | + |
| 209 | + if content_length is None: |
| 210 | + return |
| 211 | + |
| 212 | + actual_content_length = self.content.tell() |
| 213 | + |
| 214 | + if content_length > self._max_request_body_size: |
| 215 | + logger.info( |
| 216 | + "Rejecting request from %s because Content-Length %d exceeds maximum size %d: %s %s", |
| 217 | + self.client, |
| 218 | + content_length, |
| 219 | + self._max_request_body_size, |
| 220 | + self.get_method(), |
| 221 | + self.get_redacted_uri(), |
| 222 | + ) |
| 223 | + raise ContentLengthError( |
| 224 | + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, |
| 225 | + f"Request content is too large (>{self._max_request_body_size})", |
| 226 | + Codes.TOO_LARGE, |
| 227 | + ) |
| 228 | + |
| 229 | + if content_length != actual_content_length: |
| 230 | + comparison = ( |
| 231 | + "smaller" if content_length < actual_content_length else "larger" |
| 232 | + ) |
| 233 | + logger.info( |
| 234 | + "Rejecting request from %s because Content-Length %d is %s than the request content size %d: %s %s", |
| 235 | + self.client, |
| 236 | + content_length, |
| 237 | + comparison, |
| 238 | + actual_content_length, |
| 239 | + self.get_method(), |
| 240 | + self.get_redacted_uri(), |
| 241 | + ) |
| 242 | + raise ContentLengthError( |
| 243 | + HTTPStatus.BAD_REQUEST, |
| 244 | + f"Rejecting request as the Content-Length header value {content_length} " |
| 245 | + f"is {comparison} than the actual request content size {actual_content_length}", |
| 246 | + Codes.UNKNOWN, |
| 247 | + ) |
| 248 | + |
147 | 249 | # Twisted machinery: this method is called by the Channel once the full request has |
148 | 250 | # been received, to dispatch the request to a resource. |
149 | | - # |
150 | | - # We're patching Twisted to bail/abort early when we see someone trying to upload |
151 | | - # `multipart/form-data` so we can avoid Twisted parsing the entire request body into |
152 | | - # in-memory (specific problem of this specific `Content-Type`). This protects us |
153 | | - # from an attacker uploading something bigger than the available RAM and crashing |
154 | | - # the server with a `MemoryError`, or carefully block just enough resources to cause |
155 | | - # all other requests to fail. |
156 | | - # |
157 | | - # FIXME: This can be removed once we Twisted releases a fix and we update to a |
158 | | - # version that is patched |
159 | 251 | def requestReceived(self, command: bytes, path: bytes, version: bytes) -> None: |
| 252 | + # In the case of a Content-Length header being present, and it's value being too |
| 253 | + # large, throw a proper error to make debugging issues due to overly large requests much |
| 254 | + # easier. Currently we handle such cases in `handleContentChunk` and abort the |
| 255 | + # connection without providing a proper HTTP response. |
| 256 | + # |
| 257 | + # Attempting to write an HTTP response from within `handleContentChunk` does not |
| 258 | + # work, so the code here has been added to at least provide a response in the |
| 259 | + # case of the Content-Length header being present. |
| 260 | + self.method, self.uri = command, path |
| 261 | + self.clientproto = version |
| 262 | + |
| 263 | + try: |
| 264 | + self._validate_content_length() |
| 265 | + except ContentLengthError as e: |
| 266 | + self._respond_with_error(e) |
| 267 | + return |
| 268 | + |
| 269 | + # We're patching Twisted to bail/abort early when we see someone trying to upload |
| 270 | + # `multipart/form-data` so we can avoid Twisted parsing the entire request body into |
| 271 | + # in-memory (specific problem of this specific `Content-Type`). This protects us |
| 272 | + # from an attacker uploading something bigger than the available RAM and crashing |
| 273 | + # the server with a `MemoryError`, or carefully block just enough resources to cause |
| 274 | + # all other requests to fail. |
| 275 | + # |
| 276 | + # FIXME: This can be removed once Twisted releases a fix and we update to a |
| 277 | + # version that is patched |
| 278 | + # See: https://github.com/element-hq/synapse/security/advisories/GHSA-rfq8-j7rh-8hf2 |
160 | 279 | if command == b"POST": |
161 | 280 | ctype = self.requestHeaders.getRawHeaders(b"content-type") |
162 | 281 | if ctype and b"multipart/form-data" in ctype[0]: |
163 | | - self.method, self.uri = command, path |
164 | | - self.clientproto = version |
| 282 | + logger.warning( |
| 283 | + "Aborting connection from %s because `content-type: multipart/form-data` is unsupported: %s %s", |
| 284 | + self.client, |
| 285 | + self.get_method(), |
| 286 | + self.get_redacted_uri(), |
| 287 | + ) |
| 288 | + |
165 | 289 | self.code = HTTPStatus.UNSUPPORTED_MEDIA_TYPE.value |
166 | 290 | self.code_message = bytes( |
167 | 291 | HTTPStatus.UNSUPPORTED_MEDIA_TYPE.phrase, "ascii" |
168 | 292 | ) |
169 | | - self.responseHeaders.setRawHeaders(b"content-length", [b"0"]) |
170 | 293 |
|
171 | | - logger.warning( |
172 | | - "Aborting connection from %s because `content-type: multipart/form-data` is unsupported: %s %s", |
173 | | - self.client, |
174 | | - command, |
175 | | - path, |
176 | | - ) |
| 294 | + # FIXME: Return a better error response here similar to the |
| 295 | + # `error_response_json` returned in other code paths here. |
| 296 | + self.responseHeaders.setRawHeaders(b"Content-Length", [b"0"]) |
177 | 297 | self.write(b"") |
178 | 298 | self.loseConnection() |
179 | 299 | return |
|
0 commit comments