Skip to content

Commit 87e3437

Browse files
authored
fix(robots): disallow crawling when robots.txt returns a 5xx (#2065)
`RobotsTxtFile.load` only special-cased 4xx responses (file unavailable -> allow all) and parsed the raw response body as robots rules for every other status. The HTTP clients do not raise on 5xx, so a server-error response reached that branch and its body was handed to Protego. Protego reads a non-robots body (for example an HTML `500` error page, or an empty body) as allow-all, so a transient 5xx on `robots.txt` silently permitted crawling every URL, the opposite of what the site expects while it is failing. Per RFC 9309 section 2.3.1.4, a `robots.txt` that is unreachable due to server errors (5xx) is undefined and a crawler "MUST assume complete disallow". This PR handles 5xx explicitly with a disallow-all directive, using the existing `is_status_code_server_error` helper. The 4xx (allow-all, section 2.3.1.3), 2xx/3xx (parse the body), and network-error paths are left unchanged. This only affects users who opt in with `respect_robots_txt_file=True`, and only when `robots.txt` returns a 5xx: previously such a response allowed crawling everything, now it correctly disallows until the file is reachable again. It also matches crawlee-JS, which never parses a non-2xx `robots.txt` body as rules.
1 parent 272bb28 commit 87e3437

2 files changed

Lines changed: 20 additions & 6 deletions

File tree

src/crawlee/_utils/robots.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from crawlee._utils.sitemap import Sitemap
1010
from crawlee._utils.urls import filter_url
11-
from crawlee._utils.web import is_status_code_client_error
11+
from crawlee._utils.web import is_status_code_client_error, is_status_code_server_error
1212

1313
if TYPE_CHECKING:
1414
from typing_extensions import Self
@@ -57,11 +57,15 @@ async def load(cls, url: str, http_client: HttpClient, proxy_info: ProxyInfo | N
5757
try:
5858
response = await http_client.send_request(url, proxy_info=proxy_info)
5959

60-
body = (
61-
b'User-agent: *\nAllow: /'
62-
if is_status_code_client_error(response.status_code)
63-
else await response.read()
64-
)
60+
status_code = response.status_code
61+
if is_status_code_client_error(status_code):
62+
# A 4xx response means the file is unavailable, so crawling is unrestricted (RFC 9309, section 2.3.1.3).
63+
body = b'User-agent: *\nAllow: /'
64+
elif is_status_code_server_error(status_code):
65+
# A 5xx response means the file is unreachable, so assume complete disallow (RFC 9309, section 2.3.1.4).
66+
body = b'User-agent: *\nDisallow: /'
67+
else:
68+
body = await response.read()
6569
robots = Protego.parse(body.decode('utf-8'))
6670

6771
except Exception as e:

tests/unit/_utils/test_robots.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ async def test_allow_disallow_robots_txt(server_url: URL, http_client: HttpClien
2525
assert not robots.is_allowed(str(server_url / 'deny_all/page.html'))
2626

2727

28+
async def test_server_error_disallows_all(server_url: URL, http_client: HttpClient) -> None:
29+
"""A 5xx robots.txt response is treated as complete disallow (RFC 9309, section 2.3.1.4).
30+
31+
The response body must not be parsed as rules; otherwise an error page would be misread as allow-all.
32+
"""
33+
robots = await RobotsTxtFile.load(str(server_url / 'status/500'), http_client)
34+
assert not robots.is_allowed(str(server_url / 'admin/page.html'))
35+
assert not robots.is_allowed(str(server_url / 'something/page.html'))
36+
37+
2838
async def test_extract_sitemaps_urls(server_url: URL, http_client: HttpClient) -> None:
2939
"""Cross-host sitemap entries are dropped under the `'same-hostname'` enqueue strategy."""
3040
robots = await RobotsTxtFile.find(str(server_url), http_client)

0 commit comments

Comments
 (0)