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.
- {veilrender-0.6.2 → veilrender-0.6.3}/PKG-INFO +1 -1
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/__init__.py +1 -1
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/browser.py +9 -6
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/filters.py +63 -4
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/render.py +21 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/screenshot.py +22 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/url_validator.py +55 -15
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/PKG-INFO +1 -1
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/SOURCES.txt +3 -1
- veilrender-0.6.3/tests/test_filters.py +110 -0
- veilrender-0.6.3/tests/test_url_validator.py +91 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/README.md +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/pyproject.toml +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/setup.cfg +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/__main__.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/__init__.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/cache.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/httpserver.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/ratelimit.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/readability.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/s3.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/_vendor/soup.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/app.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/auth.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/cdp_proxy.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/config.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/data/__init__.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/data/blocklist.txt +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/fonts.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/models.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/ratelimit.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/__init__.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/dashboard.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/fonts_route.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/health.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/routes/metrics.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/stats.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender/storage.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/dependency_links.txt +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/entry_points.txt +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/requires.txt +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/src/veilrender.egg-info/top_level.txt +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/tests/test_fonts.py +0 -0
- {veilrender-0.6.2 → veilrender-0.6.3}/tests/test_models.py +0 -0
|
@@ -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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
-
|
|
6
|
-
|
|
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
|
|
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
|
-
-
|
|
155
|
-
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|