veilrender 0.3.0__tar.gz → 0.3.1__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.3.0 → veilrender-0.3.1}/PKG-INFO +1 -1
  2. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/__init__.py +1 -1
  3. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/app.py +3 -1
  4. veilrender-0.3.1/src/veilrender/auth.py +114 -0
  5. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/filters.py +1 -1
  6. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/routes/render.py +8 -0
  7. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/routes/screenshot.py +12 -0
  8. veilrender-0.3.1/src/veilrender/url_validator.py +123 -0
  9. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/PKG-INFO +1 -1
  10. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/SOURCES.txt +1 -0
  11. veilrender-0.3.0/src/veilrender/auth.py +0 -39
  12. {veilrender-0.3.0 → veilrender-0.3.1}/README.md +0 -0
  13. {veilrender-0.3.0 → veilrender-0.3.1}/pyproject.toml +0 -0
  14. {veilrender-0.3.0 → veilrender-0.3.1}/setup.cfg +0 -0
  15. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/__main__.py +0 -0
  16. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/__init__.py +0 -0
  17. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/benchmark_compare.py +0 -0
  18. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/cache.py +0 -0
  19. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/config.py +0 -0
  20. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/dotenv.py +0 -0
  21. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/httpserver.py +0 -0
  22. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/jsonc.py +0 -0
  23. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/markdown.py +0 -0
  24. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/readability.py +0 -0
  25. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/retry.py +0 -0
  26. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/soup.py +0 -0
  27. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/structlog.py +0 -0
  28. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/useragent.py +0 -0
  29. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/_vendor/yaml.py +0 -0
  30. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/browser.py +0 -0
  31. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/cdp_proxy.py +0 -0
  32. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/config.py +0 -0
  33. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/data/__init__.py +0 -0
  34. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/data/blocklist.txt +0 -0
  35. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/models.py +0 -0
  36. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/routes/__init__.py +0 -0
  37. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/routes/dashboard.py +0 -0
  38. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/routes/health.py +0 -0
  39. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/stats.py +0 -0
  40. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender/storage.py +0 -0
  41. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/dependency_links.txt +0 -0
  42. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/entry_points.txt +0 -0
  43. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/requires.txt +0 -0
  44. {veilrender-0.3.0 → veilrender-0.3.1}/src/veilrender.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veilrender
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.3.0"
3
+ __version__ = "0.3.1"
@@ -37,7 +37,9 @@ async def _pipe(source: asyncio.StreamReader, dest: asyncio.StreamReader) -> Non
37
37
 
38
38
  def create_app() -> App:
39
39
  """Create and configure the VeilRender application."""
40
- app = App(max_body_size=10 * 1024 * 1024) # 10 MB
40
+ app = App(
41
+ max_body_size=64 * 1024
42
+ ) # 64 KB — render/screenshot payloads are small JSON
41
43
 
42
44
  # Register routes
43
45
  health.register(app)
