Skip to content

Commit ba4ee51

Browse files
FlyM1ssclaude
andauthored
feat(security): WS/WebRTC SSRF egress firewall (#326)
* feat(security): egress firewall ruleset generator + tests Pure build_egress_ruleset + discovery/validation + --self-test for the WS/WebRTC SSRF egress firewall. Hermetic unit tests incl. parity vs ssrf_guard._is_blocked_ip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(security): image tooling + root-in-userns init for egress firewall Install nftables/iproute2/util-linux; drop USER fingpt so PID1 loads the firewall as root-in-userns then setpriv-drops to uid1001. No setcap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(security): root-init-drop entrypoint loads the egress firewall fail-closed Guarded temp-file nft load + post-load table assertion + active self-test (metadata unreachable, redis/DNS reachable), chown runtime, then setpriv-drop to uid1001 with NET_ADMIN removed from the bounding set + no_new_privs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(security): --cap-add=NET_ADMIN + pre-cutover firewall validation; compose cap_add Grant NET_ADMIN (load-bearing) to the api container; start redis then validate the new image can load the egress firewall + pass its self-test on fingpt-net before the --replace cutover (abort on failure so the old container keeps serving); record the previous image for manual rollback; grant NET_ADMIN in docker-compose for local dev. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(security): disable WebRTC + QUIC at Chromium (egress firewall defense-in-depth) Remove RTCPeerConnection in every frame (async factory + sync path) and add --disable-quic, shrinking the public WS/WebRTC/QUIC exfil surface the netns firewall leaves as a documented residual. Updates the factory guard test for the new await. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ops): off-box uptime monitor for deploy-independent fail-closed detection Scheduled curl of the public /health/ so a reboot/OOM/update that fails the egress firewall load (fail-closed, no auto-rollback) is caught within minutes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(security): update non-root guards for root-init-drop USER fingpt moved from the Dockerfile to entrypoint.sh (setpriv drop, NET_ADMIN out of the bounding set, no_new_privs) so PID1 can load the egress firewall as root. Assert the drop at its new home; count only actual chown commands (not the new comment prose). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(security): address self-review findings (resilience, honesty, coverage) Adversarial review fixes (branch validated on the droplet): - Self-test no longer makes redis/DNS a hard boot gate (was a resilience regression vs main): metadata-DROP leg stays fatal; redis/DNS legs are advisory (warn, non-fatal) so a redis blip at reboot cannot fail-close a correct firewall. - Metadata proof is now a reachable->blocked TRANSITION (--metadata-reachable before load + METADATA_WAS_REACHABLE), honestly flagged INCONCLUSIVE off-cloud instead of a vacuous pass. Applied in entrypoint + the deploy pre-cutover gate. - Deploy: redis started idempotently (start-if-absent, not --replace) so an aborted pre-cutover gate no longer bounces live redis / wipes counters. - Uptime monitor retry budget widened to ~180s (past the ~130s cold start) to stop false DOWN alarms during deploys. - entrypoint: EGRESS_FW_LOADED marker + app-phase guard (fail closed if the app phase is ever reached without the root-init firewall load). - discover_own_v6 constrained to ULA/GUA (symmetric with the v4 nesting check). - Chromium hardening comments corrected to best-effort (not a boundary). - New coverage: discovery parser (real ip -o output + rogue special-range), self-test fatal/non-fatal legs, entrypoint grep<->generator sentinel coupling, structural privilege-drop guard, ExecStart + compose NET_ADMIN cap locks, behavioral sync Chromium hardening test. Deferred (documented in PR): EGRESS_OWN_SUBNET prefixlen floor, RULES mktemp trap, writable HOME for the dropped user. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * polish(security): address PR #326 review findings Review findings from the pre-merge code review, all non-blocking: - Dedup _DISABLE_WEBRTC_JS: single public constant ssrf_guard.DISABLE_WEBRTC_JS (both scraper paths already import ssrf_guard); tests upgraded from substring to exact-equality against the shared constant so a local copy cannot drift back. - Bound the advisory redis self-test leg's worst case: probe timeout 3s -> 1s (same-subnet connect answers in ms or RSTs instantly; 6*(1+1)=12s worst case vs 24s inside the ~130s deploy health window). - _V6_DROP: document the future-IPv6 NDP footgun (ff00::/8 + fe80::/10 output drops swallow neighbor discovery; icmpv6 carve-out needed if v6 ever enabled). - Deploy workflow: document that redis start-if-absent freezes the redis image across deploys + the manual recreate procedure for redis upgrades. - entrypoint.sh: make explicit that EGRESS_FW_LOADED is a spoofable mistake-guard, not a security boundary. - Design spec: record the shipped amendment that the DNS/redis self-test legs are advisory (WARN), not fatal; only the metadata leg is fatal. Full suite: 576 passed, 1 skipped. manage.py check clean. Delta adversarially reviewed (3 independent lenses): behavior-preserving except the intended timeout change, which touches only the advisory leg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(security): simplify PR #326 per 4-lens cleanup review - Deploy gate now runs the image's REAL entrypoint (--egress-check-only) instead of an inline re-copy of the root-init sequence that had already drifted (missing [ -s ] + grep sentinels); wiring pinned by a new structural test (flag must sit after setpriv, before the store build) - egress_firewall: single _nets_within() nesting guard shared by v4/v6 discovery; _ip_addr_cidrs scans only its family's key; EGRESS_OWN_SUBNET override now fully hermetic (suppresses the v6 ip subprocess too, +test) - ssrf_guard.CHROMIUM_HARDENING_ARGS: --disable-quic single-sourced for both launch sites, next to DISABLE_WEBRTC_JS - test_ssrf_wire: one shared async + one shared sync Playwright mock scaffold replace four hand-copied chains - test_dockerfile_nonroot: setpriv flags asserted once on the actual exec statement (strictly stronger than the whole-file substring checks); line-continuation joiner reduced to a re.sub - sentinel test also pins the entrypoint's nft table-name literal to ef.TABLE; spec amended to record the gate's new shape Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2107d09 commit ba4ee51

