- 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)
310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""Click-path allowlist tests for the Playwright browser (fake only).
|
|
|
|
Covers the continuation of the navigate-only allowlist: click /
|
|
click_ref get the SAME check before the click when the target
|
|
anchor href is resolvable, and all three click paths get a
|
|
post-click URL check. click_at has no pre-click gate by design
|
|
(coordinates identify no element). No real Chromium, no network,
|
|
no real credentials -- values here are test placeholders.
|
|
"""
|
|
|
|
import os
|
|
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_navigated_url # noqa: E402
|
|
|
|
|
|
class _FakeLocator:
|
|
def __init__(self, page, href):
|
|
self._page = page
|
|
self._href = href
|
|
|
|
@property
|
|
def first(self):
|
|
return self
|
|
|
|
def click(self, timeout=None):
|
|
self._page.calls.append(("locator_click",))
|
|
if self._page.click_landing is not None:
|
|
self._page.url = self._page.click_landing
|
|
|
|
def get_attribute(self, name):
|
|
if name == "href":
|
|
return self._href
|
|
return None
|
|
|
|
def inner_text(self, timeout=None):
|
|
return "text"
|
|
|
|
|
|
class _FakeMouse:
|
|
def __init__(self, page):
|
|
self._page = page
|
|
|
|
def click(self, x, y):
|
|
self._page.calls.append(("mouse_click", x, y))
|
|
if self._page.click_landing is not None:
|
|
self._page.url = self._page.click_landing
|
|
|
|
def move(self, x, y):
|
|
self._page.calls.append(("mouse_move", x, y))
|
|
|
|
|
|
class _FakePage:
|
|
def __init__(self):
|
|
self.url = "https://example.com/"
|
|
self.calls = []
|
|
self.href_map = {}
|
|
self.click_landing = None
|
|
self.mouse = _FakeMouse(self)
|
|
|
|
def title(self):
|
|
return "Example"
|
|
|
|
def goto(self, url, timeout=None, wait_until=None):
|
|
self.calls.append(("goto", url))
|
|
self.url = url
|
|
|
|
def locator(self, selector):
|
|
self.calls.append(("locator", selector))
|
|
return _FakeLocator(self, self.href_map.get(selector))
|
|
|
|
def click(self, selector, timeout=None):
|
|
self.calls.append(("click", selector))
|
|
if self.click_landing is not None:
|
|
self.url = self.click_landing
|
|
|
|
def fill(self, selector, text, timeout=None):
|
|
self.calls.append(("fill", selector, text))
|
|
|
|
def evaluate(self, expression):
|
|
self.calls.append(("evaluate", expression))
|
|
return None
|
|
|
|
def screenshot(self, path=None):
|
|
with open(path, "wb") as fh:
|
|
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)
|
|
|
|
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
|
|
|
|
|
|
class ClickAllowlistTest(_EnvGuard, unittest.TestCase):
|
|
def setUp(self):
|
|
_EnvGuard.setUp(self)
|
|
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 _start(self, url="https://example.com/"):
|
|
return self.browser.navigate(url)
|
|
|
|
def test_click_allowed_href_executes(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
|
|
self._start()
|
|
self.page.href_map["a.docs"] = "https://docs.python.org/3/"
|
|
self.page.click_landing = "https://docs.python.org/3/"
|
|
out = self.browser.click("a.docs")
|
|
self.assertTrue(out["ok"])
|
|
self.assertIn(("click", "a.docs"), self.page.calls)
|
|
self.assertEqual(out["url"], "https://docs.python.org/3/")
|
|
|
|
def test_click_blocked_href_refused_before_click(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.page.href_map["a.evil"] = "https://evil.com/x"
|
|
self.page.click_landing = "https://evil.com/x"
|
|
with self.assertRaises(BrowserError) as ctx:
|
|
self.browser.click("a.evil")
|
|
self.assertIn("evil.com", str(ctx.exception))
|
|
self.assertNotIn(("click", "a.evil"), self.page.calls)
|
|
self.assertEqual(self.browser._current_url,
|
|
"https://example.com/")
|
|
|
|
def test_click_suffix_trick_blocked(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = ".python.org"
|
|
self._start("https://python.org/")
|
|
for bad in ("https://notpython.org/",
|
|
"https://python.org.evil.com/"):
|
|
self.page.href_map["a.bad"] = bad
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.click("a.bad")
|
|
self.assertNotIn(("click", "a.bad"), self.page.calls)
|
|
self.page.calls.clear()
|
|
|
|
def test_click_plain_entry_is_exact_only(self):
|
|
# Same semantics as navigate: a plain entry does NOT
|
|
# match subdomains (that is what leading-dot entries
|
|
# are for). Documented, not a bug: the activation
|
|
# candidate therefore uses dot forms.
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "python.org"
|
|
self._start("https://python.org/")
|
|
self.page.href_map["a.docs"] = "https://docs.python.org/"
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.click("a.docs")
|
|
self.assertNotIn(("click", "a.docs"), self.page.calls)
|
|
|
|
def test_click_port_and_case_insensitive(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
|
|
self._start()
|
|
self.page.href_map["a.p"] = "https://EXAMPLE.com:8443/p"
|
|
self.page.click_landing = "https://EXAMPLE.com:8443/p"
|
|
out = self.browser.click("a.p")
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_click_file_href_allowed(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.page.href_map["a.file"] = "file:///tmp/page.html"
|
|
self.page.click_landing = "file:///tmp/page.html"
|
|
out = self.browser.click("a.file")
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_click_localhost_needs_explicit_listing(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.page.href_map["a.local"] = "http://localhost:8000/"
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.click("a.local")
|
|
self.assertNotIn(("click", "a.local"), self.page.calls)
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
|
|
self.page.click_landing = "http://localhost:8000/"
|
|
out = self.browser.click("a.local")
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_click_relative_href_resolves_against_current(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start("https://example.com/dir/")
|
|
self.page.href_map["a.rel"] = "/other"
|
|
self.page.click_landing = "https://example.com/other"
|
|
out = self.browser.click("a.rel")
|
|
self.assertTrue(out["ok"])
|
|
# Fragment-only hrefs never navigate to another host.
|
|
self.page.href_map["a.frag"] = "#section"
|
|
self.page.click_landing = None
|
|
out = self.browser.click("a.frag")
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_click_unknown_target_post_click_detection(self):
|
|
# Button / JS handler: no href, so no pre-click gate.
|
|
# The click DOES execute; a blocked landing is then
|
|
# reported (detection, not prevention -- the proxy 403
|
|
# is the layer that stops the load in production).
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.page.href_map["button.go"] = None
|
|
self.page.click_landing = "https://evil.com/"
|
|
with self.assertRaises(BrowserError) as ctx:
|
|
self.browser.click("button.go")
|
|
self.assertIn("evil.com", str(ctx.exception))
|
|
self.assertIn(("click", "button.go"), self.page.calls)
|
|
# Same target landing on an allowed host is fine.
|
|
self.page.click_landing = "https://example.com/ok"
|
|
out = self.browser.click("button.go")
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_click_unset_allowlist_unchanged(self):
|
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
|
self._start("https://anything.example/")
|
|
self.page.href_map["a.evil"] = "https://evil.com/"
|
|
self.page.click_landing = "https://evil.com/"
|
|
out = self.browser.click("a.evil")
|
|
self.assertTrue(out["ok"])
|
|
self.assertIn(("click", "a.evil"), self.page.calls)
|
|
|
|
def test_click_ref_blocked_href_refused_before_click(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.browser._ref_paths = {"e0": "a.evil", "e1": "a.docs"}
|
|
self.page.href_map["a.evil"] = "https://evil.com/"
|
|
self.page.href_map["a.docs"] = "https://example.com/docs"
|
|
self.page.click_landing = "https://evil.com/"
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.click_ref("e0")
|
|
self.assertNotIn(("locator_click",), self.page.calls)
|
|
self.page.click_landing = "https://example.com/docs"
|
|
out = self.browser.click_ref("e1")
|
|
self.assertTrue(out["ok"])
|
|
self.assertIn(("locator_click",), self.page.calls)
|
|
|
|
def test_click_at_post_click_only(self):
|
|
# click_at has no pre-click gate by design; a blocked
|
|
# landing raises after the click, an allowed one passes.
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
self._start()
|
|
self.page.click_landing = "https://evil.com/"
|
|
with self.assertRaises(BrowserError):
|
|
self.browser.click_at(100, 200)
|
|
self.assertIn(("mouse_click", 100.0, 200.0), self.page.calls)
|
|
self.page.click_landing = "https://example.com/"
|
|
out = self.browser.click_at(100, 200)
|
|
self.assertTrue(out["ok"])
|
|
|
|
def test_check_navigated_url_skips_non_page_schemes(self):
|
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
|
# about:blank / mailto: / javascript: never navigate
|
|
# the page to a host, so they must not raise here.
|
|
_check_navigated_url("about:blank")
|
|
_check_navigated_url("mailto:someone@example.com")
|
|
_check_navigated_url("javascript:void(0)")
|
|
_check_navigated_url("file:///tmp/page.html")
|
|
with self.assertRaises(BrowserError):
|
|
_check_navigated_url("https://evil.com/")
|