Files
composiohq__composio/python/tests/test_file_upload_robustness.py
Alberto Schiabel d544006a25 fix(sdk): pin the validated address when fetching URLs (SSRF DNS rebinding) (#4172)
Fixes #4151.

## The problem

Both SDKs validated a URL by resolving its hostname, and then handed the
*hostname* to the HTTP client, which resolved it again when it opened
the socket. Two lookups, two answers: a short-TTL record under an
attacker's control answers publicly for the check and with
`169.254.169.254`, `127.0.0.1`, or RFC 1918 space for the connect. The
guard passes and the connection lands inside the network — classic
TOCTOU DNS rebinding, documented in both modules until now as a known
residual.

```mermaid
sequenceDiagram
    participant SDK
    participant DNS as Attacker DNS
    participant Meta as 169.254.169.254
    Note over SDK,Meta: before
    SDK->>DNS: resolve evil.example.com (validate)
    DNS-->>SDK: 93.184.216.34 — passes the guard
    SDK->>DNS: resolve evil.example.com (connect)
    DNS-->>SDK: 169.254.169.254
    SDK->>Meta: GET /latest/meta-data/…
    Meta-->>SDK: credentials
```

## The fix

Resolve once, validate every answer, then connect to the address that
was validated. There is no second lookup left to rebind.

- **Python** — `safe_get` / `safe_request` mount a transport adapter
that swaps the connect target for the duration of the socket connect
only. The `Host` header and TLS SNI keep the hostname, so certificate
verification is unchanged; rewriting `conn._dns_host` for the whole
connection would have sent `Host: <ip>` and offered the IP as SNI,
failing against every real origin. Every fetch call site now goes
through those two helpers, so no `requests.get` sits next to a bare
check any more:
- `_files.py::_fetch_file_from_url`,
`_files.py::FileDownloadable.download`
  - `tool_router_session_files.py::_fetch_url_bytes`
  - `safe_request`, per redirect hop
- **TypeScript** — `assertSafeFetchTarget` returns the validated address
and `ssrfSafeFetch` hands `fetch` a dispatcher pinned to it, re-pinned
per redirect hop. The dispatcher goes to the runtime's own `fetch`, so
callers that stub `globalThis.fetch` keep working. The pinned `lookup`
answers both shapes Node calls it with — the address *list* it uses for
Happy Eyeballs, and the single `(address, family)` it uses when
`autoSelectFamily` is off — since answering in the wrong shape is
rejected as an invalid address.
- A fail-closed peer assertion runs on the Python side before a byte is
written to the socket — redundant while pinning works, and a tripwire if
a urllib3 upgrade ever breaks it.
- `workerd` is unchanged: it already fails closed for user-supplied
URLs.

Redirect *validation* already existed in both SDKs (`safe_request` /
`ssrfSafeFetch`); what was missing was re-pinning each hop.

## Tests

The existing suites could not express this bug: they mock both the
resolver and the HTTP client, so check and use are the same mock. The
new tests use real sockets.

- `python/tests/test_url_safety_pinning.py` — two loopback servers and a
resolver that answers the first lookup with one endpoint and every later
one with another, which is what a short-TTL rebinding record does.
Asserts the rebound endpoint receives **zero** connections, and that
`Host` still carries the hostname. Both tests fail on `next` and pass
here.
- `ts/packages/core/test/utils/pinnedDispatcher.node.test.ts` — a real
server plus a hostname under `.invalid`, which RFC 2606 guarantees never
resolves. A request that arrives proves the connect used the pinned
address and never consulted DNS. The third case shows the contrast:
unpinned, the same fetch cannot resolve at all.
- `ssrfGuard.test.ts` gains assertions that each hop is pinned to that
hop's own validated address.
- `pinnedDispatcher.node.test.ts` also pins with
`setDefaultAutoSelectFamily(false)`, which is the branch Node takes for
the single-address callback.

## Notes

- Supersedes #4157, which diagnosed this correctly. Its post-response
peer check turned out not to hold: with an HTTP/1.0 or `Connection:
close` server, urllib3 detaches the socket (`conn.sock is None`) while
`r.content` still returns the full body, so the check fails open exactly
where exfiltration succeeds. That is why the assertion here runs at
connect time instead.
- The Python package now declares `urllib3>=2` directly. `url_safety`
imports it for `NameResolutionError`, which only exists from 2.0, and
the pinning adapter reaches into 2.x connection internals; `requests`
alone allows 1.x, where `import composio` would have failed outright.
- `@composio/core` gains an `undici` dependency, pinned to `^7`: undici
8 dispatchers are rejected by the `fetch` in every Node version this
package supports (22/24/25, verified). The real-socket test runs on the
full CI matrix, so a future incompatibility fails loudly instead of
silently un-pinning.
- `undici` is imported on first pinned request rather than at module
load: importing it installs a process-wide global dispatcher, which
would have handed the host application's own unrelated `fetch` calls
this package's undici merely because it imported `@composio/core`.
- Residuals, now documented in the modules:
- Requests routed through an environment proxy keep the pre-flight check
only. The proxy resolves the hostname itself and the SDK cannot see or
pin that resolution.
- A process that does perform a pinned fetch still ends up on this
package's `Agent` if nothing had claimed the global dispatcher slot yet.
undici defines that slot non-configurable, so it cannot be handed back —
assigning `undefined` leaves the runtime's own `fetch` asserting on a
missing dispatcher.
2026-08-20 13:34:37 +02:00

216 lines
7.7 KiB
Python

"""Regression tests for file upload and URL fetch robustness.
Covers https://github.com/ComposioHQ/composio/issues/4153: a malformed
``Content-Length`` response header must not crash the URL fetch helpers with
a raw ``ValueError``, and the local-file presigned PUT must send the
``Content-Type`` it was signed with. The shared helpers under test keep the
fetch and upload paths from drifting apart again.
"""
import typing as t
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
from composio.core.models._files import (
FileUploadable,
_fetch_file_from_url,
upload,
)
from composio.core.models.base import allow_tracking
from composio.exceptions import ErrorUploadingFile, ResponseTooLargeError
from composio.utils import mimetypes
from composio.utils.url_safety import parse_content_length
@pytest.fixture(autouse=True)
def disable_telemetry():
"""Disable telemetry for all tests to prevent thread issues."""
token = allow_tracking.set(False)
yield
allow_tracking.reset(token)
def _stream_response(
headers: t.Dict[str, str],
chunks: t.Optional[t.List[bytes]] = None,
) -> MagicMock:
"""A streaming `requests` response double, as `_fetch_file_from_url` reads it."""
response = MagicMock()
response.ok = True
response.status_code = 200
response.headers = headers
response.iter_content.return_value = chunks if chunks is not None else [b"payload"]
response.close = MagicMock()
return response
def _s3_client(presigned_url: str = "https://s3.example.com/upload") -> MagicMock:
"""A mock HTTP client whose presign POST answers a fresh upload URL."""
client = MagicMock()
s3meta = MagicMock()
s3meta.key = "s3-key-123"
s3meta.new_presigned_url = presigned_url
client.post.return_value = s3meta
return client
class TestPresignedUploadContentType:
"""The PUT must send the content type the presigned URL was signed with."""
@patch("composio.core.models._files.safe_request")
def test_upload_sends_explicit_mimetype(
self, mock_safe_request: MagicMock, tmp_path: Path
):
mock_safe_request.return_value = MagicMock(status_code=200)
source = tmp_path / "report.pdf"
source.write_bytes(b"%PDF-1.4")
assert upload(
url="https://s3.example.com/upload",
file=source,
mimetype="application/pdf",
)
assert mock_safe_request.call_args.args == (
"PUT",
"https://s3.example.com/upload",
)
assert mock_safe_request.call_args.kwargs["headers"] == {
"Content-Type": "application/pdf"
}
assert mock_safe_request.call_args.kwargs["timeout"] == (5, 60)
@patch("composio.core.models._files.safe_request")
def test_upload_guesses_mimetype_when_omitted(
self, mock_safe_request: MagicMock, tmp_path: Path
):
"""Back-compat: two-argument callers still send a Content-Type."""
mock_safe_request.return_value = MagicMock(status_code=200)
source = tmp_path / "notes.txt"
source.write_text("hello")
assert upload(url="https://s3.example.com/upload", file=source)
assert mock_safe_request.call_args.kwargs["headers"] == {
"Content-Type": mimetypes.guess(file=source)
}
@patch("composio.core.models._files.safe_request")
def test_from_path_put_matches_presigned_mimetype(
self, mock_safe_request: MagicMock, tmp_path: Path
):
"""The PUT content type must match the mimetype used to mint the URL.
S3 answers ``403 SignatureDoesNotMatch`` when a presigned URL is
signed over a content type the subsequent PUT does not send, which
made the local-file path fail where the bytes path succeeded.
"""
mock_safe_request.return_value = MagicMock(status_code=200)
client = _s3_client()
source = tmp_path / "photo.jpg"
source.write_bytes(b"jpeg bytes")
result = FileUploadable.from_path(
client=client,
file=source,
tool="TEST_TOOL",
toolkit="test_toolkit",
)
presigned_mimetype = client.post.call_args.kwargs["body"]["mimetype"]
assert presigned_mimetype == mimetypes.guess(file=source)
assert mock_safe_request.call_args.kwargs["headers"] == {
"Content-Type": presigned_mimetype
}
assert result.mimetype == presigned_mimetype
assert result.s3key == "s3-key-123"
@patch("composio.core.models._files.safe_request")
def test_upload_surfaces_http_status(
self, mock_safe_request: MagicMock, tmp_path: Path
):
"""A rejected PUT raises with the status instead of returning False."""
mock_safe_request.return_value = MagicMock(status_code=403)
source = tmp_path / "report.pdf"
source.write_bytes(b"%PDF-1.4")
with pytest.raises(ErrorUploadingFile, match="403"):
upload(url="https://s3.example.com/upload", file=source)
@patch("composio.core.models._files.safe_request")
def test_upload_wraps_transport_errors_without_leaking_the_url(
self, mock_safe_request: MagicMock, tmp_path: Path
):
mock_safe_request.side_effect = requests.exceptions.Timeout(
"HTTPSConnectionPool(host='s3.example.com', port=443): "
"Max retries exceeded with url: /upload?token=abc"
)
source = tmp_path / "report.pdf"
source.write_bytes(b"%PDF-1.4")
with pytest.raises(ErrorUploadingFile) as exc_info:
upload(url="https://s3.example.com/upload?token=abc", file=source)
assert "Failed to upload to S3" in str(exc_info.value)
assert "token=abc" not in str(exc_info.value)
class TestParseContentLength:
"""``Content-Length`` is remote-controlled input and must not crash a fetch."""
@pytest.mark.parametrize(
"value, expected",
[
("0", 0),
("1024", 1024),
(" 2048 ", 2048),
],
)
def test_accepts_valid_sizes(self, value: str, expected: int):
assert parse_content_length(value) == expected
@pytest.mark.parametrize(
"value",
[None, "", " ", "abc", "12.5", "1,024", "1e3", "0x10", "100 200", "-1"],
)
def test_untrustworthy_values_mean_unknown_size(self, value: t.Optional[str]):
assert parse_content_length(value) is None
class TestMalformedContentLength:
"""A malformed header degrades to unknown size under the streaming cap."""
@pytest.mark.parametrize(
"header", ["abc", "12.5", "1,024", "1e3", "0x10", "100 200", ""]
)
@patch("composio.core.models._files.safe_get")
def test_malformed_header_does_not_raise(self, mock_get: MagicMock, header: str):
"""A malformed header must not surface a raw ValueError to the caller."""
mock_get.return_value = _stream_response(
{"content-type": "image/jpeg", "Content-Length": header}
)
filename, content, mimetype = _fetch_file_from_url(
"https://example.com/image.jpg"
)
assert filename == "image.jpg"
assert content == b"payload"
assert mimetype == "image/jpeg"
@patch("composio.core.models._files.safe_get")
def test_negative_header_still_enforced_while_streaming(self, mock_get: MagicMock):
"""A negative header means unknown size, not a trusted "small" value."""
mock_get.return_value = _stream_response(
{"Content-Length": "-1"},
[b"x" * 1024 * 1024 for _ in range(20)],
)
with pytest.raises(ResponseTooLargeError):
_fetch_file_from_url(
"https://example.com/large.zip", max_size=10 * 1024 * 1024
)