mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
d544006a25
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.
216 lines
7.7 KiB
Python
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
|
|
)
|