14 files changed

Lines changed: 828 additions & 58 deletions

.github/workflows/backend-deploy.yml

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -177,28 +177,68 @@ jobs:
177177
podman network exists fingpt-net || podman network create fingpt-net
178178
179179
# Redis counter store (atomic incr/decr for agent budget + ratelimit).
180-
# Ephemeral cache: no persistence. --replace keeps redeploys idempotent.
181-
podman run -d --name fingpt-redis --replace --restart=always \
182-
--network fingpt-net \
183-
--health-cmd 'redis-cli ping' --health-interval 10s --health-retries 5 \
184-
docker.io/library/redis:7-alpine redis-server --save '' --appendonly no
180+
# Ephemeral cache: no persistence. Started idempotently (start-if-absent, not
181+
# --replace) BEFORE the pre-cutover gate so (a) the firewall self-test has redis
182+
# up on fingpt-net, and (b) an ABORTED deploy does not bounce the live redis /
183+
# wipe counters for the still-serving old container.
184+
# TRADE-OFF of start-if-absent: the running container stays FROZEN on whatever
185+
# redis:7-alpine image it was created from -- deploys never pick up redis image
186+
# updates (security patches included). To upgrade redis, run on the droplet:
187+
# podman pull docker.io/library/redis:7-alpine && podman rm -f fingpt-redis
188+
# then let the next deploy (or a re-run of this one) recreate it. Counters are
189+
# ephemeral by design, so the wipe is acceptable when done deliberately.
190+
if ! podman container exists fingpt-redis; then
191+
podman run -d --name fingpt-redis --restart=always \
192+
--network fingpt-net \
193+
--health-cmd 'redis-cli ping' --health-interval 10s --health-retries 5 \
194+
docker.io/library/redis:7-alpine redis-server --save '' --appendonly no
195+
else
196+
podman start fingpt-redis >/dev/null 2>&1 || true
197+
fi
198+
199+
# Pre-cutover firewall validation: run the NEW image's REAL entrypoint in
200+
# check-only mode on fingpt-net BEFORE the systemctl restart's --replace kills
201+
# the currently-serving container. --egress-check-only exercises the byte-
202+
# identical root-init PID1 runs at cutover (generate + sentinel checks + nft
203+
# load + transition-proof self-test + setpriv drop), then exits before the app
204+
# phase; an inline re-copy of that sequence (this step's previous shape) can
205+
# silently drift from the boot path it is supposed to prove. On failure, abort
206+
# so the OLD container keeps serving (there is no auto-rollback). This closes
207+
# the CI-invisible gap: the test job is pytest-only and never touches nft/netns.
208+
echo "Validating egress firewall on the new image (pre-cutover)..."
209+
podman run --rm --cap-add=NET_ADMIN --network fingpt-net \
210+
--env REDIS_URL=redis://fingpt-redis:6379/0 \
211+
"$REMOTE_IMAGE" --egress-check-only \
212+
|| { echo "ERROR: egress firewall pre-cutover validation FAILED; aborting deploy (old container left serving)"; exit 1; }
213+
echo "Egress firewall pre-cutover validation passed."
214+
215+
# Record the currently-deployed image for manual rollback (no auto-rollback).
216+
PREV_IMAGE=$(podman inspect --format '{{.ImageName}}' "$SYSTEMD_UNIT" 2>/dev/null || echo "unknown")
217+
echo "Previous image (manual rollback target): $PREV_IMAGE"
185218
186219
# Update systemd override to run the image we just pulled.
187-
# The image now runs as non-root uid 1001 (Dockerfile USER fingpt), so the
188-
# runtime bind mount below needs BOTH relabel suffixes for the non-root process
189-
# to write it on this SELinux-enforcing Fedora host:
220+
# PID1 is now root-in-userns (no Dockerfile USER line); it loads the egress
221+
# firewall, chowns /app/runtime to fingpt, then setpriv-drops to uid1001 for
222+
# the app. The runtime bind mount still needs BOTH relabel suffixes on this
223+
# SELinux-enforcing Fedora host:
190224
# :U rootless podman recursively chowns the host dir to the in-container uid
191-
# (DAC/ownership); without it the write is EACCES.
225+
# (now root/0, since PID1 is root); entrypoint then re-chowns it to fingpt
226+
# so the dropped app user can write it (validated: spec Appendix A probe 3).
192227
# :Z relabels the (private) host dir to container_file_t (MAC/SELinux);
193-
# without it container_t is denied and the write is *also* EACCES -- which
228+
# without it container_t is denied and the write is EACCES -- which
194229
# crash-looped the truth-layer store build the first time a deploy actually
195230
# wrote this mount (#322). Ownership alone is not enough; both are required.
231+
# --cap-add=NET_ADMIN is SECURITY/FUNCTIONALLY LOAD-BEARING: rootless podman's
232+
# default cap set excludes NET_ADMIN, so without it the root-in-userns init
233+
# cannot load the SSRF egress firewall (nft EPERM) -> entrypoint fail-closes ->
234+
# the container never serves. Do NOT drop it. (The app itself runs as uid1001
235+
# with NET_ADMIN removed from its bounding set; see Main/backend/entrypoint.sh.)
196236
OVERRIDE_DIR="$HOME/.config/systemd/user/${SYSTEMD_UNIT}.service.d"
197237
mkdir -p "$OVERRIDE_DIR"
198238
cat > "$OVERRIDE_DIR/override.conf" <<EOF
199239
[Service]
200240
ExecStart=
201-
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
241+
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-add=NET_ADMIN --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
202242
EOF
203243
204244
systemctl --user daemon-reload

