Skip to content

Commit dc5954b

Browse files
authored
fix(gateway): reject no-auth tailscale exposure
Fixes #50630. Replaces stale PR #50631. Behavior: reject gateway auth mode none when Tailscale Serve or Funnel exposes the gateway, across config validation, install-token preflight, and runtime startup. Proof: - node scripts/run-vitest.mjs src/config/config.gateway-tailscale-bind.test.ts src/gateway/server-runtime-config.test.ts src/commands/doctor-gateway-auth-token.test.ts - .agents/skills/autoreview/scripts/autoreview --mode local - node scripts/crabbox-wrapper.mjs run --shell -- "pnpm check:changed" (run_5a999c1e11c0, exit 0) - GitHub PR checks clean on 0b306e8; prior checkout/diff failures were GitHub infrastructure and cleared after rebase.
1 parent 0477407 commit dc5954b

7 files changed

Lines changed: 143 additions & 1 deletion

src/commands/doctor-gateway-auth-token.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resolveGatewayAuthTokenForService,
77
shouldRequireGatewayTokenForInstall,
88
} from "./doctor-gateway-auth-token.js";
9+
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
910

1011
const envVar = (...parts: string[]) => parts.join("_");
1112

@@ -270,4 +271,21 @@ describe("shouldRequireGatewayTokenForInstall", () => {
270271
);
271272
expect(required).toBe(true);
272273
});
274+
275+
it("blocks install token resolution for tailscale serve with explicit no-auth", async () => {
276+
const resolved = await resolveGatewayInstallToken({
277+
config: {
278+
gateway: {
279+
auth: { mode: "none" },
280+
tailscale: { mode: "serve" },
281+
},
282+
} as OpenClawConfig,
283+
env: {} as NodeJS.ProcessEnv,
284+
});
285+
286+
expect(resolved.token).toBeUndefined();
287+
expect(resolved.unavailableReason).toBe(
288+
"gateway.auth.mode=none cannot be used with gateway.tailscale.mode=serve; configure token, password, or trusted-proxy auth before exposing the gateway through Tailscale",
289+
);
290+
});
273291
});

