feat(gateway): add device auto-approve policy for Tailscale connections

Add gateway.devices.autoApprove config option to auto-approve device
pairing when requests come via Tailscale Serve with verified identity.

- Add "none" (default) and "tailscale" modes for autoApprove policy
- Auto-approve devices when Tailscale auth succeeds (verified via whois)
- Add autoApproved/autoApproveReason fields to device.pair.resolved event
- Add security audit info finding when tailscale mode is enabled
- Add comprehensive tests for config validation and auto-approve logic
- Add documentation in gateway configuration guide

Fixes #3795
This commit is contained in:
Trevin Chow 2026-01-28 22:37:32 -08:00 committed by Trevin Chow
parent da71eaebd2
commit 64bcb16ba3
11 changed files with 341 additions and 1 deletions

View File

@ -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. - 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`. - 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: Notes:
- `openclaw gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `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). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).

View 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);
});
});

View File

@ -102,4 +102,12 @@ describe("config schema", () => {
expect(defaultsHint?.help).toContain("last"); expect(defaultsHint?.help).toContain("last");
expect(listHint?.help).toContain("bluebubbles"); 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");
});
}); });

View File

@ -209,6 +209,7 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.nodes.browser.node": "Gateway Node Browser Pin", "gateway.nodes.browser.node": "Gateway Node Browser Pin",
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
"gateway.nodes.denyCommands": "Gateway Node Denylist", "gateway.nodes.denyCommands": "Gateway Node Denylist",
"gateway.devices.autoApprove": "Device Auto-Approve Policy",
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
"skills.load.watch": "Watch Skills", "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).", "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
"gateway.nodes.denyCommands": "gateway.nodes.denyCommands":
"Commands to block even if present in node claims or default allowlist.", "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.enabled": "Expose the local browser control server via node proxy.",
"nodeHost.browserProxy.allowProfiles": "nodeHost.browserProxy.allowProfiles":
"Optional allowlist of browser profile names exposed via the node proxy.", "Optional allowlist of browser profile names exposed via the node proxy.",

View File

@ -1,5 +1,12 @@
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom" | "tailnet"; 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 = { export type GatewayTlsConfig = {
/** Enable TLS for the gateway server. */ /** Enable TLS for the gateway server. */
enabled?: boolean; enabled?: boolean;
@ -235,6 +242,8 @@ export type GatewayConfig = {
tls?: GatewayTlsConfig; tls?: GatewayTlsConfig;
http?: GatewayHttpConfig; http?: GatewayHttpConfig;
nodes?: GatewayNodesConfig; nodes?: GatewayNodesConfig;
/** Device pairing settings. */
devices?: GatewayDevicesConfig;
/** /**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection * 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 * arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or

View File

@ -443,6 +443,12 @@ export const OpenClawSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
devices: z
.object({
autoApprove: z.enum(["none", "tailscale"]).optional(),
})
.strict()
.optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@ -84,6 +84,8 @@ export const deviceHandlers: GatewayRequestHandlers = {
deviceId: approved.device.deviceId, deviceId: approved.device.deviceId,
decision: "approved", decision: "approved",
ts: Date.now(), ts: Date.now(),
autoApproved: false,
autoApproveReason: null,
}, },
{ dropIfSlow: true }, { dropIfSlow: true },
); );
@ -116,6 +118,8 @@ export const deviceHandlers: GatewayRequestHandlers = {
deviceId: rejected.deviceId, deviceId: rejected.deviceId,
decision: "rejected", decision: "rejected",
ts: Date.now(), ts: Date.now(),
autoApproved: false,
autoApproveReason: null,
}, },
{ dropIfSlow: true }, { dropIfSlow: true },
); );

View 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);
}
});
});
});

View File

@ -623,6 +623,10 @@ export function attachGatewayWsMessageHandler(params: {
const skipPairing = allowControlUiBypass && hasSharedAuth; const skipPairing = allowControlUiBypass && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) { 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 requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({ const pairing = await requestDevicePairing({
deviceId: device.id, deviceId: device.id,
@ -634,7 +638,7 @@ export function attachGatewayWsMessageHandler(params: {
role, role,
scopes, scopes,
remoteIp: reportedClientIp, remoteIp: reportedClientIp,
silent: isLocalClient, silent: shouldAutoApprove,
}); });
const context = buildRequestContext(); const context = buildRequestContext();
if (pairing.request.silent === true) { if (pairing.request.silent === true) {
@ -650,6 +654,8 @@ export function attachGatewayWsMessageHandler(params: {
deviceId: approved.device.deviceId, deviceId: approved.device.deviceId,
decision: "approved", decision: "approved",
ts: Date.now(), ts: Date.now(),
autoApproved: true,
autoApproveReason: isLocalClient ? "local" : "tailscale",
}, },
{ dropIfSlow: true }, { dropIfSlow: true },
); );

View File

@ -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 () => { it("flags logging.redactSensitive=off", async () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
logging: { redactSensitive: "off" }, logging: { redactSensitive: "off" },

View File

@ -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) { if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({ findings.push({
checkId: "gateway.control_ui.insecure_auth", checkId: "gateway.control_ui.insecure_auth",