.github/workflows/uptime.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Uptime Monitor
2+
3+
# Deploy-independent liveness: the backend fail-closes on a bad egress-firewall load,
4+
# and a load can fail on a reboot/OOM/kernel update WITHOUT a deploy (which re-runs the
5+
# entrypoint). This cron curls the PUBLIC endpoint so such an outage is caught within
6+
# minutes instead of by a user. Zero-infra fallback; GitHub may delay scheduled runs and
7+
# disables schedules after 60 days of repo inactivity.
8+
on:
9+
schedule:
10+
- cron: "*/10 * * * *"
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
probe:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Curl public health endpoint
21+
run: |
22+
set -euo pipefail
23+
# ~180s budget: comfortably past the documented ~130s deploy cold start (the
24+
# entrypoint builds the XBRL store before gunicorn listens, behind a --replace
25+
# swap), so a tick landing in a deploy window catches recovery instead of crying
26+
# wolf. A genuinely dead endpoint (>180s) still alerts.
27+
for i in $(seq 1 9); do
28+
if curl -fsS -m 10 https://agenticfinsearch.org/health/ >/dev/null; then
29+
echo "healthy (attempt $i)"
30+
exit 0
31+
fi
32+
echo "attempt $i/9 failed; retrying in 20s..."
33+
sleep 20
34+
done
35+
echo "::error::agenticfinsearch.org/health/ is DOWN after 9 attempts (~180s)"
36+
exit 1

