Peng Ding commited on
Commit
8d22106
·
1 Parent(s): 13e00d9

feat: add outbound request filtering with StevenBlack/hosts blocklist

Browse files

Block ad networks, trackers, and beacons at the Playwright level to
prevent HF Spaces abuse-flag triggers and reduce attack surface.
Uses 82k-domain blocklist from StevenBlack/hosts (MIT), loaded at
startup with suffix matching via page.route().

Makefile CHANGED
@@ -1,6 +1,7 @@
1
- .PHONY: dev build run lint typecheck vendor clean build-package push-package deploy-dev deploy-hf help
2
 
3
  REGISTRY_MIRROR ?= docker.io
 
4
  DOCKER_IMAGE := oaklight/veilrender
5
  VERSION := $(shell grep -oE '__version__[[:space:]]*=[[:space:]]*"[^"]+"' src/veilrender/__init__.py | grep -oE '"[^"]+"' | tr -d '"' || echo "0.1.0")
6
 
@@ -23,6 +24,12 @@ typecheck:
23
  vendor:
24
  cd ~/projects/zerodep && python zerodep.py add httpserver readability soup markdown cache config useragent retry structlog -d $(CURDIR)/src/veilrender/_vendor/ -y -f
25
 
 
 
 
 
 
 
26
  clean:
27
  rm -rf build/ dist/ *.egg-info/ src/*.egg-info/
28
  find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@@ -100,6 +107,7 @@ help:
100
  @echo " lint - Run ruff check and format"
101
  @echo " typecheck - Run ty check"
102
  @echo " vendor - Re-vendor zerodep modules"
 
103
  @echo " clean - Remove build artifacts"
104
  @echo " build-package - Build Python package"
105
  @echo " push-package - Push package to PyPI"
 
1
+ .PHONY: dev build run lint typecheck vendor clean build-package push-package deploy-dev deploy-hf update-blocklist help
2
 
3
  REGISTRY_MIRROR ?= docker.io
4
+ BLOCKLIST_URL := https://cdn.jsdelivr.net/gh/StevenBlack/hosts@master/hosts
5
  DOCKER_IMAGE := oaklight/veilrender
6
  VERSION := $(shell grep -oE '__version__[[:space:]]*=[[:space:]]*"[^"]+"' src/veilrender/__init__.py | grep -oE '"[^"]+"' | tr -d '"' || echo "0.1.0")
7
 
 
24
  vendor:
25
  cd ~/projects/zerodep && python zerodep.py add httpserver readability soup markdown cache config useragent retry structlog -d $(CURDIR)/src/veilrender/_vendor/ -y -f
26
 
27
+ update-blocklist:
28
+ curl -sL "$(BLOCKLIST_URL)" \
29
+ | grep "^0.0.0.0 " | awk '{print $$2}' | grep -v "^0.0.0.0$$" \
30
+ > src/veilrender/data/blocklist.txt
31
+ @echo "Updated blocklist: $$(wc -l < src/veilrender/data/blocklist.txt) domains"
32
+
33
  clean:
34
  rm -rf build/ dist/ *.egg-info/ src/*.egg-info/
35
  find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
 
107
  @echo " lint - Run ruff check and format"
108
  @echo " typecheck - Run ty check"
109
  @echo " vendor - Re-vendor zerodep modules"
110
+ @echo " update-blocklist - Update ad/tracker blocklist from StevenBlack/hosts"
111
  @echo " clean - Remove build artifacts"
112
  @echo " build-package - Build Python package"
113
  @echo " push-package - Push package to PyPI"
pyproject.toml CHANGED
@@ -43,6 +43,7 @@ version = { attr = "veilrender.__version__" }
43
 
44
  [tool.setuptools.package-data]
45
  "veilrender" = ["py.typed"]
 
46
 
47
  [tool.ruff]
48
  target-version = "py310"
 
43
 
44
  [tool.setuptools.package-data]
45
  "veilrender" = ["py.typed"]
46
+ "veilrender.data" = ["blocklist.txt"]
47
 
48
  [tool.ruff]
49
  target-version = "py310"
src/veilrender/browser.py CHANGED
@@ -12,6 +12,7 @@ from cloakbrowser import ensure_binary, get_default_stealth_args
12
  from playwright.async_api import Browser, BrowserContext, Page, async_playwright
13
 
14
  from veilrender.config import settings
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -34,6 +35,14 @@ class BrowserManager:
34
  self._semaphore = asyncio.Semaphore(settings.max_concurrent)
35
  self._lock = asyncio.Lock()
36
 
 
 
 
 
 
 
 
 
37
  async def start(self) -> None:
38
  """Launch Chromium with CDP and connect Playwright to it."""
39
  executable_path = ensure_binary()
@@ -173,6 +182,8 @@ class BrowserManager:
173
  user_agent=None, # use Playwright default
174
  )
175
  page = await context.new_page()
 
 
176
  yield context, page
177
  finally:
178
  if context:
 
12
  from playwright.async_api import Browser, BrowserContext, Page, async_playwright
13
 
14
  from veilrender.config import settings
15
+ from veilrender.filters import load_blocklist, make_route_handler
16
 
17
  logger = logging.getLogger(__name__)
18
 
 
35
  self._semaphore = asyncio.Semaphore(settings.max_concurrent)
36
  self._lock = asyncio.Lock()
37
 
38
+ # Load blocklist once at init if resource filtering is enabled
39
+ if settings.resource_filter:
40
+ self._blocklist = load_blocklist(settings.blocked_domains_extra)
41
+ self._route_handler = make_route_handler(self._blocklist)
42
+ else:
43
+ self._blocklist = frozenset()
44
+ self._route_handler = None
45
+
46
  async def start(self) -> None:
47
  """Launch Chromium with CDP and connect Playwright to it."""
48
  executable_path = ensure_binary()
 
182
  user_agent=None, # use Playwright default
183
  )
184
  page = await context.new_page()
185
+ if self._route_handler:
186
+ await page.route("**/*", self._route_handler)
187
  yield context, page
