aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_captcha_host_check.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 11:54:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 11:54:22 +0200
commit2d8c6bc449e343a80569e45d4ceae0423616cedd (patch)
tree09a1ee29c2a39db6189ba80315cfa0a1c463bcf3 /packages/meshbay-hub/tests/test_captcha_host_check.py
parentbee5901f7a04d17c40e2c8d077437f10e59c8d81 (diff)
downloadmeshbay-2d8c6bc449e343a80569e45d4ceae0423616cedd.tar.gz
fix(hub): a desktop solve reports no hostname at all, not "meshbay"
Registering from the native client failed with `captcha_failed` while the checkbox was green — a worse symptom than the one being fixed, because the widget now looked fine and only the hub's own log said otherwise: captcha solved on an unexpected host ''; allowed: ['localhost', 'meshbay', 'meshbay.org'] The previous commit assumed Google would report the host component of the origin, so `app://meshbay` would come back as `meshbay` and could sit in `allowed_hosts`. It does not. A solve Google cannot attribute to a domain reports an **empty** hostname, and no allowlist entry can match that. An empty entry is not the answer either: a blank in a TOML list is a typo far more often than an intention, and `load_config` drops blanks for that reason — `captcha.allow_unattributed_host` is a named flag instead, so the trade is stated where it is made. What it admits, plainly: every non-web client, not only ours. A file:// page or somebody else's Electron application look identical from here. That is the same bar the client's own origin would have been — main.js already records that `app://meshbay` is not a credential — and it is a bar: the captcha still has to be solved, per token, in something that can render it. What is given up is the origin restriction for non-web clients, not the captcha. Off by default, and a hub without the desktop client should leave it off. The refusal now names which of the two it is, since they need different answers: an unexpected host names the host, an unattributed one says to set the flag. docs/captcha.md §6 said `meshbay` was the value and told operators to add it; it now records what was measured and why the guess was wrong. The packaged example config carries the flag with the same warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
Diffstat (limited to 'packages/meshbay-hub/tests/test_captcha_host_check.py')
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py83
1 files changed, 74 insertions, 9 deletions
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py
index bbd6c6d..68cad34 100644
--- a/packages/meshbay-hub/tests/test_captcha_host_check.py
+++ b/packages/meshbay-hub/tests/test_captcha_host_check.py
@@ -19,6 +19,13 @@ 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.
+
+The desktop client needed one more thing, found only by trying it. Its origin
+is `app://meshbay`, and the guess was that Google would report the host part,
+`meshbay`. It does not: a solve it cannot attribute to a domain comes back
+with an **empty** hostname. `allow_unattributed_host` is what admits those,
+and it is a named flag rather than an allowlist entry because a blank in a
+TOML list is a typo far more often than an intention.
"""
import json
@@ -79,12 +86,44 @@ async def test_a_solve_from_an_expected_host_is_accepted(google):
@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)
+async def test_an_unattributed_solve_is_refused_by_default(google):
+ """What the desktop client actually produces, and what it costs.
+
+ `app://meshbay` does not report `meshbay`. Google attributes the solve to
+ no domain at all and the hostname comes back **empty** — measured live,
+ after the first attempt to allowlist `meshbay` failed. Refusing by default
+ is right: an answer with no hostname must not read as "any hostname".
+ """
+ google(success=True, hostname="")
+ assert not await verify_captcha(
+ "s", "t", allowed_hosts=frozenset({"meshbay.org", "localhost"}))
+
+
+@pytest.mark.asyncio
+async def test_the_flag_admits_it_and_the_allowlist_is_not_how(google):
+ """The flag is the only way in, on purpose.
+
+ An empty allowlist entry would be the obvious alternative and is a bad
+ one: a blank in a TOML list is a typo far more often than an intention,
+ and `load_config` drops blanks for exactly that reason. So this must not
+ work by accident from an allowlist alone.
+ """
+ google(success=True, hostname="")
+ allowed = frozenset({"meshbay.org", "localhost"})
+ assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True)
+ assert not await verify_captcha("s", "t", allowed_hosts=allowed | {""})
+
+
+@pytest.mark.asyncio
+async def test_the_flag_does_not_open_the_allowlist(google):
+ """It admits *unattributed* solves, not solves from somewhere else.
+
+ A bot on its own web page still reports that page's hostname, which is
+ still not ours, flag or no flag.
+ """
+ google(success=True, hostname="a-bot-farm.example")
+ assert not await verify_captcha(
+ "s", "t", allowed_hosts=frozenset({"meshbay.org"}), allow_unattributed=True)
@pytest.mark.asyncio
@@ -97,10 +136,12 @@ async def test_a_solve_from_somewhere_else_is_refused(google):
@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"."""
+async def test_an_absent_hostname_field_reads_like_an_empty_one(google):
+ """`hostname` missing and `hostname: ""` are the same answer."""
google(success=True)
- assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+ allowed = frozenset({"meshbay.org"})
+ assert not await verify_captcha("s", "t", allowed_hosts=allowed)
+ assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True)
@pytest.mark.asyncio
@@ -143,6 +184,15 @@ async def test_a_transport_failure_is_a_refusal_not_an_exception(monkeypatch):
assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"}))
+@pytest.mark.asyncio
+async def test_the_flag_alone_checks_nothing_without_an_allowlist(google):
+ """`allowed_hosts` empty still means "do not check", flag or no flag —
+ the whole block is skipped and reCAPTCHA is doing the origin check."""
+ google(success=True, hostname="somewhere.example")
+ assert await verify_captcha("s", "t", allow_unattributed=False)
+ assert await verify_captcha("s", "t", allow_unattributed=True)
+
+
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."""
@@ -179,6 +229,21 @@ def test_a_hub_toml_without_the_key_checks_nothing(tmp_path):
assert load_config(cfg_file).captcha.host_check is None
+def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch):
+ cfg_file = tmp_path / "hub.toml"
+ cfg_file.write_text(
+ '[captcha]\nsite_key = "k"\nsecret_key = "s"\n'
+ 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n')
+ assert load_config(cfg_file).captcha.allow_unattributed_host is True
+
+ cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n')
+ assert load_config(cfg_file).captcha.allow_unattributed_host is False, (
+ "the default has to stay off — it is the looser of the two")
+
+ monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST", "true")
+ assert load_config(cfg_file).captcha.allow_unattributed_host is True
+
+
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 ,")