Docs/superpowers/specs/2026-07-02-websocket-webrtc-ssrf-egress-firewall-design.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ Deliberate choices, each from the design review:
172172
resolver) → else non-zero.
173173
- `fingpt-redis:6379` TCP connect **must succeed** within a short retry window (~5×1s, tolerating
174174
redis's own cold start) → else non-zero.
175+
- **AMENDED at implementation:** the DNS + redis legs shipped as **advisory (WARN, exit 0)**, not
176+
fatal — the prior entrypoint had zero redis/DNS boot dependency, so a fatal leg would let a
177+
redis blip at reboot fail-close a correct firewall (see `_self_test`'s docstring for the full
178+
rationale). Only the metadata leg is fatal.
175179
- `ops/__init__.py` (new, empty) so `python -m ops.egress_firewall` resolves from WORKDIR `/app`.
176180

177181
### Dockerfile changes
@@ -195,6 +199,12 @@ Deliberate choices, each from the design review:
195199
ssrf_egress >/dev/null && python -m ops.egress_firewall --self-test'`. **Abort the deploy on
196200
failure** so the old container keeps serving. This converts a catastrophic mid-swap fail-closed
197201
into a safe no-op abort and closes the CI-invisible gap.
202+
**AMENDED at implementation (simplify pass):** the gate no longer inlines that sequence — it runs
203+
the image's REAL entrypoint via `podman run … "$REMOTE_IMAGE" --egress-check-only` (a flag
204+
entrypoint.sh handles after the setpriv drop, exiting before the app phase). An inline copy can
205+
silently drift from the boot path it is supposed to prove (and had: it lacked the `[ -s ]` and
206+
grep-sentinel guards); the flag form exercises the byte-identical root-init PID1 runs at cutover.
207+
Wiring pinned by `test_dockerfile_nonroot.test_precutover_gate_runs_real_entrypoint_check_only`.
198208
- Record/echo the previously-deployed image digest before restart, and add a one-line manual
199209
rollback note to the deploy log for the no-auto-rollback case.
200210

