aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_captcha_host_check.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_captcha_host_check.py')
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py188
1 files changed, 188 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py
new file mode 100644
index 0000000..bbd6c6d
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_captcha_host_check.py
@@ -0,0 +1,188 @@
+"""A solved captcha has to have been solved on one of our own pages.
+
+reCAPTCHA normally does this itself: the widget reports the hostname of the
+page it is rendered in, and Google compares it against the key's domain list.
+That is the check the desktop client cannot pass. Its interface ships inside
+the package and is served from `app://meshbay` — not a preference, Chromium
+refuses a service worker on a custom scheme and the hub must never become the
+document origin — so the hostname Google sees is not the hub's and never can
+be. The widget renders "ERROR for site owner: Invalid domain for site key",
+which no amount of client-side configuration reaches, because the decision is
+made on Google's servers.
+
+Turning the key's own origin check off moves the decision here. `siteverify`
+then reports the hostname it *observed* — not one the caller asserts — so this
+stays a real check: a bot that embeds the site key on a page of its own is
+refused on the hostname it actually served from, which is the attack that
+turning the check off would otherwise open.
+
+`allowed_hosts` empty means "do not check", which is what a deployment that
+left the origin check with Google wants. That is the default, so an existing
+hub upgrades without its captcha changing behaviour.
+"""
+
+import json
+
+import pytest
+
+from meshbay_hub.captcha import verify_captcha
+from meshbay_hub.config import CaptchaConfig, load_config
+
+
+
+class _Resp:
+ def __init__(self, payload):
+ self._payload = payload
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return json.loads(json.dumps(self._payload))
+
+
+class _Client:
+ """Stands in for httpx.AsyncClient, and records what was sent."""
+
+ sent: list[dict] = []
+
+ def __init__(self, payload):
+ self._payload = payload
+
+ def __call__(self, *a, **kw):
+ return self
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def post(self, url, data=None):
+ type(self).sent.append(dict(data or {}))
+ return _Resp(self._payload)
+
+
+@pytest.fixture
+def google(monkeypatch):
+ """`siteverify` answers whatever the test says it answers."""
+ def _answer(**payload):
+ _Client.sent = []
+ monkeypatch.setattr("httpx.AsyncClient", _Client(payload))
+ return _answer
+
+
+@pytest.mark.asyncio
+async def test_a_solve_from_an_expected_host_is_accepted(google):
+ google(success=True, hostname="meshbay.org")
+ assert await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+
+
+@pytest.mark.asyncio
+async def test_the_desktop_client_s_own_host_is_just_another_entry(google):
+ """`app://meshbay` is what the reported hostname comes from — the reason
+ any of this exists. Nothing about it is special to the check."""
+ google(success=True, hostname="meshbay")
+ allowed = frozenset({"meshbay.org", "localhost", "meshbay"})
+ assert await verify_captcha("s", "t", allowed_hosts=allowed)
+
+
+@pytest.mark.asyncio
+async def test_a_solve_from_somewhere_else_is_refused(google):
+ """The attack that turning off the key's origin check would open: the site
+ key is public, so a bot can render the widget on a page of its own."""
+ google(success=True, hostname="a-bot-farm.example")
+ assert not await verify_captcha(
+ "s", "t", allowed_hosts=frozenset({"meshbay.org", "meshbay"}))
+
+
+@pytest.mark.asyncio
+async def test_a_missing_hostname_is_refused_when_checking(google):
+ """An answer with no hostname at all must not read as "any hostname"."""
+ google(success=True)
+ assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+
+
+@pytest.mark.asyncio
+async def test_no_allowed_hosts_means_google_is_still_the_one_checking(google):
+ """The default, and what every hub that never touched this config does.
+
+ The hostname is whatever it is and this does not look: the key's own origin
+ check is on, so the answer would not have come back successful otherwise.
+ """
+ google(success=True, hostname="somewhere.example")
+ assert await verify_captcha("s", "t")
+ assert await verify_captcha("s", "t", allowed_hosts=None)
+
+
+@pytest.mark.asyncio
+async def test_a_failed_solve_is_refused_whatever_the_hostname(google):
+ """Order matters: `success` first, and the host check never rescues a no."""
+ google(success=False, hostname="meshbay.org", **{"error-codes": ["timeout-or-duplicate"]})
+ assert not await verify_captcha(
+ "s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+
+
+@pytest.mark.asyncio
+async def test_the_remote_ip_still_rides_along(google):
+ """Unchanged behaviour, pinned because the signature grew a parameter."""
+ google(success=True, hostname="meshbay.org")
+ await verify_captcha("s", "t", "203.0.113.7", frozenset({"meshbay.org"}))
+ assert _Client.sent == [{"secret": "s", "response": "t", "remoteip": "203.0.113.7"}]
+
+
+@pytest.mark.asyncio
+async def test_a_transport_failure_is_a_refusal_not_an_exception(monkeypatch):
+ """Unchanged, and worth keeping: a captcha that cannot be checked is not a
+ captcha that passed."""
+ class _Boom(_Client):
+ async def post(self, url, data=None):
+ raise RuntimeError("no route to host")
+
+ monkeypatch.setattr("httpx.AsyncClient", _Boom({}))
+ assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+
+
+def test_the_config_hands_over_a_set_or_nothing():
+ """`host_check` is what the caller passes straight through, so "unset" has
+ to arrive as None rather than an empty set that refuses everything."""
+ assert CaptchaConfig().host_check is None
+ assert CaptchaConfig(allowed_hosts=[]).host_check is None
+ assert CaptchaConfig(allowed_hosts=["meshbay.org", "meshbay"]).host_check == frozenset(
+ {"meshbay.org", "meshbay"})
+
+
+def test_hub_toml_carries_the_allowed_hosts(tmp_path):
+ """An operator who turns off the key's origin check sets this, and it is
+ the only place they can: the value depends on the deployment."""
+ cfg_file = tmp_path / "hub.toml"
+ cfg_file.write_text(
+ '[captcha]\n'
+ 'site_key = "6Lsite"\n'
+ 'secret_key = "6Lsecret"\n'
+ 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n')
+
+ cfg = load_config(cfg_file)
+
+ assert cfg.captcha.enabled
+ assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay", "localhost"}), (
+ "entries are stripped and blanks dropped — a stray space in a TOML "
+ "list would otherwise refuse every solve from that host")
+
+
+def test_a_hub_toml_without_the_key_checks_nothing(tmp_path):
+ """The upgrade path. A hub that never heard of this setting keeps the
+ behaviour it has, with reCAPTCHA doing the origin check."""
+ cfg_file = tmp_path / "hub.toml"
+ cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n')
+
+ assert load_config(cfg_file).captcha.host_check is None
+
+
+def test_the_environment_can_override_the_list(monkeypatch, tmp_path):
+ """Same shape as MESHBAY_ADMIN_USERS, for a container that has no file."""
+ monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOWED_HOSTS", "meshbay.org, meshbay ,")
+
+ cfg = load_config(tmp_path / "absent.toml")
+
+ assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay"})