188
  finally:
189
  if context:
src/veilrender/config.py CHANGED
@@ -20,6 +20,13 @@ class Settings:
20
  os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
21
  )
22
  self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
 
 
 
 
 
 
 
23
 
24
 
25
  settings = Settings()
 
20
  os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
21
  )
22
  self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
23
+ self.resource_filter: bool = (
24
+ os.environ.get("VEILRENDER_RESOURCE_FILTER", "true").lower() == "true"
25
+ )
26
+ _extra = os.environ.get("VEILRENDER_BLOCKED_DOMAINS_EXTRA", "")
27
+ self.blocked_domains_extra: list[str] = (
28
+ [d.strip() for d in _extra.split(",") if d.strip()] if _extra else []
29
+ )
30
 
31
 
32
  settings = Settings()
src/veilrender/data/__init__.py ADDED
File without changes
src/veilrender/data/blocklist.txt ADDED
The diff for this file is too large to render. See raw diff
 
src/veilrender/filters.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Outbound request filtering using community-maintained blocklists.
2
+
3
+ Blocks ad networks, trackers, and beacons at the Playwright level to reduce
4
+ unnecessary outbound connections and prevent abuse of the rendering service.
5
+
6
+ Blocklist source: StevenBlack/hosts (MIT license)
7
+ https://github.com/StevenBlack/hosts
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.resources
13
+ import logging
14
+ from urllib.parse import urlparse
15
+
16
+ from playwright.async_api import Route
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def load_blocklist(extra_domains: list[str] | None = None) -> frozenset[str]:
22
+ """Load the blocklist from the bundled data file.
23
+
24
+ Args:
25
+ extra_domains: Additional domains to block beyond the bundled list.
26
+
27
+ Returns:
28
+ A frozen set of blocked domain names for O(1) lookup.
29
+ """
30
+ domains: set[str] = set()
31
+
32
+ try:
33
+ ref = importlib.resources.files("veilrender.data").joinpath("blocklist.txt")
34
+ text = ref.read_text(encoding="utf-8")
35
+ for line in text.splitlines():
36
+ line = line.strip()
37
+ if line and not line.startswith("#"):
38
+ domains.add(line.lower())
39
+ except Exception:
40
+ logger.warning("Failed to load bundled blocklist, filtering disabled")
41
+
42
+ if extra_domains:
43
+ for d in extra_domains:
44
+ d = d.strip().lower()
45
+ if d:
46
+ domains.add(d)
47
+
48
+ logger.info("Loaded blocklist with %d domains", len(domains))
49
+ return frozenset(domains)
50
+
51
+
52
+ def should_block(url: str, blocklist: frozenset[str]) -> bool:
53
+ """Check if a URL should be blocked based on the blocklist.
54
+
55
+ Performs suffix matching: for hostname ``a.b.c.com``, checks
56
+ ``a.b.c.com``, ``b.c.com``, ``c.com`` against the set.
57
+
58
+ Args:
59
+ url: The request URL to check.
60
+ blocklist: Set of blocked domain names.
61
+
62
+ Returns:
63
+ True if the URL's domain matches the blocklist.
64
+ """
65
+ try:
66
+ hostname = urlparse(url).hostname
67
+ if not hostname:
68
+ return False
69
+ hostname = hostname.lower()
70
+
71
+ # Walk up the domain hierarchy
72
+ parts = hostname.split(".")
73
+ for i in range(len(parts)):
74
+ candidate = ".".join(parts[i:])
75
+ if candidate in blocklist:
76
+ return True
77
+ except Exception:
78
+ pass
79
+
80
+ return False
81
+
82
+
83
+ def make_route_handler(blocklist: frozenset[str]):
84
+ """Create a Playwright route handler that blocks requests to listed domains.
85
+
86
+ Args:
87
+ blocklist: Set of blocked domain names.
88
+
89
+ Returns:
90
+ An async route handler function for use with ``page.route()``.
91
+ """
92
+
93
+ async def _handle_route(route: Route) -> None:
94
+ url = route.request.url
95
+ if should_block(url, blocklist):
96
+ logger.debug("Blocked: %s", url[:200])
97
+ await route.abort("blockedbyclient")
98
+ else:
99
+ await route.continue_()
100
+
101
+ return _handle_route