Main/backend/Dockerfile

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
1111
python3.12-venv \
1212
python3.12-dev \
1313
nodejs \
14+
nftables \
15+
iproute2 \
16+
util-linux \
1417
&& apt-get clean && rm -rf /var/lib/apt/lists/*
1518

1619
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
@@ -52,22 +55,24 @@ RUN echo "Verifying Playwright browsers..." && \
5255
test -d /ms-playwright/chromium_headless_shell-* && \
5356
echo "✓ Chromium browser found at /ms-playwright"
5457

55-
# Create a non-root runtime user and own ONLY the writable runtime dirs.
56-
# The application source under /app stays root-owned so a compromised
57-
# process cannot rewrite code at runtime (P0 Root A.3: no-write-under-/app).
58+
# Create the non-root runtime user and own ONLY the writable runtime dirs.
59+
# The application source under /app stays root-owned so a compromised process cannot
60+
# rewrite code at runtime (P0 Root A.3: no-write-under-/app). PID1 (entrypoint.sh) runs
61+
# as root-IN-USERNS -- rootless podman keeps that host-unprivileged -- solely to load the
62+
# SSRF egress firewall (needs CAP_NET_ADMIN) and chown the runtime mount, then
63+
# setpriv-drops to fingpt (uid1001, NET_ADMIN removed from the bounding set, no_new_privs)
64+
# so the long-running app is non-root and cannot flush its own firewall. The
65+
# no-write-under-/app intent holds: the app process is uid1001 and /app is root-owned;
66+
# only the brief pre-app init is root and it writes nothing under /app except chowning the
67+
# designated writable runtime mount (see entrypoint.sh; there is deliberately no USER line).
5868
# /app/runtime is the writable runtime-data volume mountpoint: entrypoint.sh builds the
59-
# regenerable DuckDB truth-layer store there (from the root-owned, still-read-only
60-
# vendored companyfacts JSON in the truthlayer/data package dir) BEFORE gunicorn forks, and in
61-
# production it is a persistent bind mount (podman -v ...:/app/runtime:U) so the store
62-
# survives restarts. Owning it in the image keeps it writable even with no mount
63-
# (compose/CI/bare docker run). Only this regenerable artifact dir is fingpt-writable;
64-
# no application code (.py) becomes writable, so the no-write-under-/app intent holds.
69+
# regenerable DuckDB truth-layer store there BEFORE gunicorn forks; in production it is a
70+
# persistent bind mount (podman -v ...:/app/runtime:U,Z) so the store survives restarts.
71+
# Owning these dirs in the image keeps them writable with no mount (compose/CI/bare run).
6572
RUN groupadd --system --gid 1001 fingpt \
6673
&& useradd --system --uid 1001 --gid fingpt --no-create-home fingpt \
6774
&& chown -R fingpt:fingpt /app/staticfiles /app/media /app/logs /tmp/fingpt_cache /app/runtime
6875

69-
USER fingpt
70-
7176
ENTRYPOINT ["/app/entrypoint.sh"]
7277

7378
EXPOSE 8000

Main/backend/datascraper/playwright_tools.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
logger = logging.getLogger(__name__)
1616

17+
1718
@asynccontextmanager
1819
async def PlaywrightBrowser(timeout: int = 30000):
1920
"""
@@ -36,7 +37,8 @@ async def PlaywrightBrowser(timeout: int = 30000):
3637
playwright = await async_playwright().start()
3738
browser = await playwright.chromium.launch(
3839
headless=True,
39-
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
40+
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
41+
*ssrf_guard.CHROMIUM_HARDENING_ARGS]
4042
)
4143

4244
context = await browser.new_context(
@@ -50,6 +52,10 @@ async def PlaywrightBrowser(timeout: int = 30000):
5052
# async entrypoints (and any future one) are pinned without each having
5153
# to remember the call. See datascraper.ssrf_guard.install_route_guard.
5254
await ssrf_guard.install_route_guard(page)
55+
# WebRTC hardening (best-effort, not a boundary): remove RTCPeerConnection before
56+
# page scripts run to curb WebRTC egress on Chromium's own socket (which the route
57+
# guard cannot intercept). The netns egress firewall is the real boundary.
58+
await page.add_init_script(ssrf_guard.DISABLE_WEBRTC_JS)
5359

5460
yield page
5561

Main/backend/datascraper/ssrf_guard.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
buffered response, so Chromium never opens its own connection for the HTTP(S)
1515
requests ``page.route`` intercepts. Non-GET and non-http(s) in-browser requests
1616
fail closed (aborted), as ``safe_get`` only serves pinned GETs. (WebSocket /
17-
WebRTC are NOT intercepted by ``page.route`` and remain a separate, pre-existing
18-
gap — out of scope here.)
17+
WebRTC are NOT intercepted by ``page.route``; that gap is closed for
18+
private/link-local/metadata destinations at the netns layer by the egress
19+
firewall — see ops/egress_firewall.py, which deliberately still accepts
20+
own-subnet + public egress — with ``DISABLE_WEBRTC_JS`` below as best-effort
21+
surface reduction on top.)
1922
2023
Public contract (do not rename):
2124
UnsafeURLError
@@ -25,6 +28,8 @@
2528
install_route_guard(page) (async, Playwright)
2629
install_route_guard_sync(page) (sync, Playwright)
2730
assert_safe_page_url(page) (async, Playwright)
31+
DISABLE_WEBRTC_JS (init-script string, Playwright)
32+
CHROMIUM_HARDENING_ARGS (launch-args tuple, Playwright)
2833
"""
2934
import asyncio
3035
import ipaddress
@@ -68,6 +73,25 @@
6873
# subresource burst reuses connections instead of serializing on one.
6974
_POOL_MAXSIZE = int(os.getenv("SCRAPE_POOL_MAXSIZE", "20"))
7075

76+
# Best-effort WebRTC surface reduction (NOT a boundary): a text scraper needs no WebRTC,
77+
# and page.route cannot intercept it (Chromium egresses it on its own socket). Installed
78+
# as a pre-navigation init script by BOTH scraper paths (the playwright_tools async
79+
# factory and the url_tools sync fallback) — defined once HERE so the two paths cannot
80+
# drift. Paired with CHROMIUM_HARDENING_ARGS on the launch args. A fresh realm could
81+
# re-obtain the deleted constructor, so the netns egress firewall (ops/egress_firewall.py)
82+
# remains the actual boundary; this only shrinks the surface for the public-egress residual.
83+
DISABLE_WEBRTC_JS = (
84+
"delete window.RTCPeerConnection;"
85+
"delete window.webkitRTCPeerConnection;"
86+
"delete window.RTCDataChannel;"
87+
)
88+
89+
# The launch-args half of the same surface reduction, consumed by BOTH launch sites
90+
# (playwright_tools async factory + url_tools sync fallback) so a hardening flag can
91+
# never be added to one path only. QUIC would otherwise carry HTTP/3 on Chromium's own
92+
# UDP socket, past page.route exactly like WebRTC.
93+
CHROMIUM_HARDENING_ARGS = ("--disable-quic",)
94+
7195
# Response headers that must NOT be forwarded when fulfilling a Playwright route
7296
# from a safe_get response: requests has already decoded the body (so a stale
7397
# Content-Encoding would make Chromium try to gunzip plaintext), and the framing

Main/backend/datascraper/url_tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def scrape_with_playwright(url: str) -> str:
202202

203203
try:
204204
with sync_playwright() as p:
205-
browser = p.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox'])
205+
browser = p.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox', *ssrf_guard.CHROMIUM_HARDENING_ARGS])
206206
try:
207207
context = browser.new_context(
208208
user_agent=HEADERS['User-Agent'],
@@ -215,6 +215,10 @@ def scrape_with_playwright(url: str) -> str:
215215
# seed-only validate_fetch_url checks leave open. Must precede
216216
# the first navigation.
217217
ssrf_guard.install_route_guard_sync(page)
218+
# WebRTC hardening (see ssrf_guard.DISABLE_WEBRTC_JS): remove
219+
# RTCPeerConnection in every frame so a scraped page cannot open WebRTC
220+
# on Chromium's own socket. Must precede the first navigation.
221+
page.add_init_script(ssrf_guard.DISABLE_WEBRTC_JS)
218222

219223
logger.info(f"Playwright scraping: {url}")
220224
page.goto(url, timeout=30000, wait_until="domcontentloaded")

0 commit comments

Comments
 (0)