mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: remove retired insecure auth guidance (#120295)
This commit is contained in:
committed by
GitHub
parent
b738e25780
commit
dcb125d202
@@ -54,7 +54,6 @@ exhaustive):
|
||||
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Retired device-auth bypass migration still permits remediation access | complete **Secure this browser** pairing (`gateway.controlUi.deviceAuthMigration` state) | no |
|
||||
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
|
||||
| `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no |
|
||||
|
||||
@@ -663,11 +663,9 @@ Trusted proxy headers do not make node device pairing automatically trusted - `g
|
||||
|
||||
The Control UI needs a secure context (HTTPS or localhost) to generate device identity.
|
||||
|
||||
- `gateway.controlUi.allowInsecureAuth`: local compatibility toggle. On localhost, allows Control UI auth without device identity when the page loads over non-secure HTTP. Does not bypass pairing checks and does not relax remote (non-localhost) device identity requirements. Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||
- Token/password auth does not replace browser device identity over remote plain HTTP. Use HTTPS (for example, Tailscale Serve) or open the UI on `127.0.0.1` from the Gateway host.
|
||||
- `gateway.controlUi.dangerouslyDisableDeviceAuth`: retired break-glass input. Older configs preserve authenticated, pairing-only Control UI access for remediation until a browser reopened over HTTPS or localhost completes the bounded, explicit self-pairing migration; do not add it to current config.
|
||||
- Separate from those flags, a successful `gateway.auth.mode: "trusted-proxy"` can admit **operator** Control UI sessions without device identity - an intentional auth-mode behavior, not an `allowInsecureAuth` shortcut, and it does not extend to node-role Control UI sessions.
|
||||
|
||||
`openclaw security audit` warns when `allowInsecureAuth` is enabled.
|
||||
- Separately, successful `gateway.auth.mode: "trusted-proxy"` authentication can admit **operator** Control UI sessions without device identity. This does not extend to node-role Control UI sessions.
|
||||
|
||||
### Insecure/dangerous flags
|
||||
|
||||
@@ -675,7 +673,6 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags tracked by the audit today">
|
||||
- `gateway.controlUi.allowInsecureAuth=true`
|
||||
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`
|
||||
- pending Control UI device-auth migration imported from retired `gateway.controlUi.dangerouslyDisableDeviceAuth=true`
|
||||
- `security.audit.suppressions configured (<count>)`
|
||||
|
||||
@@ -65,11 +65,12 @@ Use this to have the gateway listen directly on the Tailnet IP, with no Serve/Fu
|
||||
}
|
||||
```
|
||||
|
||||
Connect from another Tailnet device:
|
||||
Connect a native or CLI client from another Tailnet device:
|
||||
|
||||
- Control UI: `http://<tailscale-ip>:18789/`
|
||||
- WebSocket: `ws://<tailscale-ip>:18789`
|
||||
|
||||
Do not use the direct plain-HTTP address for the browser Control UI. Remote plain HTTP cannot create browser device identity, and token/password auth does not replace it. Use Tailscale Serve for the Control UI.
|
||||
|
||||
<Note>
|
||||
When a bindable Tailnet IPv4 is present, the Gateway also requires `http://127.0.0.1:18789` for authenticated same-host clients. If no Tailnet address is available at startup, it falls back to loopback only; restart after Tailscale becomes available to add direct Tailnet access. Neither path adds LAN or public exposure.
|
||||
</Note>
|
||||
|
||||
@@ -107,7 +107,6 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
|
||||
- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. With `gateway.auth.allowTailscale: true`, identity headers satisfy Control UI/WebSocket auth (no pasted shared secret, assumes a trusted gateway host); HTTP APIs still need shared-secret auth unless you deliberately use private-ingress `none` or trusted-proxy HTTP auth.
|
||||
Concurrent bad-auth Serve attempts from the same client are serialized before the failed-auth limiter records them, so a second bad retry can already show `retry later`.
|
||||
- **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"` (or configure password auth), open `http://<tailscale-ip>:18789/`, paste the matching shared secret in dashboard settings.
|
||||
- **Identity-aware reverse proxy**: keep the Gateway behind a trusted proxy, set `gateway.auth.mode: "trusted-proxy"`, open the proxy URL. Same-host loopback proxies need explicit `gateway.auth.trustedProxy.allowLoopback: true`.
|
||||
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@gateway-host`, then open `http://127.0.0.1:18789/`. Shared-secret auth still applies over the tunnel; paste the configured token or password if prompted.
|
||||
|
||||
|
||||
@@ -116,15 +116,6 @@ DigitalOcean is a straightforward paid VPS path. For cheaper or free options:
|
||||
|
||||
Tailscale Serve authenticates Control UI and WebSocket traffic via tailnet identity headers, which assumes the gateway host itself is trusted. HTTP API endpoints still follow the gateway's normal auth mode (token/password) regardless. To require explicit shared-secret credentials over Serve, set `gateway.auth.allowTailscale: false` and use `gateway.auth.mode: "token"` or `"password"`.
|
||||
|
||||
**Option C: Tailnet bind (no Serve)**
|
||||
|
||||
```bash
|
||||
openclaw config set gateway.bind tailnet
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Then open `http://<tailscale-ip>:18789` (token required).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
+13
-28
@@ -118,7 +118,7 @@ The assistant avatar override follows the same browser-local pattern: uploaded o
|
||||
|
||||
## Runtime config endpoint
|
||||
|
||||
The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` under base path `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires a valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
|
||||
The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` under base path `/__openclaw__/`). That endpoint is gated by gateway HTTP auth: unauthenticated browsers cannot fetch it, and a successful fetch requires a valid gateway token/password or trusted-proxy identity. Tailscale header auth applies to the Control UI WebSocket, not this HTTP endpoint.
|
||||
|
||||
## Gateway host status
|
||||
|
||||
@@ -614,40 +614,25 @@ constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and
|
||||
|
||||
## Tailnet access (recommended)
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Integrated Tailscale Serve (preferred)">
|
||||
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
|
||||
Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
|
||||
|
||||
```bash
|
||||
openclaw gateway --tailscale serve
|
||||
```
|
||||
```bash
|
||||
openclaw gateway --tailscale serve
|
||||
```
|
||||
|
||||
Open `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`).
|
||||
Open `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`).
|
||||
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic, then use `gateway.auth.mode: "token"` or `"password"`.
|
||||
By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic, then use `gateway.auth.mode: "token"` or `"password"`.
|
||||
|
||||
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
|
||||
For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
|
||||
|
||||
<Warning>
|
||||
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
|
||||
</Warning>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Bind to tailnet + token">
|
||||
```bash
|
||||
openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Open `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`).
|
||||
|
||||
Paste the matching shared secret into the UI settings (sent as `connect.params.auth.token` or `connect.params.auth.password`).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Warning>
|
||||
Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
|
||||
</Warning>
|
||||
|
||||
## Insecure HTTP
|
||||
|
||||
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`), the browser runs in a **non-secure context** and blocks WebCrypto. By default, OpenClaw **blocks** Control UI connections without device identity.
|
||||
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`), the browser runs in a **non-secure context** and blocks WebCrypto. OpenClaw rejects token/password Control UI connections without device identity; a shared secret cannot replace browser identity.
|
||||
|
||||
The supported device-less exception is successful operator Control UI auth
|
||||
through `gateway.auth.mode: "trusted-proxy"`. There is no persistent config
|
||||
@@ -659,7 +644,7 @@ switch that disables device identity.
|
||||
<Accordion title="Trusted-proxy note">
|
||||
- Successful trusted-proxy auth can admit **operator** Control UI sessions without device identity.
|
||||
- This does **not** extend to node-role Control UI sessions.
|
||||
- Same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
|
||||
- A same-host loopback reverse proxy requires both loopback in `gateway.trustedProxies` and `gateway.auth.trustedProxy.allowLoopback: true`; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -57,26 +57,6 @@ When `hooks.enabled=true`, the Gateway also exposes a webhook endpoint on the sa
|
||||
|
||||
Open `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`).
|
||||
|
||||
</Tab>
|
||||
<Tab title="Tailnet bind + token">
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "tailnet",
|
||||
controlUi: { enabled: true },
|
||||
auth: { mode: "token", token: "your-token" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Start the gateway (this non-loopback example uses shared-secret token auth):
|
||||
|
||||
```bash
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
Open `http://<tailscale-ip>:18789/` (or your configured `gateway.controlUi.basePath`).
|
||||
|
||||
</Tab>
|
||||
<Tab title="Public internet (Funnel)">
|
||||
```json5
|
||||
|
||||
@@ -68,7 +68,6 @@ export async function authenticateGatewayConnect(
|
||||
} = context.handler;
|
||||
const {
|
||||
connectParams,
|
||||
configSnapshot,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
peerLabel,
|
||||
@@ -105,7 +104,6 @@ export async function authenticateGatewayConnect(
|
||||
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
|
||||
const controlUiAuthPolicy = resolveControlUiAuthPolicy({
|
||||
isControlUi,
|
||||
controlUiConfig: configSnapshot.gateway?.controlUi,
|
||||
deviceRaw,
|
||||
deviceAuthMigrationPending: context.handler.isControlUiDeviceAuthMigrationPending?.(),
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ function deviceRaw(id: string): DeviceRaw {
|
||||
function authPolicy(params: Partial<ControlUiAuthPolicyInput> = {}) {
|
||||
return resolveControlUiAuthPolicy({
|
||||
isControlUi: params.isControlUi ?? false,
|
||||
controlUiConfig: params.controlUiConfig,
|
||||
deviceRaw: params.deviceRaw ?? null,
|
||||
deviceAuthMigrationPending: params.deviceAuthMigrationPending,
|
||||
});
|
||||
@@ -93,16 +92,14 @@ function expectClearsUnboundScopes(overrides: Partial<ClearUnboundScopesInput>,
|
||||
|
||||
describe("ws connect policy", () => {
|
||||
test("resolves control-ui auth policy", () => {
|
||||
const bypass = authPolicy({
|
||||
const controlUi = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
deviceRaw: deviceRaw("dev-1"),
|
||||
});
|
||||
expect(bypass.device?.id).toBe("dev-1");
|
||||
expect(controlUi.device?.id).toBe("dev-1");
|
||||
|
||||
const regular = authPolicy({
|
||||
isControlUi: false,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
deviceRaw: deviceRaw("dev-2"),
|
||||
});
|
||||
expect(regular.device?.id).toBe("dev-2");
|
||||
@@ -160,18 +157,7 @@ describe("ws connect policy", () => {
|
||||
|
||||
test("evaluates missing-device decisions", () => {
|
||||
const policy = authPolicy();
|
||||
const controlUiStrict = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false },
|
||||
});
|
||||
const controlUiNoInsecure = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: false },
|
||||
});
|
||||
const bypass = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
});
|
||||
const controlUi = authPolicy({ isControlUi: true });
|
||||
|
||||
expectMissingDeviceDecision(
|
||||
{
|
||||
@@ -182,34 +168,21 @@ describe("ws connect policy", () => {
|
||||
"allow",
|
||||
);
|
||||
|
||||
// Remote Control UI with allowInsecureAuth -> still rejected.
|
||||
expectMissingDeviceDecision(
|
||||
{
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: controlUiStrict,
|
||||
controlUiAuthPolicy: controlUi,
|
||||
isLocalClient: false,
|
||||
},
|
||||
"reject-control-ui-insecure-auth",
|
||||
);
|
||||
|
||||
// The retired insecure-auth flag no longer bypasses device identity locally.
|
||||
expectMissingDeviceDecision(
|
||||
{
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: controlUiStrict,
|
||||
isLocalClient: true,
|
||||
},
|
||||
"reject-control-ui-insecure-auth",
|
||||
);
|
||||
|
||||
// Control UI without allowInsecureAuth, even on localhost -> rejected.
|
||||
expectMissingDeviceDecision(
|
||||
{
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: controlUiNoInsecure,
|
||||
controlUiAuthPolicy: controlUi,
|
||||
isLocalClient: true,
|
||||
},
|
||||
"reject-control-ui-insecure-auth",
|
||||
@@ -263,7 +236,7 @@ describe("ws connect policy", () => {
|
||||
{
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: controlUiNoInsecure,
|
||||
controlUiAuthPolicy: controlUi,
|
||||
trustedProxyAuthOk: true,
|
||||
sharedAuthOk: false,
|
||||
hasSharedAuth: false,
|
||||
@@ -275,7 +248,7 @@ describe("ws connect policy", () => {
|
||||
{
|
||||
role: "operator",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: bypass,
|
||||
controlUiAuthPolicy: controlUi,
|
||||
sharedAuthOk: false,
|
||||
authOk: false,
|
||||
hasSharedAuth: false,
|
||||
@@ -283,12 +256,11 @@ describe("ws connect policy", () => {
|
||||
"reject-control-ui-insecure-auth",
|
||||
);
|
||||
|
||||
// Retired bypass input cannot admit device-less node-role registrations.
|
||||
expectMissingDeviceDecision(
|
||||
{
|
||||
role: "node",
|
||||
isControlUi: true,
|
||||
controlUiAuthPolicy: bypass,
|
||||
controlUiAuthPolicy: controlUi,
|
||||
sharedAuthOk: false,
|
||||
authOk: false,
|
||||
hasSharedAuth: false,
|
||||
@@ -297,15 +269,10 @@ describe("ws connect policy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("retired device-auth bypass input does not skip pairing", () => {
|
||||
const bypass = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { dangerouslyDisableDeviceAuth: true },
|
||||
});
|
||||
test("strict control-ui policy does not skip pairing", () => {
|
||||
const strict = authPolicy({ isControlUi: true });
|
||||
|
||||
expectSkipPairing(bypass, "operator", false);
|
||||
expectSkipPairing(bypass, "node", false);
|
||||
expectSkipPairing(strict, "node", false);
|
||||
expectSkipPairing(strict, "operator", false);
|
||||
expectSkipPairing(strict, "operator", false, { pairingComplete: true });
|
||||
});
|
||||
@@ -413,10 +380,7 @@ describe("ws connect policy", () => {
|
||||
|
||||
test("clears unbound scopes for device-less shared auth outside explicit preservation cases", () => {
|
||||
const nonControlUi = authPolicy();
|
||||
const controlUi = authPolicy({
|
||||
isControlUi: true,
|
||||
controlUiConfig: { allowInsecureAuth: true },
|
||||
});
|
||||
const controlUi = authPolicy({ isControlUi: true });
|
||||
|
||||
expectClearsUnboundScopes({ controlUiAuthPolicy: nonControlUi }, true);
|
||||
expectClearsUnboundScopes(
|
||||
|
||||
@@ -11,11 +11,9 @@ type ControlUiAuthPolicy = {
|
||||
|
||||
export function resolveControlUiAuthPolicy(params: {
|
||||
isControlUi: boolean;
|
||||
controlUiConfig: unknown;
|
||||
deviceRaw: ConnectParams["device"] | null | undefined;
|
||||
deviceAuthMigrationPending?: boolean;
|
||||
}): ControlUiAuthPolicy {
|
||||
void params.controlUiConfig;
|
||||
return {
|
||||
isControlUi: params.isControlUi,
|
||||
device: params.deviceRaw,
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("GatewayChatClient operator scopes", () => {
|
||||
const client = new RealGatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
...(token ? { token } : {}),
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
clients.push(client);
|
||||
const socketCount = sockets.length;
|
||||
|
||||
@@ -182,7 +182,6 @@ describe("resolveGatewayConnection", () => {
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
tlsFingerprint: "sha256:selected",
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
expect(loadConfig).not.toHaveBeenCalled();
|
||||
},
|
||||
@@ -220,7 +219,6 @@ describe("resolveGatewayConnection", () => {
|
||||
url: "wss://override.example/ws",
|
||||
...expected,
|
||||
preauthHandshakeTimeoutMs: undefined,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,46 +588,6 @@ describe("resolveGatewayConnection", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps loopback local connections on device-authenticated operator UI", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
},
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveGatewayConnection({});
|
||||
expect(result.allowInsecureLocalOperatorUi).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a loopback URL override on device-authenticated operator UI", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
},
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveGatewayConnection({
|
||||
url: "ws://127.0.0.1:18791",
|
||||
token: "override-token",
|
||||
});
|
||||
expect(result.allowInsecureLocalOperatorUi).toBe(false);
|
||||
expect(result.token).toBe("override-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayChatClient", () => {
|
||||
@@ -641,7 +599,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
let finishStop: (() => void) | undefined;
|
||||
const stopAndWait = vi.fn(
|
||||
@@ -691,10 +648,8 @@ describe("GatewayChatClient", () => {
|
||||
token: "test-token",
|
||||
tlsFingerprint: "sha256:11:22:33:44",
|
||||
preauthHandshakeTimeoutMs: 30_000,
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
|
||||
expect(client.connection.allowInsecureLocalOperatorUi).toBe(true);
|
||||
expect(constructedOptions).toHaveLength(1);
|
||||
expect(constructedOptions[0]).toMatchObject({
|
||||
clientName: "openclaw-tui",
|
||||
@@ -703,8 +658,8 @@ describe("GatewayChatClient", () => {
|
||||
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
|
||||
preauthHandshakeTimeoutMs: 30_000,
|
||||
tlsFingerprint: "sha256:11:22:33:44",
|
||||
deviceIdentity: null,
|
||||
});
|
||||
expect(constructedOptions[0]).not.toHaveProperty("deviceIdentity");
|
||||
const onConnectError = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
client.onConnectError = onConnectError;
|
||||
@@ -761,7 +716,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
client.onDisconnected = onDisconnected;
|
||||
|
||||
@@ -783,7 +737,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
@@ -811,7 +764,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi.fn().mockResolvedValue({ messages: [] });
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
@@ -850,7 +802,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi.fn().mockResolvedValue({ ok: true, aborted: true });
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
@@ -867,7 +818,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
@@ -896,7 +846,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
@@ -933,7 +882,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
@@ -971,7 +919,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi.fn().mockResolvedValue({ runId: "run-gateway", status: "timeout" });
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
@@ -989,7 +936,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const command = {
|
||||
name: "tts",
|
||||
@@ -1016,7 +962,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const pending = [{ id: "plugin:skill-1" }];
|
||||
const request = vi.fn().mockResolvedValueOnce(pending).mockResolvedValueOnce({ ok: true });
|
||||
@@ -1038,7 +983,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const suggestion = {
|
||||
id: "task_1",
|
||||
@@ -1082,7 +1026,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
client.hello = {
|
||||
features: {
|
||||
@@ -1101,7 +1044,6 @@ describe("GatewayChatClient", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi.fn();
|
||||
client.hello = { features: { methods: ["chat.history"] } } as never;
|
||||
|
||||
@@ -67,7 +67,6 @@ type ResolvedGatewayConnection = {
|
||||
password?: string;
|
||||
tlsFingerprint?: string;
|
||||
preauthHandshakeTimeoutMs?: number;
|
||||
allowInsecureLocalOperatorUi: boolean;
|
||||
};
|
||||
|
||||
function throwGatewayAuthResolutionError(reason: string): never {
|
||||
@@ -161,7 +160,6 @@ export class GatewayChatClient implements TuiBackend {
|
||||
platform: process.platform,
|
||||
mode: GATEWAY_CLIENT_MODES.UI,
|
||||
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
|
||||
deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined,
|
||||
caps: [
|
||||
GATEWAY_CLIENT_CAPS.AGENT_KIND,
|
||||
GATEWAY_CLIENT_CAPS.PLUGIN_APPROVALS,
|
||||
@@ -469,7 +467,6 @@ function resolveBoundGatewayConnection(
|
||||
token: explicitAuth.token,
|
||||
password: explicitAuth.password,
|
||||
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -505,7 +502,6 @@ async function resolveGatewayConnection(
|
||||
...(urlOverride ? { url: urlOverride } : {}),
|
||||
...(activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}),
|
||||
}).url;
|
||||
const allowInsecureLocalOperatorUi = false;
|
||||
|
||||
if (urlOverride) {
|
||||
return {
|
||||
@@ -513,7 +509,6 @@ async function resolveGatewayConnection(
|
||||
token: explicitAuth.token,
|
||||
password: explicitAuth.password,
|
||||
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
|
||||
allowInsecureLocalOperatorUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,7 +530,6 @@ async function resolveGatewayConnection(
|
||||
...((opts.tlsFingerprint ?? config.gateway?.remote?.tlsFingerprint)
|
||||
? { tlsFingerprint: opts.tlsFingerprint ?? config.gateway?.remote?.tlsFingerprint }
|
||||
: {}),
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,7 +545,6 @@ async function resolveGatewayConnection(
|
||||
token: resolved.token,
|
||||
password: resolved.password,
|
||||
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
|
||||
allowInsecureLocalOperatorUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -576,6 +569,5 @@ async function resolveGatewayConnection(
|
||||
token: resolved.token,
|
||||
password: resolved.password,
|
||||
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
|
||||
allowInsecureLocalOperatorUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -746,7 +746,6 @@ async function startSharedGatewayFixture(): Promise<SharedGatewayFixture> {
|
||||
controlClient = new GatewayChatClient({
|
||||
url: gateway.url,
|
||||
token: gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
controlClient.onConnected = () => {
|
||||
controlClientConnected = true;
|
||||
@@ -860,7 +859,6 @@ async function startGatewayModeTui(
|
||||
const controlClient = new GatewayChatClient({
|
||||
url: shared.gateway.url,
|
||||
token: shared.gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
let controlClientConnected = false;
|
||||
controlClient.onConnected = () => {
|
||||
@@ -1573,7 +1571,6 @@ export default {
|
||||
eventProbe = new GatewayChatClient({
|
||||
url: fixture.gateway.url,
|
||||
token: fixture.gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
eventProbe.onConnected = () => {
|
||||
probeConnected = true;
|
||||
@@ -1725,7 +1722,6 @@ export default {
|
||||
const historyClient = new GatewayChatClient({
|
||||
url: shared.gateway.url,
|
||||
token: shared.gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
let historyClientConnected = false;
|
||||
historyClient.onConnected = () => {
|
||||
@@ -1809,7 +1805,6 @@ export default {
|
||||
const controlClient = new GatewayChatClient({
|
||||
url: shared.gateway.url,
|
||||
token: shared.gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
let controlClientConnected = false;
|
||||
controlClient.onConnected = () => {
|
||||
@@ -2267,7 +2262,6 @@ export default {
|
||||
const queueClient = new GatewayChatClient({
|
||||
url: fixture.gateway.url,
|
||||
token: fixture.gateway.gatewayToken,
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
try {
|
||||
let queueClientConnected = false;
|
||||
|
||||
@@ -264,7 +264,6 @@ describe("Gateway queued session rotation", () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: instance.url,
|
||||
token: "secret-token",
|
||||
allowInsecureLocalOperatorUi: false,
|
||||
});
|
||||
client.start();
|
||||
await client.waitForReady();
|
||||
|
||||
@@ -437,8 +437,7 @@ export class GatewayBrowserClient {
|
||||
const explicitPassword = this.opts.password?.trim() || undefined;
|
||||
|
||||
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
|
||||
// Over plain HTTP, we skip device identity and fall back to token-only auth.
|
||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||
// Token/password auth cannot replace browser device identity over plain HTTP.
|
||||
const isSecureContext = typeof crypto !== "undefined" && Boolean(crypto.subtle);
|
||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||
let selectedAuth: GatewayConnectAuthSelection = {
|
||||
|
||||
@@ -101,6 +101,22 @@ describe("login gate failure recovery", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only supported recovery for an insecure browser context", async () => {
|
||||
const element = await mountFailure(
|
||||
"device identity required",
|
||||
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||
);
|
||||
|
||||
const steps = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(".login-gate__failure-steps li"),
|
||||
(entry) => entry.textContent?.trim(),
|
||||
);
|
||||
expect(steps).toEqual([
|
||||
"Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.",
|
||||
"Do not use a remote plain-HTTP URL; a token or password cannot replace browser device identity.",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["click", "Enter", " ", "nested button"])(
|
||||
"surfaces denied gateway-command copying from the %s interaction",
|
||||
async (interaction) => {
|
||||
|
||||
@@ -178,11 +178,7 @@ function resolveLoginFailureFeedback(
|
||||
docsHref: "https://docs.openclaw.ai/web/control-ui#insecure-http",
|
||||
titleKey: "login.failure.insecure.title",
|
||||
summaryKey: "login.failure.insecure.summary",
|
||||
stepKeys: [
|
||||
"login.failure.insecure.stepHttps",
|
||||
"login.failure.insecure.stepLocalCompat",
|
||||
"login.failure.insecure.stepAvoidDisable",
|
||||
],
|
||||
stepKeys: ["login.failure.insecure.stepHttps", "login.failure.insecure.stepAvoidDisable"],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4247,9 +4247,8 @@ export const en: TranslationMap = {
|
||||
summary:
|
||||
"This page is running over plain HTTP, so the browser cannot create the device identity the Gateway expects.",
|
||||
stepHttps: "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.",
|
||||
stepLocalCompat:
|
||||
"For local token-only compatibility, set gateway.controlUi.allowInsecureAuth: true.",
|
||||
stepAvoidDisable: "Avoid disabling device auth for remote HTTP access.",
|
||||
stepAvoidDisable:
|
||||
"Do not use a remote plain-HTTP URL; a token or password cannot replace browser device identity.",
|
||||
},
|
||||
origin: {
|
||||
title: "Browser origin not allowed",
|
||||
|
||||
@@ -33,4 +33,13 @@ describe("formatConnectError", () => {
|
||||
}),
|
||||
).toBe("device role upgrade requires approval (approved: operator; requested: node)");
|
||||
});
|
||||
|
||||
it("routes missing browser identity to a supported secure context", () => {
|
||||
expect(
|
||||
formatConnectError({
|
||||
message: "device identity required",
|
||||
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
|
||||
}),
|
||||
).toBe("device identity required (use HTTPS or localhost)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st
|
||||
case ConnectErrorDetailCodes.PAIRING_REQUIRED:
|
||||
return formatPairingRequiredError(error);
|
||||
case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED:
|
||||
return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)";
|
||||
return "device identity required (use HTTPS or localhost)";
|
||||
case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED:
|
||||
return "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
|
||||
case ConnectErrorDetailCodes.AUTH_TOKEN_MISSING:
|
||||
|
||||
Reference in New Issue
Block a user