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.
5 issues found across 5 files
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:14">
P3: The module docstring now says the URL connector is not registered, but the registry imports `.url` and adds `url_source_entry`. Updating this note would avoid misleading future changes around connector registration.</violation>
<violation number="2" location="unstructured_ingest/processes/connectors/url.py:187">
P2: Large URL downloads can spike worker memory because `_ssrf_safe_get` loads the entire response into `resp.content` before `UrlDownloader` writes it. Consider streaming the validated response to the download path, while keeping the per-redirect SSRF checks.</violation>
</file>
<file name="scripts/url_connector_spike.py">
<violation number="1" location="scripts/url_connector_spike.py:6">
P3: The documented run command can fail from a fresh checkout before the spike starts because direct script execution puts `scripts/` on `sys.path` instead of the repo root. Documenting module execution avoids that import-path trap for repo-local runs.</violation>
<violation number="2" location="scripts/url_connector_spike.py:55">
P3: Each spike run leaves its served and downloaded files in a new temp directory because `mkdtemp()` is never removed. Consider wrapping the work tree in `tempfile.TemporaryDirectory()` or removing `work` in `finally`.</violation>
<violation number="3" location="scripts/url_connector_spike.py:95">
P2: The redirect check proves redirect following, but not per-hop SSRF revalidation: it uses the `allow_private_ips=True` downloader, so a private redirect hop would be allowed either way. Consider adding a redirect-to-private case with the default guard, or monkeypatching `_validate_and_pin` to assert both hop hosts are validated.
(Based on your team's feedback about proposing specific test cases.) [FEEDBACK_USED]</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| assert resp["file_data"].local_download_path == str(path.resolve()) | ||
| assert contents == {"a.txt": "hello alpha", "b.txt": "hello beta"}, contents | ||
|
|
||
| # redirect is followed (each hop revalidated) |
There was a problem hiding this comment.
P2: The redirect check proves redirect following, but not per-hop SSRF revalidation: it uses the allow_private_ips=True downloader, so a private redirect hop would be allowed either way. Consider adding a redirect-to-private case with the default guard, or monkeypatching _validate_and_pin to assert both hop hosts are validated.
(Based on your team's feedback about proposing specific test cases.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/url_connector_spike.py, line 95:
<comment>The redirect check proves redirect following, but not per-hop SSRF revalidation: it uses the `allow_private_ips=True` downloader, so a private redirect hop would be allowed either way. Consider adding a redirect-to-private case with the default guard, or monkeypatching `_validate_and_pin` to assert both hop hosts are validated.
(Based on your team's feedback about proposing specific test cases.) </comment>
<file context>
@@ -0,0 +1,123 @@
+ assert resp["file_data"].local_download_path == str(path.resolve())
+ assert contents == {"a.txt": "hello alpha", "b.txt": "hello beta"}, contents
+
+ # redirect is followed (each hop revalidated)
+ redirect_fd = next(iter(indexer.run()))
+ redirect_fd.metadata.url = f"{base}/redirect"
</file context>
| continue | ||
| if resp.status_code != 200: | ||
| raise RuntimeError(f"GET {current} -> {resp.status_code}") | ||
| return resp.content |
There was a problem hiding this comment.
P2: Large URL downloads can spike worker memory because _ssrf_safe_get loads the entire response into resp.content before UrlDownloader writes it. Consider streaming the validated response to the download path, while keeping the per-redirect SSRF checks.
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 187:
<comment>Large URL downloads can spike worker memory because `_ssrf_safe_get` loads the entire response into `resp.content` before `UrlDownloader` writes it. Consider streaming the validated response to the download path, while keeping the per-redirect SSRF checks.</comment>
<file context>
@@ -0,0 +1,226 @@
+ continue
+ if resp.status_code != 200:
+ raise RuntimeError(f"GET {current} -> {resp.status_code}")
+ return resp.content
+ raise IngestValueError(f"Too many redirects for url: {url}")
+
</file context>
| except Exception as e: # noqa: BLE001 | ||
| assert "refusing" in str(e).lower(), (bad, e) | ||
|
|
||
| work = Path(tempfile.mkdtemp()) |
There was a problem hiding this comment.
P3: Each spike run leaves its served and downloaded files in a new temp directory because mkdtemp() is never removed. Consider wrapping the work tree in tempfile.TemporaryDirectory() or removing work in finally.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/url_connector_spike.py, line 55:
<comment>Each spike run leaves its served and downloaded files in a new temp directory because `mkdtemp()` is never removed. Consider wrapping the work tree in `tempfile.TemporaryDirectory()` or removing `work` in `finally`.</comment>
<file context>
@@ -0,0 +1,123 @@
+ except Exception as e: # noqa: BLE001
+ assert "refusing" in str(e).lower(), (bad, e)
+
+ work = Path(tempfile.mkdtemp())
+ served = work / "served"
+ served.mkdir()
</file context>
| the SSRF guard (validator on public/private, private blocked by default, redirect | ||
| followed with per-hop revalidation). | ||
|
|
||
| Run: python scripts/url_connector_spike.py |
There was a problem hiding this comment.
P3: The documented run command can fail from a fresh checkout before the spike starts because direct script execution puts scripts/ on sys.path instead of the repo root. Documenting module execution avoids that import-path trap for repo-local runs.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/url_connector_spike.py, line 6:
<comment>The documented run command can fail from a fresh checkout before the spike starts because direct script execution puts `scripts/` on `sys.path` instead of the repo root. Documenting module execution avoids that import-path trap for repo-local runs.</comment>
<file context>
@@ -0,0 +1,123 @@
+the SSRF guard (validator on public/private, private blocked by default, redirect
+followed with per-hop revalidation).
+
+Run: python scripts/url_connector_spike.py
+"""
+
</file context>
| (`UrlDownloaderConfig.allow_private_ips`), and the TOCTOU in that original guard | ||
| is closed (see `_ssrf_safe_get`). | ||
|
|
||
| NOT YET REGISTERED. `add_source_entry("url", url_source_entry)` in |
There was a problem hiding this comment.
P3: The module docstring now says the URL connector is not registered, but the registry imports .url and adds url_source_entry. Updating this note would avoid misleading future changes around connector registration.
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 14:
<comment>The module docstring now says the URL connector is not registered, but the registry imports `.url` and adds `url_source_entry`. Updating this note would avoid misleading future changes around connector registration.</comment>
<file context>
@@ -0,0 +1,226 @@
+(`UrlDownloaderConfig.allow_private_ips`), and the TOCTOU in that original guard
+is closed (see `_ssrf_safe_get`).
+
+NOT YET REGISTERED. `add_source_entry("url", url_source_entry)` in
+`processes/connectors/__init__.py` + the utic_types enum + plugin manifests are
+follow-ups. Importing this module has no effect on the shipped registry.
</file context>
- 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
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