Skip to content

Commit 96b840f

Browse files
committed
feat: Add send_request_enqueue_strategy option to BasicCrawler
1 parent ba6e555 commit 96b840f

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/crawlee/crawlers/_basic/_basic_crawler.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ class _BasicCrawlerOptions(TypedDict):
204204
"""If set to `True`, the crawler will automatically try to fetch the robots.txt file for each domain,
205205
and skip those that are not allowed. This also prevents disallowed URLs to be added via `EnqueueLinksFunction`."""
206206

207+
send_request_enqueue_strategy: NotRequired[EnqueueStrategy]
208+
"""Strategy applied by `BasicCrawlingContext.send_request` to validate the target URL against the current request's
209+
URL. Defaults to `'all'`, which preserves the historical behaviour of allowing arbitrary URLs. Set to e.g.
210+
`'same-hostname'` to harden the crawler against SSRF when handlers extract URLs from untrusted page content and
211+
pass them to `send_request` directly."""
212+
207213
status_message_logging_interval: NotRequired[timedelta]
208214
"""Interval for logging the crawler status messages."""
209215

@@ -299,6 +305,7 @@ def __init__(
299305
configure_logging: bool = True,
300306
statistics_log_format: Literal['table', 'inline'] = 'table',
301307
respect_robots_txt_file: bool = False,
308+
send_request_enqueue_strategy: EnqueueStrategy = 'all',
302309
status_message_logging_interval: timedelta = timedelta(seconds=10),
303310
status_message_callback: Callable[[StatisticsState, StatisticsState | None, str], Awaitable[str | None]]
304311
| None = None,
@@ -352,6 +359,11 @@ def __init__(
352359
respect_robots_txt_file: If set to `True`, the crawler will automatically try to fetch the robots.txt file
353360
for each domain, and skip those that are not allowed. This also prevents disallowed URLs to be added
354361
via `EnqueueLinksFunction`
362+
send_request_enqueue_strategy: Strategy applied by `BasicCrawlingContext.send_request` to validate the
363+
target URL against the current request's URL (`loaded_url` if available, otherwise `url`). Defaults
364+
to `'all'`, preserving the historical behaviour of allowing arbitrary URLs. Set to e.g.
365+
`'same-hostname'` when handlers extract URLs from page content and pass them straight to
366+
`send_request`, to keep cross-host fetches intentional.
355367
status_message_logging_interval: Interval for logging the crawler status messages.
356368
status_message_callback: Allows overriding the default status message. The default status message is
357369
provided in the parameters. Returning `None` suppresses the status message.
@@ -432,6 +444,7 @@ def __init__(
432444
self._max_session_rotations = max_session_rotations
433445
self._max_crawl_depth = max_crawl_depth
434446
self._respect_robots_txt_file = respect_robots_txt_file
447+
self._send_request_enqueue_strategy = send_request_enqueue_strategy
435448

436449
# Timeouts
437450
self._request_handler_timeout = request_handler_timeout
@@ -1275,14 +1288,28 @@ def _prepare_send_request_function(
12751288
self,
12761289
session: Session | None,
12771290
proxy_info: ProxyInfo | None,
1291+
request: Request,
12781292
) -> SendRequestFunction:
1293+
strategy = self._send_request_enqueue_strategy
1294+
origin_url = request.loaded_url or request.url
1295+
origin_parsed = urlparse(origin_url) if strategy != 'all' else None
1296+
12791297
async def send_request(
12801298
url: str,
12811299
*,
12821300
method: HttpMethod = 'GET',
12831301
payload: HttpPayload | None = None,
12841302
headers: HttpHeaders | dict[str, str] | None = None,
12851303
) -> HttpResponse:
1304+
if origin_parsed is not None and not self._check_enqueue_strategy(
1305+
strategy,
1306+
target_url=urlparse(url),
1307+
origin_url=origin_parsed,
1308+
):
1309+
raise ValueError(
1310+
f'send_request() refusing to fetch {url!r}: does not match enqueue strategy '
1311+
f'{strategy!r} relative to {origin_url!r}.'
1312+
)
12861313
return await self._http_client.send_request(
12871314
url=url,
12881315
method=method,
@@ -1428,7 +1455,7 @@ async def __run_task_function(self) -> None:
14281455
request=result.request,
14291456
session=session,
14301457
proxy_info=proxy_info,
1431-
send_request=self._prepare_send_request_function(session, proxy_info),
1458+
send_request=self._prepare_send_request_function(session, proxy_info, request),
14321459
add_requests=result.add_requests,
14331460
push_data=result.push_data,
14341461
get_key_value_store=result.get_key_value_store,

tests/unit/crawlers/_basic/test_basic_crawler.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import pytest
2121

22-
from crawlee import ConcurrencySettings, Glob, service_locator
22+
from crawlee import ConcurrencySettings, EnqueueStrategy, Glob, service_locator
2323
from crawlee._request import Request, RequestState
2424
from crawlee._types import BasicCrawlingContext, EnqueueLinksKwargs, HttpMethod
2525
from crawlee._utils.robots import RobotsTxtFile
@@ -340,6 +340,43 @@ async def failed_request_handler(context: BasicCrawlingContext, error: Exception
340340
await crawler.run(['https://a.placeholder.com', 'https://b.placeholder.com', 'https://c.placeholder.com'])
341341

342342

343+
@pytest.mark.parametrize(
344+
('strategy', 'target_path', 'should_succeed'),
345+
[
346+
pytest.param('all', 'get', True, id='default-all-allows-same-host'),
347+
pytest.param('same-hostname', 'get', True, id='same-hostname-allows-same-host'),
348+
pytest.param('same-hostname', 'http://attacker.evil/payload', False, id='same-hostname-rejects-cross-host'),
349+
],
350+
)
351+
async def test_send_request_enqueue_strategy(
352+
server_url: URL, strategy: EnqueueStrategy, target_path: str, *, should_succeed: bool
353+
) -> None:
354+
bodies: list[bytes] = []
355+
errors: list[Exception] = []
356+
357+
crawler = BasicCrawler(max_request_retries=1, send_request_enqueue_strategy=strategy)
358+
target_url = target_path if target_path.startswith('http') else str(server_url / target_path)
359+
360+
@crawler.router.default_handler
361+
async def handler(context: BasicCrawlingContext) -> None:
362+
try:
363+
response = await context.send_request(target_url)
364+
except ValueError as exc:
365+
errors.append(exc)
366+
else:
367+
bodies.append(await response.read())
368+
369+
await crawler.run([str(server_url / 'a/page')])
370+
371+
if should_succeed:
372+
assert bodies, 'expected the handler to receive a response'
373+
assert not errors
374+
else:
375+
assert errors, 'expected send_request to refuse the target URL'
376+
assert strategy in str(errors[0])
377+
assert target_url in str(errors[0])
378+
379+
343380
@pytest.mark.parametrize(
344381
('method', 'path', 'payload'),
345382
[

0 commit comments

Comments
 (0)