@@ -0,0 +1,114 @@
1
+ """Token-based authentication with IP-based rate limiting.
2
+
3
+ Failed auth attempts are tracked per IP. After 3 failures within a
4
+ rolling window, the IP is banned for a configurable duration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ from collections import defaultdict
12
+
13
+ from veilrender._vendor.httpserver import HTTPException, Request
14
+ from veilrender.config import settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Fail2ban settings
19
+ _MAX_FAILURES = 3
20
+ _WINDOW_SECONDS = 300 # 5 minute rolling window
21
+ _BAN_SECONDS = 600 # 10 minute ban
22
+
23
+ # State: per-IP failure timestamps and ban expiry
24
+ _failures: dict[str, list[float]] = defaultdict(list)
25
+ _banned: dict[str, float] = {}
26
+
27
+
28
+ def _get_client_ip(request: Request) -> str:
29
+ """Extract client IP, respecting X-Forwarded-For behind reverse proxy."""
30
+ forwarded = request.headers.get("x-forwarded-for", "")
31
+ if forwarded:
32
+ # Take the first (leftmost) IP — the original client
33
+ return forwarded.split(",")[0].strip()
34
+ return request.client_addr[0] if hasattr(request, "client_addr") else "unknown"
35
+
36
+
37
+ def _is_banned(ip: str) -> bool:
38
+ """Check if an IP is currently banned."""
39
+ ban_until = _banned.get(ip)
40
+ if ban_until is None:
41
+ return False
42
+ if time.monotonic() >= ban_until:
43
+ del _banned[ip]
44
+ return False
45
+ return True
46
+
47
+
48
+ def _record_failure(ip: str) -> None:
49
+ """Record an auth failure and ban if threshold exceeded."""
50
+ now = time.monotonic()
51
+
52
+ # Clean old entries outside the window
53
+ _failures[ip] = [t for t in _failures[ip] if now - t < _WINDOW_SECONDS]
54
+ _failures[ip].append(now)
55
+
56
+ if len(_failures[ip]) >= _MAX_FAILURES:
57
+ _banned[ip] = now + _BAN_SECONDS
58
+ _failures.pop(ip, None)
59
+ logger.warning(
60
+ "IP %s banned for %ds after %d auth failures",
61
+ ip,
62
+ _BAN_SECONDS,
63
+ _MAX_FAILURES,
64
+ )
65
+
66
+
67
+ def _clear_failures(ip: str) -> None:
68
+ """Clear failure history on successful auth."""
69
+ _failures.pop(ip, None)
70
+
71
+
72
+ def verify_token(request: Request) -> None:
73
+ """Verify the API token from header or query param.
74
+
75
+ Checks ``Authorization: Bearer <token>`` header first, then falls
76
+ back to ``?token=<token>`` query parameter.
77
+
78
+ Raises:
79
+ HTTPException: 403 if IP is banned, 401 if token is invalid,
80
+ 403 if token is missing.
81
+
82
+ If ``VEILRENDER_API_TOKEN`` is not configured, auth is disabled.
83
+ """
84
+ expected = settings.api_token
85
+ if expected is None:
86
+ return
87
+
88
+ ip = _get_client_ip(request)
89
+
90
+ # Check ban first
91
+ if _is_banned(ip):
92
+ raise HTTPException(403, "Temporarily banned due to repeated auth failures")
93
+
94
+ # Check Authorization header
95
+ auth_header = request.headers.get("authorization", "")
96
+ if auth_header.startswith("Bearer "):
97
+ token = auth_header[7:].strip()
98
+ if token == expected:
99
+ _clear_failures(ip)
100
+ return
101
+ _record_failure(ip)
102
+ raise HTTPException(401, "Invalid token")
103
+
104
+ # Check query param
105
+ token_params = request.query_params.get("token", [])
106
+ if token_params:
107
+ if token_params[0] == expected:
108
+ _clear_failures(ip)
109
+ return
110
+ _record_failure(ip)
111
+ raise HTTPException(401, "Invalid token")
112
+
113
+ _record_failure(ip)
114
+ raise HTTPException(403, "Authentication required")
@@ -93,7 +93,7 @@ def make_route_handler(blocklist: frozenset[str]):
93
93
  async def _handle_route(route: Route) -> None:
94
94
  url = route.request.url
95
95
  if should_block(url, blocklist):
96
- logger.debug("Blocked: %s", url[:200])
96
+ logger.info("Blocked: %s", url[:200])
97
97
  await route.abort("blockedbyclient")
98
98
  else:
99
99
  await route.continue_()
@@ -21,6 +21,7 @@ from veilrender.models import (
21
21
  RenderResponse,
22
22
  )
23
23
  from veilrender.storage import storage_manager
24
+ from veilrender.url_validator import URLValidationError, validate_url
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
@@ -72,6 +73,13 @@ def register(app: App) -> None:
72
73
  return JSONResponse({"error": "Missing 'url' field"}, status_code=400)
73
74
 
74
75
  req = RenderRequest.from_dict(data)
76
+
77
+ # Validate URL before any processing
78
+ try:
79
+ validate_url(req.url)
80
+ except URLValidationError as exc:
81
+ return JSONResponse({"error": f"URL rejected: {exc!s}"}, status_code=400)
82
+
75
83
  timeout = req.timeout or settings.timeout
76
84
  stats.render.requests += 1
77
85
  t0 = time.monotonic()
@@ -11,6 +11,7 @@ from veilrender.auth import verify_token
11
11
  from veilrender.browser import browser_manager
12
12
  from veilrender.config import settings
13
13
  from veilrender.models import ScreenshotRequest
14
+ from veilrender.url_validator import URLValidationError, validate_url
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
@@ -39,6 +40,17 @@ def register(app: App) -> None:
39
40
  )
40
41
 
41
42
  req = ScreenshotRequest.from_dict(data)
43
+
44
+ # Validate URL before any processing
45
+ try:
46
+ validate_url(req.url)
47
+ except URLValidationError as exc:
48
+ return Response(
49
+ body=f'{{"error": "URL rejected: {exc!s}"}}'.encode(),
50
+ status_code=400,
51
+ content_type="application/json",
52
+ )
53
+
42
54
  timeout = req.timeout or settings.timeout
43
55
  stats.screenshot.requests += 1
44
56
  t0 = time.monotonic()
@@ -0,0 +1,123 @@
1
+ """URL validation to prevent SSRF, local file reads, and protocol abuse.
2
+
3
+ Blocks:
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.
7
+ - DNS resolution is checked before navigation to prevent DNS rebinding.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ipaddress
13
+ import logging
14
+ import socket
15
+ from urllib.parse import urlparse
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _ALLOWED_SCHEMES = {"http", "https"}
20
+
21
+
22
+ class URLValidationError(Exception):
23
+ """Raised when a URL fails validation."""
24
+
25
+
26
+ def validate_url(url: str) -> str:
27
+ """Validate and sanitize a URL before passing it to Playwright.
28
+
29
+ Args:
30
+ url: The user-supplied URL to validate.
31
+
32
+ Returns:
33
+ The validated URL (unchanged if valid).
34
+
35
+ Raises:
36
+ URLValidationError: If the URL is unsafe.
37
+ """
38
+ if not url or not url.strip():
39
+ raise URLValidationError("Empty URL")
40
+
41
+ parsed = urlparse(url)
42
+
43
+ # Scheme check
44
+ scheme = parsed.scheme.lower()
45
+ if scheme not in _ALLOWED_SCHEMES:
46
+ raise URLValidationError(f"Scheme '{scheme}' not allowed (only http/https)")
47
+
48
+ # Hostname check
49
+ hostname = parsed.hostname
50
+ if not hostname:
51
+ raise URLValidationError("Missing hostname")
52
+
53
+ # Resolve DNS and check against private IP ranges
54
+ _check_resolved_ips(hostname)
55
+
56
+ return url
57
+
58
+
59
+ def _check_resolved_ips(hostname: str) -> None:
60
+ """Resolve hostname and reject private/reserved IP addresses.
61
+
62
+ Resolves DNS before Playwright navigates to prevent DNS rebinding
63
+ attacks where a hostname initially resolves to a public IP but
64
+ later resolves to a private one.
65
+
66
+ Args:
67
+ hostname: The hostname to check.
68
+
69
+ Raises:
70
+ URLValidationError: If the hostname resolves to a private IP.
71
+ """
72
+ # Check if hostname is a raw IP literal
73
+ try:
74
+ addr = ipaddress.ip_address(hostname)
75
+ if _is_blocked_ip(addr):
76
+ raise URLValidationError(f"IP address {hostname} is in a blocked range")
77
+ return
78
+ except ValueError:
79
+ pass # Not an IP literal, proceed to DNS resolution
80
+
81
+ # Resolve hostname
82
+ try:
83
+ results = socket.getaddrinfo(
84
+ hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
85
+ )
86
+ except socket.gaierror as exc:
87
+ raise URLValidationError(
88
+ f"DNS resolution failed for '{hostname}': {exc}"
89
+ ) from exc
90
+
91
+ if not results:
92
+ raise URLValidationError(f"No DNS results for '{hostname}'")
93
+
94
+ for family, _type, _proto, _canonname, sockaddr in results:
95
+ ip_str = sockaddr[0]
96
+ try:
97
+ addr = ipaddress.ip_address(ip_str)
98
+ if _is_blocked_ip(addr):
99
+ raise URLValidationError(
100
+ f"'{hostname}' resolves to blocked IP {ip_str}"
101
+ )
102
+ except ValueError:
103
+ continue
104
+
105
+
106
+ def _is_blocked_ip(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
107
+ """Check if an IP address is in a blocked range.
108
+
109
+ Blocks:
110
+ - Loopback (127.0.0.0/8, ::1)
111
+ - Private networks (10/8, 172.16/12, 192.168/16)
112
+ - Link-local (169.254/16, fe80::/10)
113
+ - Unique local (fd00::/8)
114
+ - Multicast, reserved, unspecified
115
+ """
116
+ return (
117
+ addr.is_loopback
118
+ or addr.is_private
119
+ or addr.is_reserved
120
+ or addr.is_multicast
121
+ or addr.is_link_local
122
+ or addr.is_unspecified
123
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veilrender
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -11,6 +11,7 @@ src/veilrender/filters.py
11
11
  src/veilrender/models.py
12
12
  src/veilrender/stats.py
13
13
  src/veilrender/storage.py
14
+ src/veilrender/url_validator.py
14
15
  src/veilrender.egg-info/PKG-INFO
15
16
  src/veilrender.egg-info/SOURCES.txt
16
17
  src/veilrender.egg-info/dependency_links.txt
@@ -1,39 +0,0 @@
1
- """Token-based authentication."""
2
-
3
- from __future__ import annotations
4
-
5
- from veilrender._vendor.httpserver import HTTPException, Request
6
- from veilrender.config import settings
7
-
8
-
9
- def verify_token(request: Request) -> None:
10
- """Verify the API token from header or query param.
11
-
12
- Checks ``Authorization: Bearer <token>`` header first, then falls
13
- back to ``?token=<token>`` query parameter.
14
-
15
- Raises:
16
- HTTPException: 401 if token is invalid, 403 if token is missing.
17
-
18
- If ``VEILRENDER_API_TOKEN`` is not configured, auth is disabled.
19
- """
20
- expected = settings.api_token
21
- if expected is None:
22
- return
23
-
24
- # Check Authorization header
25
- auth_header = request.headers.get("authorization", "")
26
- if auth_header.startswith("Bearer "):
27
- token = auth_header[7:].strip()
28
- if token == expected:
29
- return
30
- raise HTTPException(401, "Invalid token")
31
-
32
- # Check query param
33
- token_params = request.query_params.get("token", [])
34
- if token_params:
35
- if token_params[0] == expected:
36
- return
37
- raise HTTPException(401, "Invalid token")
38
-
39
- raise HTTPException(403, "Authentication required")
File without changes
File without changes
File without changes