src/commands/gateway-install-token.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-pol
77
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
88
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
99
import { resolveGatewayAuth } from "../gateway/auth.js";
10+
import {
11+
formatUnsafeGatewayTailscaleNoAuthMessage,
12+
isUnsafeGatewayTailscaleNoAuth,
13+
} from "../shared/gateway-tailscale-auth-policy.js";
1014
import { normalizeOptionalString } from "../shared/string-coerce.js";
1115
import {
1216
readConfigFileSnapshotForWrite,
@@ -133,6 +137,15 @@ export async function resolveGatewayInstallToken(
133137
env: options.env,
134138
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
135139
});
140+
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
141+
if (isUnsafeGatewayTailscaleNoAuth({ authMode: resolvedAuth.mode, tailscaleMode })) {
142+
return {
143+
token: undefined,
144+
tokenRefConfigured: false,
145+
unavailableReason: formatUnsafeGatewayTailscaleNoAuthMessage(tailscaleMode),
146+
warnings,
147+
};
148+
}
136149
const needsToken =
137150
shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale;
138151
if (!needsToken) {

src/config/config.gateway-tailscale-bind.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,55 @@ describe("gateway tailscale bind validation", () => {
2020
expect(funnelRes.ok).toBe(true);
2121
});
2222

23+
it("rejects explicit no-auth when tailscale serve or funnel exposes the gateway", () => {
24+
const serveRes = validateConfigObject({
25+
gateway: {
26+
bind: "loopback",
27+
auth: { mode: "none" },
28+
tailscale: { mode: "serve" },
29+
},
30+
});
31+
expect(serveRes.ok).toBe(false);
32+
if (!serveRes.ok) {
33+
expect(serveRes.issues).toEqual([
34+
{
35+
path: "gateway.auth.mode",
36+
message:
37+
"gateway.auth.mode=none cannot be used with gateway.tailscale.mode=serve; configure token, password, or trusted-proxy auth before exposing the gateway through Tailscale",
38+
},
39+
]);
40+
}
41+
42+
const funnelRes = validateConfigObject({
43+
gateway: {
44+
bind: "loopback",
45+
auth: { mode: "none" },
46+
tailscale: { mode: "funnel" },
47+
},
48+
});
49+
expect(funnelRes.ok).toBe(false);
50+
if (!funnelRes.ok) {
51+
expect(funnelRes.issues).toEqual([
52+
{
53+
path: "gateway.auth.mode",
54+
message:
55+
"gateway.tailscale.mode=funnel requires gateway.auth.mode=password; auth.mode=none cannot be used when exposing the gateway through Tailscale Funnel",
56+
},
57+
]);
58+
}
59+
});
60+
61+
it("allows explicit no-auth for loopback-only gateway config", () => {
62+
const res = validateConfigObject({
63+
gateway: {
64+
bind: "loopback",
65+
auth: { mode: "none" },
66+
tailscale: { mode: "off" },
67+
},
68+
});
69+
expect(res.ok).toBe(true);
70+
});
71+
2372
it("accepts custom loopback bind host with tailscale serve/funnel", () => {
2473
const res = validateConfigObject({
2574
gateway: {

src/config/validation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import {
3131
isPathWithinRoot,
3232
isWindowsAbsolutePath,
3333
} from "../shared/avatar-policy.js";
34+
import {
35+
formatUnsafeGatewayTailscaleNoAuthMessage,
36+
isUnsafeGatewayTailscaleNoAuth,
37+
} from "../shared/gateway-tailscale-auth-policy.js";
3438
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
3539
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
3640
import { isRecord, resolveUserPath } from "../utils.js";
@@ -854,6 +858,19 @@ function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationI
854858
];
855859
}
856860

861+
function validateGatewayTailscaleAuth(config: OpenClawConfig): ConfigValidationIssue[] {
862+
const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
863+
if (!isUnsafeGatewayTailscaleNoAuth({ authMode: config.gateway?.auth?.mode, tailscaleMode })) {
864+
return [];
865+
}
866+
return [
867+
{
868+
path: "gateway.auth.mode",
869+
message: formatUnsafeGatewayTailscaleNoAuthMessage(tailscaleMode),
870+
},
871+
];
872+
}
873+
857874
/**
858875
* Validates config without applying runtime defaults.
859876
* Use this when you need the raw validated config (e.g., for writing back to file).
@@ -914,6 +931,10 @@ export function validateConfigObjectRaw(
914931
if (gatewayTailscaleBindIssues.length > 0) {
915932
return { ok: false, issues: gatewayTailscaleBindIssues };
916933
}
934+
const gatewayTailscaleAuthIssues = validateGatewayTailscaleAuth(validatedConfig);
935+
if (gatewayTailscaleAuthIssues.length > 0) {
936+
return { ok: false, issues: gatewayTailscaleAuthIssues };
937+
}
917938
return {
918939
ok: true,
919940
config: validatedConfig,

src/gateway/server-runtime-config.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,20 @@ describe("resolveGatewayRuntimeConfig", () => {
298298
).rejects.toThrow(/refusing to bind gateway/);
299299
});
300300

301+
it("rejects tailscale serve with explicit no-auth", async () => {
302+
await expect(
303+
resolveGatewayRuntimeConfig({
304+
cfg: {
305+
gateway: {
306+
auth: { mode: "none" },
307+
tailscale: { mode: "serve" },
308+
},
309+
},
310+
port: 18789,
311+
}),
312+
).rejects.toThrow("gateway.auth.mode=none cannot be used with gateway.tailscale.mode=serve");
313+
});
314+
301315
it("respects explicit loopback config even inside a container", async () => {
302316
const fs = require("node:fs");
303317
vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); // /.dockerenv exists
@@ -314,7 +328,7 @@ describe("resolveGatewayRuntimeConfig", () => {
314328
const result = await resolveGatewayRuntimeConfig({
315329
cfg: {
316330
gateway: {
317-
auth: { mode: "none" },
331+
auth: TOKEN_AUTH,
318332
tailscale: { mode: "serve" },
319333
},
320334
},

src/gateway/server-runtime-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import type {
44
GatewayTailscaleConfig,
55
} from "../config/types.gateway.js";
66
import type { OpenClawConfig } from "../config/types.openclaw.js";
7+
import {
8+
formatUnsafeGatewayTailscaleNoAuthMessage,
9+
isUnsafeGatewayTailscaleNoAuth,
10+
} from "../shared/gateway-tailscale-auth-policy.js";
711
import {
812
assertGatewayAuthConfigured,
913
type ResolvedGatewayAuth,
@@ -134,6 +138,9 @@ export async function resolveGatewayRuntimeConfig(params: {
134138
"tailscale funnel requires gateway auth mode=password (set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD)",
135139
);
136140
}
141+
if (isUnsafeGatewayTailscaleNoAuth({ authMode, tailscaleMode })) {
142+
throw new Error(formatUnsafeGatewayTailscaleNoAuthMessage(tailscaleMode));
143+
}
137144
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
138145
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
139146
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { GatewayAuthMode, GatewayTailscaleMode } from "../config/types.gateway.js";
2+
3+
export function isUnsafeGatewayTailscaleNoAuth(params: {
4+
authMode?: GatewayAuthMode;
5+
tailscaleMode?: GatewayTailscaleMode;
6+
}): boolean {
7+
return (
8+
params.authMode === "none" &&
9+
(params.tailscaleMode === "serve" || params.tailscaleMode === "funnel")
10+
);
11+
}
12+
13+
export function formatUnsafeGatewayTailscaleNoAuthMessage(
14+
tailscaleMode: GatewayTailscaleMode,
15+
): string {
16+
if (tailscaleMode === "funnel") {
17+
return "gateway.tailscale.mode=funnel requires gateway.auth.mode=password; auth.mode=none cannot be used when exposing the gateway through Tailscale Funnel";
18+
}
19+
return `gateway.auth.mode=none cannot be used with gateway.tailscale.mode=${tailscaleMode}; configure token, password, or trusted-proxy auth before exposing the gateway through Tailscale`;
20+
}

0 commit comments

Comments
 (0)