1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
|
"""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.
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
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_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
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_an_absent_hostname_field_reads_like_an_empty_one(google):
"""`hostname` missing and `hostname: ""` are the same answer."""
google(success=True)
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
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"}))
@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."""
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_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 ,")
cfg = load_config(tmp_path / "absent.toml")
assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay"})
|