Repository navigation
Opt-in hardening for scraping URLs from untrusted users - #5
Merged
Merged
Conversation
- SCRAPER_DISABLE_CLOUDSCRAPER turns tier_3 off. Its JS-challenge solve runs site-supplied JavaScript in js2py or Node's vm, neither of which is a security boundary; the browser tiers clear Cloudflare inside a real browser sandbox instead. A host whose learned winner was tier_3 falls through to the next tier, and --doctor shows tier_3 as off without failing the healthcheck. - SCRAPER_BROWSER_BLOCK_PRIVATE makes tiers 4 and 5 refuse every request and WebSocket a page makes to a loopback, private, link-local (incl. cloud metadata) or CGNAT address, with service workers blocked so they can't route around it. A page could otherwise pull a local service's response into the scraped content. - Both are off by default and the default path is untouched: no routing is installed, new_context()/new_page() get no extra options, doctor's output and exit code are the same. Tests pin that, plus each switch. - README and CHANGELOG document both variables. Verified end-to-end: 54 offline tests pass; the 4 live checks pass against a real Chromium (local service read with the guard off, never contacted with it on); the --quick smoke suite gives the same 6/7 on this branch and on main.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Two new opt-in environment variables for deployments that scrape URLs submitted by people they don't fully trust (a "save this link" box, a feed reader, a public API):
SCRAPER_DISABLE_CLOUDSCRAPER=1SCRAPER_BROWSER_BLOCK_PRIVATE=1Nothing changes for existing users unless they set a variable. That's pinned by tests (see Proof the default is unchanged).
Background: why this is needed
Switchback is often pointed at URLs that someone else chose. Two parts of the cascade then trust the page more than they should.
1. tier_3 runs site JavaScript outside a sandbox.
To clear a Cloudflare JS challenge, cloudscraper evaluates JavaScript the site sent. It uses either js2py (pure Python) or, when Node is installed, Node's built-in
vmmodule. Neither is designed as a security boundary: js2py has a published vulnerability advisory with no fixed release (and has had sandbox escapes reported), and Node's own docs sayvmis not a security mechanism. So a hostile page posing as a challenge could, in principle, run code on the scraping machine.The browser tiers (patchright/Chromium, Camoufox/Firefox) also run page JavaScript, but inside a real browser sandbox. That's the right place for untrusted code.
2. The browser tiers will fetch anything the page asks for.
A scraped page can redirect, frame, embed or
fetch()an address likehttp://127.0.0.1:<port>/or a cloud metadata endpoint. Whatever a service on the scraping machine (or its private network) answers then lands in the DOM, and so in the scrape result. Callers can check the URL they pass in, but they can't see what the page does once it's loaded.Both are reasonable defaults for scraping your own list of public sites, which is why they stay the defaults. They're the wrong defaults when the URLs come from users.
What changed
switchback/tiers/tier_3.pydisabled()hook readingSCRAPER_DISABLE_CLOUDSCRAPER(same pattern asSCRAPER_DISABLE_CAMOUFOXon tier_5); docstring explains when to use itswitchback/tiers/_browser.pyis_private_url(),context_options()andblock_private_networks(context), all no-ops unlessSCRAPER_BROWSER_BLOCK_PRIVATEis setswitchback/tiers/tier_4.pynew_context(**_browser.context_options())+_browser.block_private_networks(ctx)switchback/tiers/tier_5.pyswitchback/doctor.pyoff (SCRAPER_DISABLE_CLOUDSCRAPER set); a tier you turned off on purpose doesn't fail the healthchecktests/test_untrusted_urls.pyREADME.md,CHANGELOG.mdHow the guard works when on:
context.route("**/*")checks each request's host. A literal IP is checked directly; a hostname is resolved once per host per scrape (cached). Non-public addresses getroute.abort("blockedbyclient")and everything else continues.context.route_web_socket()does the same for WebSockets (Playwright β₯ 1.48; skipped if the API is missing).service_workers="block", because Playwright routing doesn't see requests made by a service worker.data:,blob:andabout:URLs, and hosts that don't resolve, aren't judged. The browser handles those exactly as before.How tier_3 being off flows through the cascade: the orchestrator already honours a tier's
disabled()(as it does for tier_5 and tier_7). The attempt is recorded asdisabledand the next tier runs. That holds even for a host whose learnedwinning_tieristier_3, so no host gets stranded.Proof the default is unchanged
context_options()returns{}, sonew_context()/new_page()get no extra arguments.block_private_networks()returns before touching the context, andtier_3.disabled()isFalse.tests/test_untrusted_urls.py):test_by_default_cloudscraper_runs_and_browsers_are_untouched: no route or WebSocket handler is installed, and no context options are added.test_by_default_doctor_reports_and_exits_exactly_as_before: same row and same exit codes, including exit 1 when cloudscraper is genuinely missing.test_cloudscraper_still_runs_when_not_opted_out: the cascade still uses tier_3.test_live_β¦[False](real Chromium): a page can still read a local service with the guard off, i.e. today's behaviour is preserved.python tests/test_suite.py --quickgives the same result onmainand on this branch: 6 passed, 1 failed (Ars Technica homepage, which fails identically onmainfrom this server).What correct output looks like
switchback --doctorwith the defaults (unchanged):switchback --doctorwithSCRAPER_DISABLE_CLOUDSCRAPER=1:A scrape with
SCRAPER_DISABLE_CLOUDSCRAPER=1: the outcome's attempts start withtier_3 β disabledand the result comes from a later tier. The log showstier_3 disabled; skipping <url>(the orchestrator's existing message).A scrape with
SCRAPER_BROWSER_BLOCK_PRIVATE=1where the page tries to reach a local service:browser: blocked a request to a non-public address (127.0.0.1). Only the hostname is logged, never the full URL.What would be wrong: tier_3 being skipped with the variable unset; any change to doctor's output with nothing set; a public page failing to load with the guard on. Any of these is a bug in this PR.
Tests run
pytest tests --ignore=tests/test_suite.pySWITCHBACK_BROWSER_TESTS=1 pytest tests/test_untrusted_urls.py -k live(real Chromium, two local HTTP servers)tier_4.fetch()end to endtests/test_suite.py --quickon branch vsmainTo re-run the live checks:
patchright install chromium-headless-shell, thenSWITCHBACK_BROWSER_TESTS=1 pytest tests/test_untrusted_urls.py -k live.How to review (about 10 minutes)
switchback/tiers/tier_3.py: thedisabled()hook (5 lines).switchback/tiers/tier_4.pyandtier_5.py: the two-line call sites. Confirm both are no-ops when unset.switchback/tiers/_browser.py: the guard (bottom of the file).switchback/doctor.py: the display and exit-code tweak.tests/test_untrusted_urls.py: the first section pins the defaults.Known limits (documented in the code and README)
Not in this PR
Rollout for OwnFeed (after merge)
patchright install chromium). With tier_3 off, that's what clears Cloudflare.SCRAPER_DISABLE_CLOUDSCRAPER=1andSCRAPER_BROWSER_BLOCK_PRIVATE=1in OwnFeed's environment.--doctorplus a few real saves.