- click/click_ref: pre-click allowlist gate on resolvable anchor href
(same check as navigate; miss refuses before the click executes);
click/click_ref/click_at: post-click landing URL check.
click_at has no pre-click gate by design (coordinates name no
element); JS-driven navigation, evaluate, meta refresh and popups
are documented as unintercepable with the Tinyproxy 403 backstop.
- Tenant script upgrade is now safe: only proven-unmodified scripts
(manifest match, 2a569ac known-shipped SHA-256, missing, or
current) auto-merge; tenant-modified scripts are never overwritten,
are reported as skipped, and get one deduped audit record. The
manifest never learns a tenant hash (else the next upgrade would
mistake a modified file for untouched). Idempotent reruns.
- Activation candidate = deployed Tinyproxy programming set encoded
with leading-dot entries (plain entries would regress
docs.python.org); npmjs.org per the deployed filter, not npmjs.com.
Host activation itself is NOT performed this round: only the plan,
validation tests and rollback are delivered.
Tests: 28 new (click 13, upgrade 8, activation 7), all fake/no-network;
98 browser-related tests pass including prior suites.
(cherry picked from commit e4a93f5748ec815f163d0bc096ff4dc4b8c651f5)
210 lines
7.4 KiB
Python
210 lines
7.4 KiB
Python
"""BROWSER_ALLOWED_HOSTS activation-candidate validation.
|
|
|
|
The candidate value is the ALREADY-DEPLOYED Tinyproxy
|
|
programming-domain set (deploy/egress/tinyproxy-filter) plus the
|
|
already-present github.com / pypi.org / npmjs.org -- no invented
|
|
domains. NOTE: the task text said "npmjs.com", but the deployed
|
|
filter (tinyproxy-filter line 23, new-host-checklist.md, and
|
|
docs/platform-browser.md) all say npmjs.org, so the candidate
|
|
follows the DEPLOYED set (npmjs.org); npmjs.com is deliberately
|
|
NOT in the candidate -- adding it would widen the allowlist
|
|
beyond the proxy, breaking allowlist==proxy parity. If the npm
|
|
website itself must be browsable, that is a separate filter
|
|
change for the parent to decide, not something this round
|
|
invents. Because the Tinyproxy lines are subdomain-inclusive
|
|
(``^\\(.*\\.\\)*domain$``), the env value uses leading-dot
|
|
entries, which are the allowlist encoding of the same
|
|
subdomain-inclusive set; plain entries would NOT match
|
|
subdomains (proven by test_plain_entries_would_break_subdomains)
|
|
and would therefore regress docs.python.org etc.
|
|
|
|
All checks run against fake browsers / the filter file itself:
|
|
no production tenant, no network, no real credentials.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from playwright_browser import Browser, BrowserError # noqa: E402
|
|
from playwright_browser import _check_allowed_url # noqa: E402
|
|
|
|
# The activation candidate: the deployed Tinyproxy programming
|
|
# set, encoded subdomain-inclusively. Domain SET is fixed by the
|
|
# task (no new domains); only the encoding (dot prefix) follows
|
|
# from the filter's own subdomain rule.
|
|
CANDIDATE_DOMAINS = [
|
|
"python.org", "nodejs.org", "go.dev", "pkg.go.dev",
|
|
"rust-lang.org", "developer.mozilla.org", "crates.io",
|
|
"stackoverflow.com", "cve.mitre.org", "nvd.nist.gov",
|
|
"github.com", "pypi.org", "npmjs.org",
|
|
]
|
|
CANDIDATE = ",".join("." + d for d in CANDIDATE_DOMAINS)
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),
|
|
"..", ".."))
|
|
FILTER_PATH = os.path.join(REPO_ROOT, "deploy", "egress",
|
|
"tinyproxy-filter")
|
|
|
|
|
|
class _FakePage:
|
|
def __init__(self):
|
|
self.url = ""
|
|
self.calls = []
|
|
self.mouse = self
|
|
|
|
def title(self):
|
|
return "T"
|
|
|
|
def goto(self, url, timeout=None, wait_until=None):
|
|
self.calls.append(("goto", url))
|
|
self.url = url
|
|
|
|
def locator(self, selector):
|
|
raise AssertionError("no locator use in activation tests")
|
|
|
|
def click(self, selector, timeout=None):
|
|
self.calls.append(("click", selector))
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _FakeHandle:
|
|
def __init__(self, page):
|
|
self._page = page
|
|
|
|
def new_page(self, viewport=None):
|
|
return self._page
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _FakePW:
|
|
def __init__(self, page):
|
|
self._page = page
|
|
self.launch_count = 0
|
|
|
|
def start(self):
|
|
return self
|
|
|
|
@property
|
|
def chromium(self):
|
|
outer = self
|
|
|
|
class _Chromium:
|
|
def launch(self, **kwargs):
|
|
outer.launch_count += 1
|
|
return _FakeHandle(outer._page)
|
|
|
|
return _Chromium()
|
|
|
|
def stop(self):
|
|
pass
|
|
|
|
|
|
class _EnvGuard:
|
|
def setUp(self):
|
|
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
|
|
|
|
def tearDown(self):
|
|
if self._old is None:
|
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
|
else:
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
|
|
|
|
|
|
def _filter_domains():
|
|
"""Domains covered by subdomain-inclusive filter lines."""
|
|
with open(FILTER_PATH, encoding="utf-8") as fh:
|
|
lines = fh.read().splitlines()
|
|
out = set()
|
|
for line in lines:
|
|
m = re.match(r"^\^\\\(\.\*\\.\\\)\*(.+)\$$", line)
|
|
if m:
|
|
out.add(m.group(1).replace("\\.", "."))
|
|
return out
|
|
|
|
|
|
class ActivationCandidateTest(_EnvGuard, unittest.TestCase):
|
|
def setUp(self):
|
|
_EnvGuard.setUp(self)
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = CANDIDATE
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.page = _FakePage()
|
|
self.factory = _FakePW(self.page)
|
|
self.browser = Browser(cwd=self._tmp.name,
|
|
proxy="http://proxy:8888",
|
|
browser_factory=lambda: self.factory)
|
|
|
|
def tearDown(self):
|
|
self._tmp.cleanup()
|
|
_EnvGuard.tearDown(self)
|
|
|
|
def test_candidate_matches_deployed_filter_set(self):
|
|
# Every candidate domain has a subdomain-inclusive line
|
|
# in the deployed filter; the candidate invents nothing.
|
|
filt = _filter_domains()
|
|
for domain in CANDIDATE_DOMAINS:
|
|
self.assertIn(domain, filt, domain)
|
|
|
|
def test_candidate_allows_bare_and_subdomains(self):
|
|
for domain in CANDIDATE_DOMAINS:
|
|
_check_allowed_url("https://%s/" % domain)
|
|
_check_allowed_url("https://www.%s/docs" % domain)
|
|
# The cases the filter explicitly exists for.
|
|
_check_allowed_url("https://docs.python.org/3/")
|
|
_check_allowed_url("https://docs.rust-lang.org/")
|
|
_check_allowed_url("https://api.github.com/")
|
|
_check_allowed_url("https://pypi.org/project/x/")
|
|
|
|
def test_candidate_blocks_suffix_tricks_and_outsiders(self):
|
|
for bad in ("https://notpython.org/",
|
|
"https://python.org.evil.com/",
|
|
"https://evilgithub.com/",
|
|
"https://api.diante.me/",
|
|
"https://www.amazon.com/",
|
|
"https://mail.google.com/"):
|
|
with self.assertRaises(BrowserError, msg=bad):
|
|
_check_allowed_url(bad)
|
|
|
|
def test_plain_entries_would_break_subdomains(self):
|
|
# Why the candidate uses dot forms: plain "python.org"
|
|
# does not match docs.python.org under the established
|
|
# navigate semantics, so a plain-entry activation would
|
|
# regress exactly the docs the filter was added for.
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = (
|
|
"python.org,nodejs.org,go.dev,pkg.go.dev,rust-lang.org,"
|
|
"developer.mozilla.org,crates.io,stackoverflow.com,"
|
|
"cve.mitre.org,nvd.nist.gov,github.com,pypi.org,npmjs.org")
|
|
with self.assertRaises(BrowserError):
|
|
_check_allowed_url("https://docs.python.org/3/")
|
|
|
|
def test_file_allowed_localhost_blocked_under_candidate(self):
|
|
out = self.browser.navigate("file:///tmp/page.html")
|
|
self.assertTrue(out["ok"])
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.navigate("http://localhost:8000/")
|
|
# localhost blocking is a behaviour CHANGE vs unset:
|
|
# local dev servers need an explicit localhost entry.
|
|
self.assertEqual(self.factory.launch_count, 1)
|
|
|
|
def test_task_level_navigate_allow_and_block(self):
|
|
out = self.browser.navigate("https://docs.python.org/3/")
|
|
self.assertTrue(out["ok"])
|
|
launches = self.factory.launch_count
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.navigate("https://evil.com/")
|
|
self.assertEqual(self.factory.launch_count, launches)
|
|
|
|
def test_unset_means_no_change(self):
|
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
|
out = self.browser.navigate("https://anything.example/")
|
|
self.assertTrue(out["ok"])
|
|
_check_allowed_url("https://anything.example/")
|