Merge 64bcb16ba3 into 09be5d45d5
This commit is contained in:
commit
b99d97e00b
@ -2861,6 +2861,14 @@ Trusted proxies:
|
||||
- When a connection comes from one of these IPs, OpenClaw uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
|
||||
|
||||
Device auto-approve:
|
||||
- `gateway.devices.autoApprove` controls whether new device pairing requests are auto-approved.
|
||||
- Valid values: `"none"` (default) or `"tailscale"`.
|
||||
- `"none"`: only local (loopback) connections are auto-approved; remote connections require manual approval.
|
||||
- `"tailscale"`: auto-approve when the connection authenticates via Tailscale Serve identity (verified via `tailscale whois`).
|
||||
- This is useful for personal tailnets where you trust all machines on your network.
|
||||
- For shared or corporate tailnets, consider keeping `"none"` and approving devices manually.
|
||||
|
||||
Notes:
|
||||
- `openclaw gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
|
||||
83
src/config/config.gateway-devices-autoapprove.test.ts
Normal file
83
src/config/config.gateway-devices-autoapprove.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MoltbotSchema } from "./zod-schema.js";
|
||||
|
||||
describe("gateway.devices.autoApprove config", () => {
|
||||
it("accepts valid autoApprove=none", () => {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.gateway?.devices?.autoApprove).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid autoApprove=tailscale", () => {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: "tailscale",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.gateway?.devices?.autoApprove).toBe("tailscale");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts omitted autoApprove (defaults to none)", () => {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
devices: {},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.gateway?.devices?.autoApprove).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts omitted devices config entirely", () => {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
port: 18789,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.gateway?.devices).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid autoApprove values", () => {
|
||||
const invalidValues = ["all", "always", "local", "open", "", "TAILSCALE", "Tailscale"];
|
||||
for (const value of invalidValues) {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success, `should reject autoApprove=${value}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects extra properties in devices config", () => {
|
||||
const result = MoltbotSchema.safeParse({
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: "none",
|
||||
unknownProperty: "value",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -102,4 +102,12 @@ describe("config schema", () => {
|
||||
expect(defaultsHint?.help).toContain("last");
|
||||
expect(listHint?.help).toContain("bluebubbles");
|
||||
});
|
||||
|
||||
it("includes gateway.devices.autoApprove ui hints", () => {
|
||||
const res = buildConfigSchema();
|
||||
const autoApproveHint = res.uiHints["gateway.devices.autoApprove"];
|
||||
expect(autoApproveHint?.label).toBe("Device Auto-Approve Policy");
|
||||
expect(autoApproveHint?.help).toContain("none");
|
||||
expect(autoApproveHint?.help).toContain("tailscale");
|
||||
});
|
||||
});
|
||||
|
||||
@ -209,6 +209,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
|
||||
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
||||
"gateway.devices.autoApprove": "Device Auto-Approve Policy",
|
||||
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
|
||||
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
|
||||
"skills.load.watch": "Watch Skills",
|
||||
@ -399,6 +400,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
|
||||
"gateway.nodes.denyCommands":
|
||||
"Commands to block even if present in node claims or default allowlist.",
|
||||
"gateway.devices.autoApprove":
|
||||
'Auto-approve policy for new device pairing requests ("none" or "tailscale").',
|
||||
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
|
||||
"nodeHost.browserProxy.allowProfiles":
|
||||
"Optional allowlist of browser profile names exposed via the node proxy.",
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
||||
|
||||
export type GatewayDeviceAutoApproveMode = "none" | "tailscale";
|
||||
|
||||
export type GatewayDevicesConfig = {
|
||||
/** Auto-approve policy for new device pairing requests. */
|
||||
autoApprove?: GatewayDeviceAutoApproveMode;
|
||||
};
|
||||
|
||||
export type GatewayTlsConfig = {
|
||||
/** Enable TLS for the gateway server. */
|
||||
enabled?: boolean;
|
||||
@ -235,6 +242,8 @@ export type GatewayConfig = {
|
||||
tls?: GatewayTlsConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
nodes?: GatewayNodesConfig;
|
||||
/** Device pairing settings. */
|
||||
devices?: GatewayDevicesConfig;
|
||||
/**
|
||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||
|
||||
@ -443,6 +443,12 @@ export const OpenClawSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
devices: z
|
||||
.object({
|
||||
autoApprove: z.enum(["none", "tailscale"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@ -84,6 +84,8 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
deviceId: approved.device.deviceId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
autoApproved: false,
|
||||
autoApproveReason: null,
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
@ -116,6 +118,8 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
deviceId: rejected.deviceId,
|
||||
decision: "rejected",
|
||||
ts: Date.now(),
|
||||
autoApproved: false,
|
||||
autoApproveReason: null,
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
152
src/gateway/server/ws-connection/device-auto-approve.test.ts
Normal file
152
src/gateway/server/ws-connection/device-auto-approve.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GatewayDeviceAutoApproveMode } from "../../../config/types.gateway.js";
|
||||
|
||||
/**
|
||||
* Replicates the shouldAutoApprove logic from message-handler.ts for testing.
|
||||
* This must match the implementation in message-handler.ts exactly.
|
||||
*/
|
||||
function computeShouldAutoApprove(params: {
|
||||
isLocalClient: boolean;
|
||||
deviceAutoApprove: GatewayDeviceAutoApproveMode;
|
||||
authMethod: string;
|
||||
}): boolean {
|
||||
const { isLocalClient, deviceAutoApprove, authMethod } = params;
|
||||
return isLocalClient || (deviceAutoApprove === "tailscale" && authMethod === "tailscale");
|
||||
}
|
||||
|
||||
describe("device auto-approve logic", () => {
|
||||
describe("local client", () => {
|
||||
it("auto-approves local clients regardless of config", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: true,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-approves local clients even with tailscale config", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: true,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config=none (default)", () => {
|
||||
it("does NOT auto-approve remote clients with token auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with password auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod: "password",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with tailscale auth when config is none", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod: "tailscale",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with device-token auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod: "device-token",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config=tailscale", () => {
|
||||
it("auto-approves remote clients with tailscale auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod: "tailscale",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with token auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod: "token",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with password auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod: "password",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT auto-approve remote clients with device-token auth", () => {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod: "device-token",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security invariants", () => {
|
||||
it("never auto-approves non-tailscale remote auth when config is tailscale", () => {
|
||||
const nonTailscaleAuthMethods = ["token", "password", "device-token", "unknown", ""];
|
||||
for (const authMethod of nonTailscaleAuthMethods) {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "tailscale",
|
||||
authMethod,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("never auto-approves any remote auth when config is none", () => {
|
||||
const allAuthMethods = ["token", "password", "device-token", "tailscale", "unknown", ""];
|
||||
for (const authMethod of allAuthMethods) {
|
||||
expect(
|
||||
computeShouldAutoApprove({
|
||||
isLocalClient: false,
|
||||
deviceAutoApprove: "none",
|
||||
authMethod,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -623,6 +623,10 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
||||
if (device && devicePublicKey && !skipPairing) {
|
||||
const deviceAutoApprove = configSnapshot.gateway?.devices?.autoApprove ?? "none";
|
||||
const shouldAutoApprove =
|
||||
isLocalClient || (deviceAutoApprove === "tailscale" && authMethod === "tailscale");
|
||||
|
||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
@ -634,7 +638,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: reportedClientIp,
|
||||
silent: isLocalClient,
|
||||
silent: shouldAutoApprove,
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
@ -650,6 +654,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceId: approved.device.deviceId,
|
||||
decision: "approved",
|
||||
ts: Date.now(),
|
||||
autoApproved: true,
|
||||
autoApproveReason: isLocalClient ? "local" : "tailscale",
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
@ -104,6 +104,55 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits info when device auto-approve is set to tailscale", async () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: "tailscale",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.devices.auto_approve_tailscale",
|
||||
severity: "info",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT emit device auto-approve finding when set to none", async () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
gateway: {
|
||||
devices: {
|
||||
autoApprove: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.devices.auto_approve_tailscale",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags logging.redactSensitive=off", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
logging: { redactSensitive: "off" },
|
||||
|
||||
@ -320,6 +320,18 @@ function collectGatewayConfigFindings(
|
||||
});
|
||||
}
|
||||
|
||||
const deviceAutoApprove = cfg.gateway?.devices?.autoApprove ?? "none";
|
||||
if (deviceAutoApprove === "tailscale") {
|
||||
findings.push({
|
||||
checkId: "gateway.devices.auto_approve_tailscale",
|
||||
severity: "info",
|
||||
title: "Device auto-approve via Tailscale enabled",
|
||||
detail:
|
||||
'gateway.devices.autoApprove="tailscale" auto-approves device pairing for Tailscale-authenticated connections. ' +
|
||||
'Safe for personal tailnets; for shared/corporate tailnets, consider keeping autoApprove="none".',
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
||||
findings.push({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user