fix(webui): tighten localhost bootstrap check
This commit is contained in:
+4
-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. |
|
||||
| `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). |
|
||||
| `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`. |
|
||||
| `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 same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret` or `token`. |
|
||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||
|
||||
### Access Control
|
||||
@@ -267,8 +267,9 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
||||
4. The token is consumed (single use) and cannot be reused.
|
||||
|
||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||
It returns a separate `api_token` for REST routes to localhost requests, or
|
||||
after the request proves knowledge of `tokenIssueSecret` or the static `token`.
|
||||
It returns a separate `api_token` for REST routes to same-machine localhost
|
||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`
|
||||
or the static `token`.
|
||||
|
||||
### Example setup
|
||||
|
||||
|
||||
+3
-3
@@ -33,9 +33,9 @@ nanobot webui --background
|
||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||
|
||||
Manual config still works. Localhost WebUI access can run without a browser
|
||||
password. Set `tokenIssueSecret` when you intentionally expose the WebUI beyond
|
||||
localhost or want a browser password:
|
||||
Manual config still works. Same-machine localhost WebUI access can run without
|
||||
a browser password. Set `tokenIssueSecret` when you intentionally expose the
|
||||
WebUI beyond localhost or want a browser password:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -45,6 +45,9 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
http_response as _http_response,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
is_local_browser_request as _is_local_browser_request,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
is_localhost as _is_localhost,
|
||||
)
|
||||
@@ -306,14 +309,14 @@ class GatewayHTTPHandler:
|
||||
|
||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||
is_local = _is_localhost(connection)
|
||||
is_local_browser = _is_local_browser_request(connection, request.headers)
|
||||
if secret:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not is_local:
|
||||
elif not is_local_browser:
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
|
||||
api_token_allowed = bool(secret) or is_local
|
||||
api_token_allowed = bool(secret) or is_local_browser
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
|
||||
@@ -1971,6 +1971,7 @@ class _FakeReq:
|
||||
_REMOTE = _FakeConn(("192.168.1.5", 12345))
|
||||
_LOCAL = _FakeConn(("127.0.0.1", 12345))
|
||||
_NO_HEADERS = _FakeReq()
|
||||
_LOCAL_BROWSER_REQ = _FakeReq({"Host": "127.0.0.1:8765"})
|
||||
|
||||
|
||||
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
||||
@@ -2058,10 +2059,16 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
|
||||
|
||||
|
||||
def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="127.0.0.1", port=29931)
|
||||
channel = _ch(bus, host="127.0.0.1", port=29931, tokenIssueSecret="s3cret")
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_LOCAL,
|
||||
_FakeReq({"Host": "nanobot.example", "X-Forwarded-Proto": "https"}),
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": "Bearer s3cret",
|
||||
"Host": "nanobot.example",
|
||||
"X-Forwarded-Proto": "https",
|
||||
}
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
@@ -2074,9 +2081,18 @@ def test_bootstrap_without_auth_rejects_remote_requests(bus: MagicMock) -> None:
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_bootstrap_without_auth_rejects_reverse_proxy_remote_headers(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="127.0.0.1")
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_LOCAL,
|
||||
_FakeReq({"Host": "nanobot.example", "X-Forwarded-For": "203.0.113.42"}),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="127.0.0.1")
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert body["token"].startswith("nbwt_")
|
||||
@@ -2114,7 +2130,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes
|
||||
lambda: "from-disk",
|
||||
)
|
||||
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert body["model_name"] == "live/model"
|
||||
@@ -2126,7 +2142,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp
|
||||
lambda: "from-disk",
|
||||
)
|
||||
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert body["model_name"] == "from-disk"
|
||||
@@ -2142,7 +2158,7 @@ def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: p
|
||||
raise RuntimeError("resolver failed")
|
||||
|
||||
channel = _ch(bus, host="127.0.0.1", runtime_model_name=boom)
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert body["model_name"] == "from-disk"
|
||||
|
||||
Reference in New Issue
Block a user