You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: One seed that fails the destination check rejects the whole /crawl batch #2288
The other seeds are crawled, and the refused one comes back as a failed result carrying the same opaque message, the way a robots.txt refusal already does inside a batch.
Current Behavior
_normalize_and_validate_seeds checks every seed before crawling and raises on the first one refused (api.py#L650-L657), so the whole request fails with 400 {"detail": "URL blocked (SSRF protection): URL blocked"} and no results. resolve_and_pin also raises EgressBlocked when getaddrinfo fails (egress_broker.py#L92-L96), so a host that simply doesn't resolve (a dead domain from a stale sitemap) takes the batch down the same way. The caller can't tell which seed it was, so its only recovery is to split the batch and retry.
Keeping the message opaque, with no resolution oracle, is right, and a per-URL failure keeps it: it says no more than today's 400, because a caller can already find the refused seed by sending the URLs one at a time.
If per-URL failures are the direction you want, I can send a PR. cc @ntohidi
1. Run the Docker image (0.9.4, or built from develop), without CRAWL4AI_ALLOW_INTERNAL_URLS
2. POST /crawl with the two URLs above
3. 400, no results; the first URL alone crawls fine
Thanks for the clear report. Confirmed on develop. Here is the root cause.
Root cause
Both crawl handlers check the seeds with _normalize_and_validate_seeds before crawling (api.py#L650-L657):
forurlinurls:
validate_url_destination(url)
validate_url_destination raises HTTPException(400, "URL blocked (SSRF protection): ...") on the first seed that fails (utils.py#L366). That ends the whole request, so the other seeds are never crawled.
It runs on both paths: handle_crawl_request (api.py#L687) and handle_stream_crawl_request (api.py#L903). So /crawl/stream has the same problem.
A host that does not resolve is refused the same way as an internal one, because _resolve turns socket.gaierror into EgressBlocked (egress_broker.py#L92-L96). So one dead domain in a batch is enough.
Fix direction
Split the seeds into allowed and refused instead of raising:
Crawl only the allowed seeds. Return each refused seed as a success: false result with the same URL blocked message, like a robots.txt refusal.
If no seed is left to crawl, return the same 400 as today. A single refused URL keeps today's behavior.
Do this on both the batch and the stream path.
The SSRF check stays the same: every seed is still checked before any fetch, refused seeds never reach the crawler, and the message stays the same for every refusal reason.
Activity
nightcityblade commentedon Oct 7, 2026
I can take this issue and add focused regression coverage for mixed valid and blocked seed batches. I will submit a PR shortly.
nightcityblade commentedon Oct 8, 2026
I found the newer open fix in #2347 while rechecking the issue today. I'm stepping back to avoid duplicate work.
ntohidi commentedon Oct 12, 2026
Thanks for the clear report. Confirmed on
develop. Here is the root cause.Root cause
Both crawl handlers check the seeds with
_normalize_and_validate_seedsbefore crawling (api.py#L650-L657):validate_url_destinationraisesHTTPException(400, "URL blocked (SSRF protection): ...")on the first seed that fails (utils.py#L366). That ends the whole request, so the other seeds are never crawled.handle_crawl_request(api.py#L687) andhandle_stream_crawl_request(api.py#L903). So/crawl/streamhas the same problem._resolveturnssocket.gaierrorintoEgressBlocked(egress_broker.py#L92-L96). So one dead domain in a batch is enough.Fix direction
Split the seeds into allowed and refused instead of raising:
success: falseresult with the sameURL blockedmessage, like a robots.txt refusal.The SSRF check stays the same: every seed is still checked before any fetch, refused seeds never reach the crawler, and the message stays the same for every refusal reason.
fix(docker): report a refused seed as a failed result, not a dead batch