Synapse v2.1.10 — "bounded by bytes, not by hope"¶
Patch release closing an unbounded outbound download in the SSRF guard, found by reading the web-fetch path during weekly maintenance. No schema migrations, no new endpoints, no API changes, no new configuration keys.
Like 2.1.8 and 2.1.9, this did not come from a live failure report. Unlike those two, it does not need an adversary or an unlucky race to fire: one ordinary large PDF behind a web-search result is enough. It is a robustness/DoS fix rather than a confidentiality one — nothing leaks, but the backend can be made to die.
Fixed¶
An outbound fetch buffered the whole response body with no size limit¶
safe_fetch() in app/security_net.py is the single path by which untrusted,
externally-chosen URLs are fetched. Two callers use it:
ops/deep_research.py— fetches every SearXNG (or other provider) result link, at concurrency 3.chat/web_context.py— fetches result links for the chat web-context block.
Both take the entire body at once and cap the extracted text afterwards:
content_md = _html_to_markdown(resp.text)[: _fetch_max_chars()] # deep_research
md = _html_to_markdown(resp.text)[:char_cap] # web_context
Those caps are real (deep_research_fetch_max_chars = 20 000, chat_web_fetch_max_chars
= 8 000) and they were never the problem. They apply to the output of a download that had
already happened in full.
The guard bounded where a fetch may go — scheme allowlist, host resolution against private ranges, redirect count, connect/read timeouts — but never how much it may bring back. Every other limit in the pipeline sits downstream of the complete download.
Three things that look like they would catch this, and do not:
- The read timeout does not bound the transfer. httpx applies
readper read operation, not to a whole response. A server that keeps trickling bytes — each chunk arriving inside the 10 s window — streams for as long as it likes. - The content-type dispatch happens too late.
_fetch_and_extractreadsresp.contentand only then looks at the content-type to decide whether it wanted those bytes:
body = resp.content # already fully buffered
if content_type == "application/pdf" or body[:5] == b"%PDF-":
...
elif _is_texty_content_type(content_type):
...
else:
logger.info("skipping non-text content-type %r", content_type)
A video or a disk image behind a search hit is paid for in RAM in full, then discarded.
- Content-Length was never consulted at all, so even a politely self-declaring
multi-gigabyte response was downloaded.
With deep-research at concurrency 3 that is up to three unbounded buffers at once against a container memory limit — an OOM kill of the whole backend (I7). The trigger does not require an attacker: a 500 MB scanned PDF is a normal thing to find in search results. An adversary who can get a link in front of the crawler makes it deterministic.
The fix¶
The body is now streamed and abandoned the moment it passes max_bytes:
async for chunk in response.aiter_bytes():
received += len(chunk)
if received > max_bytes:
raise ResponseTooLargeError(...)
chunks.append(chunk)
Default MAX_RESPONSE_BYTES is 25 MB — the same ceiling as the existing generic upload limit
(settings.max_upload_bytes), and orders of magnitude above the ~20 KB either caller can
actually consume. It is a module constant with a max_bytes keyword override rather than a new
config key: a new setting would be new surface, and this is a patch.
Three supporting details:
- An oversize advertised
Content-Lengthis refused before any download — a cheap early exit. The received-byte count remains authoritative regardless, becauseContent-Lengthis absent under chunked transfer-encoding and is attacker-supplied either way. - A redirect's body is never downloaded. Only its
Locationheader is ever read, so a redirect chain can no longer be used to pull three bodies on the way to the target. ResponseTooLargeErrorsubclassesSSRFErrordeliberately. Both callers already handleSSRFErrorexactly as this case wants to be handled — log it, skip that source, carry on with the rest of the run — so the cap required no caller change to degrade gracefully, while the distinct type keeps "blocked" and "too big" tellable apart for a future caller.
The capped body is rebuilt into an ordinary non-streaming Response so callers keep using
.content / .text / .headers unchanged. Its Content-Encoding, Content-Length and
Transfer-Encoding headers are stripped when rebuilding: aiter_bytes() has already
applied the content-encoding, so carrying Content-Encoding: gzip onto a response holding
already-decoded bytes makes httpx decode a second time and raise httpx.DecodingError on the
first access to .text.
Regression tests T-SEC-020–T-SEC-023 cover the oversize body, the oversize
Content-Length, the exact-cap boundary (no off-by-one), and the gzip re-read. All four fail
without the fix.
T-SEC-023 is driven through httpx's own MockTransport rather than the httpx_mock
fixture used by its neighbours, and deliberately so: pytest_httpx builds its canned response
eagerly, which decodes the gzip at construction time and then replays the decoded bytes while
still advertising content-encoding: gzip. Against that fixture the double-decode comes from
the mock rather than from the code under test, which is the opposite of what the test is for.
Known limitation (not fixed here)¶
The SSRF guard resolves the hostname, checks every returned address, and then hands httpx the URL by name — so httpx resolves it a second time, independently. A hostile authoritative DNS server answering with a short TTL can pass the check with a public address and serve a private one to the connection that actually opens. That TOCTOU (DNS rebinding) is unchanged by this release; the byte cap limits how much a successful rebind can pull in one request but does not close it.
Fixing it properly means pinning the validated IP — a custom httpx transport that connects to
the already-checked address while keeping the Host header and SNI on the original name —
which touches the shared network path for both deep-research and chat web-context and needs
TLS/SNI and IPv6 verification. That is too large for a weekly patch, so it is recorded in
docs/reference/ROADMAP-3.0-IDEAS.md with evidence and a
sizing (M) instead of being rushed in here.
Upgrade notes¶
Nothing to do. No migration, no configuration change, no API or MCP contract change.
- Behaviour change: a fetched page larger than 25 MB is now skipped instead of being buffered and then (usually) discarded anyway. Deep-research and chat web-context log it at INFO and continue with the remaining sources, exactly as they already did for a blocked or unreachable URL. In practice nothing that previously produced usable content stops doing so: the callers can consume at most ~20 KB of extracted text.
- The cap is a module constant (
app.security_net.MAX_RESPONSE_BYTES) with amax_byteskeyword onsafe_fetch(). It was intentionally not exposed as an environment variable — that would be new configuration surface, which belongs in a minor release rather than a patch. - Backend tests: 3179 passing, coverage 83.19% (gate 82%). Frontend untouched.