veilrender 0.6.2__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {veilrender-0.6.2 → veilrender-0.6.3}/PKG-INFO +1 -1
  2. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/__init__.py +1 -1
  3. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/browser.py +9 -6
  4. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/filters.py +63 -4
  5. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/render.py +21 -0
  6. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/screenshot.py +22 -0
  7. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/url_validator.py +55 -15
  8. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/PKG-INFO +1 -1
  9. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/SOURCES.txt +3 -1
  10. veilrender-0.6.3/tests/test_filters.py +110 -0
  11. veilrender-0.6.3/tests/test_url_validator.py +91 -0
  12. {veilrender-0.6.2 → veilrender-0.6.3}/README.md +0 -0
  13. {veilrender-0.6.2 → veilrender-0.6.3}/pyproject.toml +0 -0
  14. {veilrender-0.6.2 → veilrender-0.6.3}/setup.cfg +0 -0
  15. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/__main__.py +0 -0
  16. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/__init__.py +0 -0
  17. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/cache.py +0 -0
  18. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/httpserver.py +0 -0
  19. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/ratelimit.py +0 -0
  20. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/readability.py +0 -0
  21. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/s3.py +0 -0
  22. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/soup.py +0 -0
  23. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/app.py +0 -0
  24. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/auth.py +0 -0
  25. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/cdp_proxy.py +0 -0
  26. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/config.py +0 -0
  27. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/data/__init__.py +0 -0
  28. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/data/blocklist.txt +0 -0
  29. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/fonts.py +0 -0
  30. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/models.py +0 -0
  31. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/ratelimit.py +0 -0
  32. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/__init__.py +0 -0
  33. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/dashboard.py +0 -0
  34. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/fonts_route.py +0 -0
  35. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/health.py +0 -0
  36. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/metrics.py +0 -0
  37. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/stats.py +0 -0
  38. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/storage.py +0 -0
  39. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/dependency_links.txt +0 -0
  40. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/entry_points.txt +0 -0
  41. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/requires.txt +0 -0
  42. {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/top_level.txt +0 -0
  43. {veilrender-0.6.2 → veilrender-0.6.3}/tests/test_fonts.py +0 -0
  44. {veilrender-0.6.2 → veilrender-0.6.3}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veilrender
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
5
5
  Author: Peng Ding
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """VeilRender — headless browser rendering API."""
2
2
 
3
- __version__ = "0.6.2"
3
+ __version__ = "0.6.3"
@@ -789,12 +789,15 @@ class BrowserManager:
789
789
  self._health_task: asyncio.Task | None = None # type: ignore[type-arg]
790
790
  self._queue_depth: int = 0
791
791
 
792
- if settings.resource_filter:
793
- self._blocklist = load_blocklist(settings.blocked_domains_extra)
794
- self._route_handler = make_route_handler(self._blocklist)
795
- else:
796
- self._blocklist = frozenset()
797
- self._route_handler = None
792
+ self._blocklist = (
793
+ load_blocklist(settings.blocked_domains_extra)
794
+ if settings.resource_filter
795
+ else frozenset()
796
+ )
797
+ # The route handler enforces both ad/tracker filtering and the SSRF
798
+ # destination guard, so it is always installed: an empty blocklist
799
+ # disables filtering only, never the security check.
800
+ self._route_handler = make_route_handler(self._blocklist)
798
801
 
799
802
  async def start(self) -> None:
800
803
  for w in self._workers:
@@ -9,12 +9,15 @@ https://github.com/StevenBlack/hosts
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import asyncio
12
13
  import importlib.resources
13
14
  import logging
14
15
  from urllib.parse import urlparse
15
16
 
16
17
  from patchright.async_api import Route
17
18
 
19
+ from veilrender.url_validator import is_global_host
20
+
18
21
  logger = logging.getLogger(__name__)
19
22
 
20
23
 
@@ -81,7 +84,19 @@ def should_block(url: str, blocklist: frozenset[str]) -> bool:
81
84
 
82
85
 
83
86
  def make_route_handler(blocklist: frozenset[str]):
84
- """Create a Playwright route handler that blocks requests to listed domains.
87
+ """Create a Playwright route handler enforcing the outbound request policy.
88
+
89
+ Two checks run on every request the browser issues, including requests
90
+ produced by a redirect or by a subresource fetch:
91
+
92
+ 1. The blocklist (ad/tracker domains) is matched by domain suffix.
93
+ 2. The destination host must resolve only to globally routable addresses,
94
+ so a redirect cannot bounce the navigation into loopback, RFC1918, the
95
+ cloud metadata endpoint, or any other non-public range.
96
+
97
+ Checking the destination rather than the URL the caller submitted is what
98
+ closes the redirect-based SSRF: the submitted URL passes validation, but
99
+ the hop it redirects to does not.
85
100
 
86
101
  Args:
87
102
  blocklist: Set of blocked domain names.
@@ -90,12 +105,56 @@ def make_route_handler(blocklist: frozenset[str]):
90
105
  An async route handler function for use with ``page.route()``.
91
106
  """
92
107
 
108
+ # Hostnames already found to be non-public stay blocked for the life of the
109
+ # handler. Only refusals are cached: caching an allow would let a host that
110
+ # later resolves to a private address slip through.
111
+ blocked_hosts: set[str] = set()
112
+
93
113
  async def _handle_route(route: Route) -> None:
94
114
  url = route.request.url
115
+
95
116
  if should_block(url, blocklist):
96
- logger.info("Blocked: %s", url[:200])
117
+ logger.info("Blocked (blocklist): %s", url[:200])
118
+ await route.abort("blockedbyclient")
119
+ return
120
+
121
+ if not await _allows_public_destination(url, blocked_hosts):
122
+ logger.warning("Blocked (non-public destination): %s", url[:200])
97
123
  await route.abort("blockedbyclient")
98
- else:
99
- await route.continue_()
124
+ return
125
+
126
+ await route.continue_()
100
127
 
101
128
  return _handle_route
129
+
130
+
131
+ async def _allows_public_destination(url: str, blocked_hosts: set[str]) -> bool:
132
+ """Check that a request URL points at a globally routable host.
133
+
134
+ Non-network schemes (``data:``, ``blob:``, ``about:``) carry inline
135
+ content rather than triggering a fetch, so they are allowed through. DNS
136
+ resolution blocks, so it runs in a worker thread to avoid stalling the
137
+ event loop.
138
+
139
+ Args:
140
+ url: The request URL the browser is about to issue.
141
+ blocked_hosts: Cache of hostnames already known to be non-public.
142
+ Refusals are recorded here.
143
+
144
+ Returns:
145
+ True if the request may proceed.
146
+ """
147
+ parsed = urlparse(url)
148
+ if parsed.scheme not in ("http", "https"):
149
+ return True
150
+
151
+ hostname = (parsed.hostname or "").lower()
152
+ if not hostname:
153
+ return False
154
+ if hostname in blocked_hosts:
155
+ return False
156
+
157
+ allowed = await asyncio.to_thread(is_global_host, hostname)
158
+ if not allowed:
159
+ blocked_hosts.add(hostname)
160
+ return allowed
@@ -194,6 +194,13 @@ def register(app: App) -> None:
194
194
  {"error": "URL triggers a file download, not a renderable page"},
195
195
  status_code=400,
196
196
  )
197
+ if "blockedbyclient" in exc_str or "ERR_BLOCKED_BY_CLIENT" in exc_str:
198
+ stats.render.record_failure(elapsed)
199
+ logger.warning("Navigation blocked by outbound policy for %s", req.url)
200
+ return JSONResponse(
201
+ {"error": "Destination blocked by outbound request policy"},
202
+ status_code=400,
203
+ )
197
204
  stats.render.record_failure(elapsed)
198
205
  logger.error("Render failed for %s: %s", req.url, exc)
199
206
  return JSONResponse(
@@ -201,6 +208,20 @@ def register(app: App) -> None:
201
208
  status_code=502,
202
209
  )
203
210
 
211
+ # Re-check where the navigation actually ended up. The submitted URL is
212
+ # validated up front, but a redirect can land somewhere else entirely,
213
+ # and the body returned below comes from wherever it landed.
214
+ try:
215
+ validate_url(final_url)
216
+ except URLValidationError as exc:
217
+ elapsed = (time.monotonic() - t0) * 1000
218
+ stats.render.record_failure(elapsed)
219
+ logger.warning("Rejected redirect destination %s: %s", final_url, exc)
220
+ return JSONResponse(
221
+ {"error": f"Redirect destination rejected: {exc!s}"},
222
+ status_code=400,
223
+ )
224
+
204
225
  # Build content based on requested formats
205
226
  content = RenderContent()
206
227
 
@@ -102,6 +102,10 @@ def register(app: App) -> None:
102
102
  wait_until=effective_wu,
103
103
  timeout=goto_timeout,
104
104
  )
105
+ # A redirect can land somewhere other than the submitted URL,
106
+ # so re-check where navigation actually ended up before
107
+ # capturing anything from it.
108
+ validate_url(page.url)
105
109
  # Font CSS is cached locally as inline content — single
106
110
  # add_style_tag(content=...) call, no external network.
107
111
  # Skip on full_page where content > typography.
@@ -209,8 +213,26 @@ def register(app: App) -> None:
209
213
  status_code=504,
210
214
  content_type="application/json",
211
215
  )
216
+ except URLValidationError as exc:
217
+ elapsed = (time.monotonic() - t0) * 1000
218
+ stats.screenshot.record_failure(elapsed)
219
+ logger.warning("Rejected redirect destination for %s: %s", req.url, exc)
220
+ return Response(
221
+ body=f'{{"error": "Redirect destination rejected: {exc!s}"}}'.encode(),
222
+ status_code=400,
223
+ content_type="application/json",
224
+ )
212
225
  except Exception as exc:
213
226
  elapsed = (time.monotonic() - t0) * 1000
227
+ exc_str = str(exc)
228
+ if "blockedbyclient" in exc_str or "ERR_BLOCKED_BY_CLIENT" in exc_str:
229
+ stats.screenshot.record_failure(elapsed)
230
+ logger.warning("Navigation blocked by outbound policy for %s", req.url)
231
+ return Response(
232
+ body=b'{"error": "Destination blocked by outbound request policy"}',
233
+ status_code=400,
234
+ content_type="application/json",
235
+ )
214
236
  stats.screenshot.record_failure(elapsed)
215
237
  logger.error("Screenshot failed for %s: %s", req.url, exc)
216
238
  return Response(
@@ -2,8 +2,9 @@
2
2
 
3
3
  Blocks:
4
4
  - Non-HTTP(S) schemes: file://, data:, javascript:, ftp://, etc.
5
- - Private/reserved IP ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
6
- 192.168.0.0/16, 169.254.0.0/16, ::1, fd00::/8, etc.
5
+ - Every address outside the globally routable space, via an ``is_global``
6
+ allowlist: loopback, RFC1918, link-local (incl. the 169.254.169.254 cloud
7
+ metadata address), CGNAT 100.64.0.0/10, unique-local, multicast, reserved.
7
8
  - DNS resolution is checked before navigation to prevent DNS rebinding.
8
9
  """
9
10
 
@@ -145,20 +146,59 @@ def _check_resolved_ips(hostname: str) -> None:
145
146
 
146
147
 
147
148
  def _is_blocked_ip(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
148
- """Check if an IP address is in a blocked range.
149
+ """Check whether an IP address is outside the globally routable space.
150
+
151
+ This is an allowlist (``is_global``) rather than an enumerated denylist,
152
+ so ranges that are neither loopback nor ``is_private`` are rejected too:
149
153
 
150
- Blocks:
151
154
  - Loopback (127.0.0.0/8, ::1)
152
155
  - Private networks (10/8, 172.16/12, 192.168/16)
153
- - Link-local (169.254/16, fe80::/10)
154
- - Unique local (fd00::/8)
155
- - Multicast, reserved, unspecified
156
+ - Link-local, including cloud metadata (169.254/16, fe80::/10)
157
+ - CGNAT / shared address space (100.64.0.0/10)
158
+ - Unique local (fc00::/7), multicast, reserved, unspecified,
159
+ benchmarking (198.18.0.0/15) and other special-purpose ranges
160
+ """
161
+ return not addr.is_global
162
+
163
+
164
+ def is_global_host(hostname: str) -> bool:
165
+ """Check whether a hostname resolves only to globally routable addresses.
166
+
167
+ Callers use this to re-check each request the browser issues, including
168
+ requests produced by a redirect, so that a public URL cannot bounce a
169
+ navigation into loopback, an RFC1918 address, or the cloud metadata
170
+ endpoint.
171
+
172
+ Args:
173
+ hostname: Hostname or IP literal taken from a request URL.
174
+
175
+ Returns:
176
+ True only if the host is an IP literal or resolves exclusively to
177
+ public addresses. Returns False when resolution fails, so unresolvable
178
+ hosts are refused rather than attempted.
156
179
  """
157
- return (
158
- addr.is_loopback
159
- or addr.is_private
160
- or addr.is_reserved
161
- or addr.is_multicast
162
- or addr.is_link_local
163
- or addr.is_unspecified
164
- )
180
+ host = (hostname or "").strip()
181
+ if host.startswith("[") and host.endswith("]"):
182
+ host = host[1:-1]
183
+ if not host:
184
+ return False
185
+
186
+ try:
187
+ return not _is_blocked_ip(ipaddress.ip_address(host))
188
+ except ValueError:
189
+ pass # Not an IP literal, resolve it below
190
+
191
+ try:
192
+ results = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
193
+ except (socket.gaierror, UnicodeError):
194
+ return False
195
+
196
+ for _family, _type, _proto, _canonname, sockaddr in results:
197
+ try:
198
+ addr = ipaddress.ip_address(sockaddr[0])
199
+ except ValueError:
200
+ continue
201
+ if _is_blocked_ip(addr):
202
+ return False
203
+
204
+ return bool(results)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veilrender
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
5
5
  Author: Peng Ding
6
6
  License-Expression: MIT
@@ -36,5 +36,7 @@ src/veilrender/routes/health.py
36
36
  src/veilrender/routes/metrics.py
37
37
  src/veilrender/routes/render.py
38
38
  src/veilrender/routes/screenshot.py
39
+ tests/test_filters.py
39
40
  tests/test_fonts.py
40
- tests/test_models.py
41
+ tests/test_models.py
42
+ tests/test_url_validator.py
@@ -0,0 +1,110 @@
1
+ """Tests for the outbound request policy enforced on every browser request.
2
+
3
+ Regression coverage for the redirect-based SSRF: the route handler must refuse
4
+ a request whose destination is not globally routable, so that a public URL
5
+ cannot redirect the navigation into loopback or a cloud metadata endpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from veilrender.filters import (
13
+ _allows_public_destination,
14
+ make_route_handler,
15
+ should_block,
16
+ )
17
+
18
+
19
+ class _FakeRequest:
20
+ def __init__(self, url: str) -> None:
21
+ self.url = url
22
+
23
+
24
+ class _FakeRoute:
25
+ """Stands in for a patchright Route, recording what the handler did."""
26
+
27
+ def __init__(self, url: str) -> None:
28
+ self.request = _FakeRequest(url)
29
+ self.aborted = False
30
+ self.continued = False
31
+ self.abort_reason: str | None = None
32
+
33
+ async def abort(self, reason: str | None = None) -> None:
34
+ self.aborted = True
35
+ self.abort_reason = reason
36
+
37
+ async def continue_(self) -> None:
38
+ self.continued = True
39
+
40
+
41
+ class TestShouldBlockStillWorks:
42
+ def test_blocks_listed_domain(self) -> None:
43
+ assert should_block("https://ads.example.com/x", frozenset({"example.com"}))
44
+
45
+ def test_allows_unlisted_domain(self) -> None:
46
+ assert not should_block("https://ok.test/x", frozenset({"example.com"}))
47
+
48
+
49
+ class TestAllowsPublicDestination:
50
+ def test_rejects_loopback(self) -> None:
51
+ assert (
52
+ asyncio.run(_allows_public_destination("http://127.0.0.1/", set())) is False
53
+ )
54
+
55
+ def test_rejects_metadata_endpoint(self) -> None:
56
+ url = "http://169.254.169.254/latest/meta-data/"
57
+ assert asyncio.run(_allows_public_destination(url, set())) is False
58
+
59
+ def test_rejects_cgnat(self) -> None:
60
+ assert (
61
+ asyncio.run(_allows_public_destination("http://100.64.0.1/", set()))
62
+ is False
63
+ )
64
+
65
+ def test_allows_public_literal(self) -> None:
66
+ assert asyncio.run(_allows_public_destination("http://1.1.1.1/", set())) is True
67
+
68
+ def test_allows_inline_scheme(self) -> None:
69
+ # data:/blob: carry content inline and trigger no fetch.
70
+ assert (
71
+ asyncio.run(_allows_public_destination("data:text/html,x", set())) is True
72
+ )
73
+
74
+ def test_rejects_missing_hostname(self) -> None:
75
+ assert asyncio.run(_allows_public_destination("http:///path", set())) is False
76
+
77
+ def test_records_refusal_in_cache(self) -> None:
78
+ blocked: set[str] = set()
79
+ asyncio.run(_allows_public_destination("http://127.0.0.1/", blocked))
80
+ assert "127.0.0.1" in blocked
81
+
82
+
83
+ class TestRouteHandlerEnforcesDestination:
84
+ def test_aborts_redirect_to_loopback(self) -> None:
85
+ handler = make_route_handler(frozenset())
86
+ route = _FakeRoute("http://127.0.0.1:7860/health")
87
+ asyncio.run(handler(route))
88
+ assert route.aborted
89
+ assert not route.continued
90
+
91
+ def test_continues_public_request(self) -> None:
92
+ handler = make_route_handler(frozenset())
93
+ route = _FakeRoute("http://1.1.1.1/")
94
+ asyncio.run(handler(route))
95
+ assert route.continued
96
+ assert not route.aborted
97
+
98
+ def test_blocklist_takes_precedence(self) -> None:
99
+ handler = make_route_handler(frozenset({"ads.example.com"}))
100
+ route = _FakeRoute("https://ads.example.com/a.js")
101
+ asyncio.run(handler(route))
102
+ assert route.aborted
103
+
104
+ def test_repeat_private_host_uses_cache(self) -> None:
105
+ handler = make_route_handler(frozenset())
106
+ first = _FakeRoute("http://127.0.0.1/a")
107
+ second = _FakeRoute("http://127.0.0.1/b")
108
+ asyncio.run(handler(first))
109
+ asyncio.run(handler(second))
110
+ assert first.aborted and second.aborted
@@ -0,0 +1,91 @@
1
+ """Tests for URL validation — SSRF guards on the submitted URL.
2
+
3
+ Regression coverage for the redirect-based SSRF: the validator must reject
4
+ every non-globally-routable destination, not just the historically enumerated
5
+ private ranges (CGNAT 100.64.0.0/10 previously slipped through because the
6
+ check used ``is_private`` instead of ``is_global``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ from veilrender.url_validator import (
14
+ URLValidationError,
15
+ is_global_host,
16
+ validate_url,
17
+ )
18
+
19
+
20
+ class TestValidateUrlRejectsNonPublic:
21
+ @pytest.mark.parametrize(
22
+ "url",
23
+ [
24
+ "http://127.0.0.1/",
25
+ "http://127.1.2.3:8080/",
26
+ "http://[::1]/",
27
+ "http://10.0.0.1/",
28
+ "http://172.16.5.4/",
29
+ "http://192.168.1.1/",
30
+ "http://169.254.169.254/latest/meta-data/",
31
+ "http://100.64.0.1/", # CGNAT — the regression
32
+ "http://100.127.255.254/", # top of the CGNAT range
33
+ ],
34
+ )
35
+ def test_rejects_non_public_literal(self, url: str) -> None:
36
+ with pytest.raises(URLValidationError):
37
+ validate_url(url)
38
+
39
+ @pytest.mark.parametrize(
40
+ "url",
41
+ [
42
+ "file:///etc/passwd",
43
+ "data:text/html,<h1>hi</h1>",
44
+ "javascript:alert(1)",
45
+ "ftp://example.com/x",
46
+ ],
47
+ )
48
+ def test_rejects_non_http_scheme(self, url: str) -> None:
49
+ with pytest.raises(URLValidationError):
50
+ validate_url(url)
51
+
52
+ def test_rejects_localhost_name(self) -> None:
53
+ # Exercises the DNS path rather than an IP literal.
54
+ with pytest.raises(URLValidationError):
55
+ validate_url("http://localhost/")
56
+
57
+
58
+ class TestValidateUrlAcceptsPublic:
59
+ @pytest.mark.parametrize("url", ["http://1.1.1.1/", "https://8.8.8.8/x"])
60
+ def test_accepts_public_literal(self, url: str) -> None:
61
+ assert validate_url(url) == url
62
+
63
+
64
+ class TestIsGlobalHost:
65
+ @pytest.mark.parametrize(
66
+ "host",
67
+ [
68
+ "127.0.0.1",
69
+ "::1",
70
+ "[::1]",
71
+ "169.254.169.254",
72
+ "10.0.0.1",
73
+ "192.168.0.1",
74
+ "100.64.0.1",
75
+ "0.0.0.0",
76
+ "",
77
+ ],
78
+ )
79
+ def test_false_for_non_public(self, host: str) -> None:
80
+ assert is_global_host(host) is False
81
+
82
+ @pytest.mark.parametrize("host", ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])
83
+ def test_true_for_public_literal(self, host: str) -> None:
84
+ assert is_global_host(host) is True
85
+
86
+ def test_false_for_localhost_name(self) -> None:
87
+ assert is_global_host("localhost") is False
88
+
89
+ def test_false_when_resolution_fails(self) -> None:
90
+ # Unresolvable hosts are refused rather than attempted.
91
+ assert is_global_host("nonexistent.invalid") is False
File without changes
File without changes
File without changes