diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-14 01:53:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-14 01:53:04 +0200 |
| commit | 392b5e4a53aace725794c7bbabf9e95fb4e1b9c5 (patch) | |
| tree | 0d31021b8558833a822bb906ec59d95abeb3f860 /packages/meshbay-hub/tests/test_unauthenticated_surface.py | |
| parent | 413837a0845240241ed7e9d9ac1f3b1dc45a2f40 (diff) | |
| download | meshbay-392b5e4a53aace725794c7bbabf9e95fb4e1b9c5.tar.gz | |
fix(hub): a per-account sign-in lockout, and a reviewed unauthenticated surface
Passphrase sign-in locks per username: after `login.max_failures` wrong
passphrases (default 4) the name is refused with `429 account_locked` and a
`Retry-After` for `login.lockout_minutes` (default 60), without the passphrase
being checked. Both numbers are instance policy an admin sets from the panel;
zero failures turns it off. The per-IP limit bounds one address, and IPv6
gives every subscriber a /64 of them — an online guess targets an account, so
the account is what is counted.
- Counted by the name as typed, existing or not, so `login` stays uniform (M1).
The key is a hash: people type passphrases into the username field.
- The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE
… WHERE … RETURNING`, so a concurrent burst gets no more than the limit.
- Sign-in, passphrase change and account deletion count on the same row; the
last had no rate limit at all.
- A lockout refuses passphrase sign-in and nothing else: sessions, renewal and
device sign-in continue, and a reset code clears it (AV26). A session learns
its own lockout from `/v1/users/me`, and the passphrase change checks it
before re-wrapping any node's bundle — the hub accepts the new passphrase
only after the nodes have it.
The SPA now shows what the hub said. `loginAndRecover` threw "Login failed:
{json}", so `email_verification_required` never matched and was never shown;
the passphrase-change form rendered no error at all in its first phase.
The unauthenticated surface, reviewed route by route:
- No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them
on meshbay.org only; a packaged hub behind any other proxy published all three.
- The node socket's first message must arrive within ten seconds. It is
accepted before anyone is known, and an unbounded read is a connection any
stranger holds for free.
- `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does:
nothing in the tree calls it and two of its routes take no account.
- `test_unauthenticated_surface.py` walks every route and fails on one without
an authentication dependency that is not listed with its reason.
Verified in Chrome against a local hub: the lockout and wrong-passphrase
messages, the admin section saving both lockout and mail limits, and the
passphrase change refused while locked. Not verified in Firefox (a running
instance blocks the headless one), nor the upsert's concurrency on PostgreSQL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
Diffstat (limited to 'packages/meshbay-hub/tests/test_unauthenticated_surface.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_unauthenticated_surface.py | 134 |
1 files changed, 134 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py new file mode 100644 index 0000000..2c9541a --- /dev/null +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -0,0 +1,134 @@ +""" +What the hub answers to somebody with no token. + +Every route either depends on an authentication dependency, or is on the list +below with the reason it must not. The list is the review, written down: a new +route that takes no account fails here until somebody adds it and says why, +which is the moment the question "should this be public?" gets asked. + +Routes that authenticate in their own body (a signature, an MHP token, a JWT +inside the first WebSocket message) are listed too, with what they check — +"no `Depends`" is not the same as "open", and the list says which is which. +""" + +import asyncio +import json + +import pytest +from fastapi.routing import APIRoute, APIWebSocketRoute + +AUTH_DEPENDENCIES = { + "_decode_token", "get_current_user", "require_user_scope", + "require_moderator", "require_admin", +} + +# (method, path) → why it takes no hub account. +PUBLIC = { + ("GET", "/"): "the application shell", + ("GET", "/app"): "the application shell", + ("GET", "/app/{path:path}"): "the application shell", + ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy", + ("GET", "/v1/hub/version"): "an installed client checks its minimum version first", + ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact", + ("GET", "/v1/health"): "supervision", + ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit", + ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout", + ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp", + ("POST", "/v1/users/token/refresh"): "the refresh token is the credential", + ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits", + ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp", + ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout", + ("GET", "/v1/groups"): "the public directory — empty when public groups are off", + ("GET", "/v1/blocklist"): "nodes sync it on their own behalf — hashes only", + ("GET", "/v1/blocklist/check"): "nodes consult it on their own behalf", + ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False", + ("POST", "/v1/relays/register"): "closed; when open, approved key + signature", + ("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False", + ("GET", "/mhp/directory"): "closed; when open, an MHP token", + ("POST", "/mhp/directory"): "closed; when open, an MHP token", + ("POST", "/mhp/revoke"): "closed; when open, an MHP token", +} + + +def _dependency_names(dependant, acc): + if dependant.call is not None: + acc.add(getattr(dependant.call, "__name__", "")) + for d in dependant.dependencies: + _dependency_names(d, acc) + return acc + + +def _routes(routes): + for r in routes: + # FastAPI wraps an included router rather than copying its routes. + inner = getattr(r, "original_router", None) + if inner is not None: + yield from _routes(inner.routes) + elif isinstance(r, (APIRoute, APIWebSocketRoute)): + yield r + + +@pytest.mark.asyncio +async def test_every_route_without_an_account_is_one_somebody_chose(app): + found = set() + for route in _routes(app.routes): + if _dependency_names(route.dependant, set()) & AUTH_DEPENDENCIES: + continue + for method in (getattr(route, "methods", None) or {"WS"}): + found.add((method, route.path)) + + assert found - set(PUBLIC) == set(), ( + "open route(s) nobody has reviewed — add an auth dependency, or list " + "them in PUBLIC with the reason") + assert set(PUBLIC) - found == set(), "PUBLIC lists routes that are gone or now authenticated" + + +@pytest.mark.asyncio +async def test_the_walk_sees_the_whole_api(app): + """The test above passes vacuously if the walk finds nothing.""" + assert sum(1 for _ in _routes(app.routes)) > 80 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/docs", "/redoc", "/openapi.json"]) +async def test_the_api_describes_itself_to_nobody(client, path): + r = await client.get(path) + assert r.status_code == 404 + assert "swagger" not in r.text.lower() and "openapi" not in r.text.lower() + + +class _SilentSocket: + """A client that connects and never says anything.""" + + def __init__(self): + self.sent, self.closed_with = [], None + + async def accept(self): + pass + + async def receive_text(self): + await asyncio.Event().wait() + + async def send_text(self, text): + self.sent.append(json.loads(text)) + + async def close(self, code=1000): + self.closed_with = code + + +@pytest.mark.asyncio +async def test_a_socket_that_never_authenticates_is_closed(monkeypatch): + """The node socket is accepted before anyone is known; silence is not a lease. + + Driven directly: the suite's transport has no WebSocket support. + """ + from meshbay_hub.api import revocation + + monkeypatch.setattr(revocation, "NODE_WS_AUTH_TIMEOUT", 0.05) + ws = _SilentSocket() + await asyncio.wait_for(revocation.node_websocket(ws), timeout=5) + + assert ws.closed_with == 4001 + assert ws.sent == [{"type": "error", "detail": "Authentication timed out"}] |