fix(webui): issue localhost bootstrap api tokens
This commit is contained in:
+3
-3
@@ -221,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`.
|
|||||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
||||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning), and `/webui/bootstrap` will not return a WebUI REST API token. |
|
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for localhost requests; remote bootstrap requires `tokenIssueSecret` or `token`. |
|
||||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
@@ -267,8 +267,8 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
|||||||
4. The token is consumed (single use) and cannot be reused.
|
4. The token is consumed (single use) and cannot be reused.
|
||||||
|
|
||||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||||
It returns a separate `api_token` for REST routes only after the request proves
|
It returns a separate `api_token` for REST routes to localhost requests, or
|
||||||
knowledge of `tokenIssueSecret` or the static `token`.
|
after the request proves knowledge of `tokenIssueSecret` or the static `token`.
|
||||||
|
|
||||||
### Example setup
|
### Example setup
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -33,9 +33,9 @@ nanobot webui --background
|
|||||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||||
|
|
||||||
Manual config still works. Set `tokenIssueSecret` for full WebUI access; it is
|
Manual config still works. Localhost WebUI access can run without a browser
|
||||||
required before `/webui/bootstrap` returns a REST API token for session, settings,
|
password. Set `tokenIssueSecret` when you intentionally expose the WebUI beyond
|
||||||
Apps, Skills, and automation routes:
|
localhost or want a browser password:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -306,13 +306,14 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||||
api_token_allowed = bool(secret)
|
is_local = _is_localhost(connection)
|
||||||
if secret:
|
if secret:
|
||||||
if not _issue_route_secret_matches(request.headers, secret):
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
elif not _is_localhost(connection):
|
elif not is_local:
|
||||||
return _http_error(403, "bootstrap is localhost-only")
|
return _http_error(403, "bootstrap is localhost-only")
|
||||||
|
|
||||||
|
api_token_allowed = bool(secret) or is_local
|
||||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||||
return _http_response(
|
return _http_response(
|
||||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ async def test_bootstrap_returns_token_for_localhost(
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
assert "api_token" not in body
|
assert body["api_token"].startswith("nbwt_")
|
||||||
|
assert body["api_token"] != body["token"]
|
||||||
assert body["ws_path"] == "/"
|
assert body["ws_path"] == "/"
|
||||||
assert body["ws_url"] == "ws://127.0.0.1:29901/"
|
assert body["ws_url"] == "ws://127.0.0.1:29901/"
|
||||||
assert body["expires_in"] > 0
|
assert body["expires_in"] > 0
|
||||||
@@ -2067,16 +2068,26 @@ def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
|
|||||||
assert body["ws_url"] == "wss://nanobot.example/"
|
assert body["ws_url"] == "wss://nanobot.example/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="127.0.0.1")
|
||||||
|
resp = channel.gateway.http._handle_bootstrap(_REMOTE, _NO_HEADERS)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="127.0.0.1")
|
channel = _ch(bus, host="127.0.0.1")
|
||||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = json.loads(resp.body)
|
body = json.loads(resp.body)
|
||||||
assert body["token"].startswith("nbwt_")
|
assert body["token"].startswith("nbwt_")
|
||||||
assert "api_token" not in body
|
assert body["api_token"].startswith("nbwt_")
|
||||||
|
assert body["api_token"] != body["token"]
|
||||||
assert not channel.gateway.tokens.check_api_token(
|
assert not channel.gateway.tokens.check_api_token(
|
||||||
_FakeReq({"Authorization": f"Bearer {body['token']}"})
|
_FakeReq({"Authorization": f"Bearer {body['token']}"})
|
||||||
)
|
)
|
||||||
|
assert channel.gateway.tokens.check_api_token(
|
||||||
|
_FakeReq({"Authorization": f"Bearer {body['api_token']}"})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> None:
|
def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user