feat(connectors): add url source connector#751
Conversation
A `url` source connector: ingest a literal list of {url, filename}. Replaces a
bespoke indexer+downloader pair with a normal Indexer/Downloader. UrlIndexer
yields FileData per {url, filename}; UrlDownloader does an SSRF-safe fetch with
the private-IP guard as connector config (allow_private_ips) and no TOCTOU
(resolve once, validate all records, pin the socket to the validated IP,
revalidate redirect hops).
Not yet registered in the source registry / utic_types enum / plugin manifests
(follow-ups). scripts/url_connector_spike.py proves it end-to-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Port the SSRF-safe fetch from stdlib http.client to an httpx pinned transport (matches repo convention; httpx is a lazily-imported `url` extra). Resolves and validates ALL records once, then pins the httpx transport's TCP connect to the validated IP while httpcore keeps TLS SNI/cert on the real hostname -> no TOCTOU. Redirects disabled + followed manually so each hop is revalidated. - Register `url` in the source registry (URL_CONNECTOR_TYPE) and add the `url` extra. - Unit tests: indexer, download happy path, redirect-follow, validator (public/private/metadata/multi-record), redirect-target revalidation, and a guard test asserting the httpx pinning seam actually connects to the pinned IP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the platform-plugins SSRF fix (cubic P1): `not addr.is_global` is a stricter allowlist than the private/loopback/... denylist and also rejects non-globally-routable ranges like CGNAT 100.64.0.0/10. Adds a 100.64.0.1 test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 5 files
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
- P1 (path traversal): the download path derives from the caller-supplied filename, so `../` could escape the download dir. Reduce to a safe basename in the indexer (`_safe_filename`) and reject pure-traversal names. - P2 (collisions): reject duplicate basenames in precheck (would overwrite). - P2 (coverage): add a full-pipeline test that the default guard (allow_private_ips=False) blocks a private target through UrlDownloader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed cubic:
|
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 5 unresolved issues from previous reviews.
Re-trigger cubic
Merge main (branch was 8 behind), bump version above main's 1.6.28, add CHANGELOG entry for the url source connector, and re-lock so the url extra (httpx) is in uv.lock — the stale lock was the lint --locked failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Stream the validated response to disk (_ssrf_safe_get -> _ssrf_safe_download) instead of buffering the whole body in resp.content, so a large download does not spike worker memory. Per-redirect SSRF revalidation is unchanged. - Fix the module docstring that still claimed the connector was NOT REGISTERED; it is wired via add_source_entry in processes/connectors/__init__.py. Path-traversal and duplicate-filename rejection (other cubic findings) were already handled in _safe_filename / precheck; the default-config SSRF download path is covered by test_download_blocks_private_by_default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The SSRF design is right and I verified it: the socket pins to the validated IP with no connect-time re-resolution (no TOCTOU), every resolved record is validated, each redirect hop is revalidated, and Host/SNI stay on the real hostname. Blocking:
Worth addressing:
|
There was a problem hiding this comment.
1 issue found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="unstructured_ingest/processes/connectors/url.py">
<violation number="1" location="unstructured_ingest/processes/connectors/url.py:231">
P2: Streaming to disk mid-download can leave a partial file on failure. Previously, `_ssrf_safe_get` buffered the full response in memory and wrote the file only after a successful download, so an error never produced a partial file on disk. Now that the file is opened and written incrementally inside `_ssrf_safe_download`, a mid-stream failure (connection drop, timeout, server error after partial body) will leave a truncated file at `download_path`. On retry, the file is truncated and overwritten (safe). But on a non-retried failure, the partial file persists and could be picked up by a subsequent pipeline pass as if it were a complete download. Consider writing to a temporary path and renaming on success, or cleaning up the partial file on failure.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
|
||
| download_path = self.get_download_path(file_data=file_data) | ||
| download_path.parent.mkdir(parents=True, exist_ok=True) | ||
| _ssrf_safe_download( |
There was a problem hiding this comment.
P2: Streaming to disk mid-download can leave a partial file on failure. Previously, _ssrf_safe_get buffered the full response in memory and wrote the file only after a successful download, so an error never produced a partial file on disk. Now that the file is opened and written incrementally inside _ssrf_safe_download, a mid-stream failure (connection drop, timeout, server error after partial body) will leave a truncated file at download_path. On retry, the file is truncated and overwritten (safe). But on a non-retried failure, the partial file persists and could be picked up by a subsequent pipeline pass as if it were a complete download. Consider writing to a temporary path and renaming on success, or cleaning up the partial file on failure.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured_ingest/processes/connectors/url.py, line 231:
<comment>Streaming to disk mid-download can leave a partial file on failure. Previously, `_ssrf_safe_get` buffered the full response in memory and wrote the file only after a successful download, so an error never produced a partial file on disk. Now that the file is opened and written incrementally inside `_ssrf_safe_download`, a mid-stream failure (connection drop, timeout, server error after partial body) will leave a truncated file at `download_path`. On retry, the file is truncated and overwritten (safe). But on a non-retried failure, the partial file persists and could be picked up by a subsequent pipeline pass as if it were a complete download. Consider writing to a temporary path and renaming on success, or cleaning up the partial file on failure.</comment>
<file context>
@@ -217,17 +226,15 @@ def run(self, file_data: FileData, **kwargs: Any) -> DownloadResponse:
- data = _ssrf_safe_get(
+ download_path = self.get_download_path(file_data=file_data)
+ download_path.parent.mkdir(parents=True, exist_ok=True)
+ _ssrf_safe_download(
url,
+ download_path,
</file context>
The url connector's UrlIndexerConfig.files is list[FileReference]; building the CLI (unstructured-ingest --help) iterates every registered connector's config fields and had no mapping for a list of pydantic models, raising 'Unexpected field type' and breaking --help (test_ingest_help / test_install_cli). Map list[<BaseModel>] to Dict(), which json-loads an inline JSON array (and rejects a non-JSON value cleanly) so pydantic can validate it into the model list. Additive: this case previously raised, so no existing field mapping changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 4 unresolved issues from previous reviews.
Re-trigger cubic
It was a throwaway spike; the connector is covered by test/unit/connectors/test_url.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Dropped |
…rd (review) Addresses Andrew's #6: is_global alone allows IPv6 transition addresses that embed a private/metadata IPv4 (NAT64 64:ff9b::/96, 6to4 2002::/16, IPv4-mapped/-compatible) and is CGNAT-version-dependent. _is_public_address now: for IPv4 rejects CGNAT explicitly (version-independent); for IPv6 requires is_global AND that any embedded IPv4 is public. Tests cover NAT64/6to4/mapped/compat + a public IPv6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the SSRF address-validation gaps (1fd4891):
On the shared-util suggestion: I mirrored the same fix into platform-plugins#1668 rather than factor a cross-repo util, because that downloader is being retired by the playground collapse (its index/downloader are replaced by this |
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="unstructured_ingest/processes/connectors/url.py">
<violation number="1" location="unstructured_ingest/processes/connectors/url.py:134">
P1: SSRF protection can still miss NAT64/DNS64 addresses that use an operator Network-Specific Prefix: `_NAT64_PREFIX` only covers the Well-Known Prefix `64:ff9b::/96`, so a global NSP embedding `169.254.169.254` can pass as public IPv6. Consider decoding configured RFC6052 NAT64 prefixes or failing closed for known translation prefixes before treating these IPv6 addresses as public.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| # each hop is revalidated. | ||
|
|
||
|
|
||
| _NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96") |
There was a problem hiding this comment.
P1: SSRF protection can still miss NAT64/DNS64 addresses that use an operator Network-Specific Prefix: _NAT64_PREFIX only covers the Well-Known Prefix 64:ff9b::/96, so a global NSP embedding 169.254.169.254 can pass as public IPv6. Consider decoding configured RFC6052 NAT64 prefixes or failing closed for known translation prefixes before treating these IPv6 addresses as public.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured_ingest/processes/connectors/url.py, line 134:
<comment>SSRF protection can still miss NAT64/DNS64 addresses that use an operator Network-Specific Prefix: `_NAT64_PREFIX` only covers the Well-Known Prefix `64:ff9b::/96`, so a global NSP embedding `169.254.169.254` can pass as public IPv6. Consider decoding configured RFC6052 NAT64 prefixes or failing closed for known translation prefixes before treating these IPv6 addresses as public.</comment>
<file context>
@@ -131,6 +131,42 @@ class UrlDownloaderConfig(DownloaderConfig):
# each hop is revalidated.
+_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
+_CGNAT = ipaddress.ip_network("100.64.0.0/10")
+
</file context>
|
The IPv6 transition hardening looks right. One low nit: |
Part of making "playground" (the Test / workbench flow) run on the mainstream processing path instead of its own bespoke code.
Today playground ingests uploaded/sample files through a hidden, one-off plugin (
plugin-utic-playground) with its own indexer + downloader. This adds a realurlsource connector so ingest can instead be a normal connector — "fetch this list of {url, filename}" — the same way every other source works.What it does
UrlIndexer: yieldsFileDatafor a configured list of{url, filename}.UrlDownloader: SSRF-safe fetch — validates every resolved address (is_globalallowlist), pins the httpx transport to the validated IP, revalidates each redirect hop (no DNS-rebind TOCTOU). Private-IP guard is connector config (allow_private_ips), not an env var.urlextra. 14 unit tests.Once released, playground's uploaded-file ingest becomes a plain
urlsource — no special plugin.Linkages
pk/url-source-connector-typedeclares this as aSourceConnectorType(needed for it to be usable in workflows).pk/playground-notifyhandles the uploader side of the same goal.urlsource (separate PR, once this is released and plugin manifests reference it).🤖 Generated with Claude Code