mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
* LANG-145: bind WebAuthn to server config and make passkeys OSS-only Two problems in the passkey implementation: 1. The RP ID and expected origin came from the request: a JSON 'origin' field, then the Origin/Referer headers, then the request URL. WebAuthn's anti-phishing guarantee depends on values the server chose; trusting the request lets a sibling host under the same registrable domain obtain a valid assertion for a victim's credential. Resolution now follows the same rule the OAuth and invitation links already use: api.webui_url, then api.webhook_prefix, then the configured port on loopback. 2. Passkeys are now an OSS-only feature. Cloud deployments authenticate through their own account service, so every passkey route (register options/verify, auth options/verify, list, rename, delete) refuses with edition_unsupported, and /account-info advertises neither passkey_login_enabled nor passkey_supported. Both cloud flavours are covered: system.edition == 'cloud' and deployment.mode == 'cloud'. Tests: - New unit suite for the edition matrix and for origin resolution (including that client-supplied origin/Origin/Referer are ignored). - The three 'reverse proxy scenario' tests asserted the old, unsafe behaviour (server trusting the request). They now assert the server-config contract. - test_smoke cloud capabilities expectation updated to passkey_* = false. Co-authored-by: multica-agent <[email protected]> * style(api): drop the typing import left unused by the WebAuthn origin change Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""Passkey gating (OSS-only) and server-side WebAuthn origin resolution.
|
|
|
|
Regression coverage for LANG-145:
|
|
- WebAuthn RP ID / origin must come from server config, never from the request,
|
|
otherwise a sibling host can obtain a valid assertion for a victim credential.
|
|
- Passkeys are an OSS-only feature: every passkey route must refuse on a cloud
|
|
deployment (multi-tenant Cloud or standalone cloud edition).
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.http.controller.groups import user as user_module
|
|
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
|
from langbot.pkg.utils import constants
|
|
|
|
|
|
def _router(instance_config=None, deployment_mode=None):
|
|
"""Build just enough of the router group to call its helpers."""
|
|
group = UserRouterGroup.__new__(UserRouterGroup)
|
|
group.ap = SimpleNamespace(
|
|
instance_config=SimpleNamespace(data=instance_config or {}),
|
|
deployment=SimpleNamespace(mode=deployment_mode) if deployment_mode else None,
|
|
)
|
|
return group
|
|
|
|
|
|
def test_origin_and_rp_id_come_from_server_config(monkeypatch):
|
|
monkeypatch.setattr(constants, 'edition', 'community')
|
|
group = _router({'api': {'webui_url': 'https://bot.langbot.app', 'webhook_prefix': 'https://example.test'}})
|
|
|
|
origin, rp_id = group._resolve_server_origin()
|
|
|
|
assert origin == 'https://bot.langbot.app'
|
|
assert rp_id == 'bot.langbot.app'
|
|
|
|
|
|
def test_origin_falls_back_to_webhook_prefix(monkeypatch):
|
|
monkeypatch.setattr(constants, 'edition', 'community')
|
|
group = _router({'api': {'webui_url': '', 'webhook_prefix': 'https://bot2.langbot.app'}})
|
|
|
|
origin, rp_id = group._resolve_server_origin()
|
|
|
|
assert origin == 'https://bot2.langbot.app'
|
|
assert rp_id == 'bot2.langbot.app'
|
|
|
|
|
|
def test_origin_never_uses_loopback_of_attacker_supplied_host(monkeypatch):
|
|
"""With nothing configured the RP falls back to loopback, not the request."""
|
|
monkeypatch.setattr(constants, 'edition', 'community')
|
|
group = _router({'api': {'webui_url': '', 'webhook_prefix': '', 'port': 5300}})
|
|
|
|
origin, rp_id = group._resolve_server_origin()
|
|
|
|
assert origin == 'http://127.0.0.1:5300'
|
|
assert rp_id == 'localhost'
|
|
|
|
|
|
def test_client_supplied_origin_helper_no_longer_exists():
|
|
"""The request-derived helper must be gone, not merely unused."""
|
|
assert not hasattr(UserRouterGroup, '_extract_origin_and_rp_id')
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'edition,deployment_mode,expected',
|
|
[
|
|
('community', None, True),
|
|
('oss', None, True),
|
|
('community', 'oss', True),
|
|
('cloud', None, False),
|
|
('community', 'cloud', False),
|
|
('cloud', 'cloud', False),
|
|
],
|
|
)
|
|
def test_passkey_supported_only_on_oss(monkeypatch, edition, deployment_mode, expected):
|
|
monkeypatch.setattr(constants, 'edition', edition)
|
|
group = _router(deployment_mode=deployment_mode)
|
|
|
|
assert group._passkey_supported() is expected
|
|
|
|
|
|
def test_refusal_builds_only_when_unsupported(monkeypatch):
|
|
"""The denial path is taken on cloud and skipped on OSS.
|
|
|
|
``_passkey_disabled_response`` builds a Quart response, which needs an app
|
|
context, so the decision itself is asserted here through the gate that
|
|
drives it; the wiring of every route to that denial is covered below.
|
|
"""
|
|
monkeypatch.setattr(constants, 'edition', 'cloud')
|
|
assert _router()._passkey_supported() is False
|
|
|
|
monkeypatch.setattr(constants, 'edition', 'community')
|
|
assert _router()._passkey_supported() is True
|
|
|
|
|
|
def test_every_passkey_route_is_gated():
|
|
"""Guards against a new passkey route being added without the edition gate."""
|
|
import inspect
|
|
|
|
source = inspect.getsource(user_module)
|
|
passkey_routes = source.count("@self.route('/passkey")
|
|
assert passkey_routes >= 7, f'expected at least 7 passkey routes, found {passkey_routes}'
|
|
assert source.count('_passkey_disabled_response()') >= passkey_routes
|