fix: block no-auth managed gateway LAN installs (#98022)

* fix: block no-auth managed gateway LAN installs

* test(gateway): cover managed install bind resolution

Co-authored-by: luyifan <al3060388206@gmail.com>

* test(gateway): drop unrelated env cleanup

* test(gateway): use synthetic token fixture

* fix(gateway): reject dynamic tailnet no-auth installs

Co-authored-by: luyifan <al3060388206@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ooiuuii
2026-07-17 09:45:05 +08:00
committed by GitHub
parent 44b01a1e5a
commit a82c72904a
2 changed files with 208 additions and 0 deletions
+149
View File
@@ -30,6 +30,7 @@ const resolveGatewayAuthMock = vi.hoisted(() =>
allowTailscale: false,
})),
);
const resolveGatewayBindHostMock = vi.hoisted(() => vi.fn(async () => "127.0.0.1"));
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
const createInstallPlanFixture = vi.hoisted(() => {
@@ -119,6 +120,14 @@ vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: resolveGatewayAuthMock,
}));
vi.mock("../../gateway/net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../gateway/net.js")>();
return {
...actual,
resolveGatewayBindHost: resolveGatewayBindHostMock,
};
});
vi.mock("../../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
@@ -267,6 +276,7 @@ describe("runDaemonInstall", () => {
resolveIsNixModeMock.mockReset();
resolveSecretInputRefMock.mockReset();
resolveGatewayAuthMock.mockReset();
resolveGatewayBindHostMock.mockReset();
resolveSecretRefValuesMock.mockReset();
randomTokenMock.mockReset();
buildGatewayInstallPlanMock.mockReset();
@@ -298,6 +308,7 @@ describe("runDaemonInstall", () => {
password: undefined,
allowTailscale: false,
});
resolveGatewayBindHostMock.mockResolvedValue("127.0.0.1");
resolveSecretRefValuesMock.mockResolvedValue(new Map());
randomTokenMock.mockReturnValue("generated-token");
buildGatewayInstallPlanMock.mockImplementation(createInstallPlanFixture);
@@ -495,6 +506,144 @@ describe("runDaemonInstall", () => {
});
});
it("blocks managed install when explicit no-auth would bind to LAN", async () => {
const config = {
gateway: {
mode: "local",
bind: "lan",
auth: {
mode: "none",
token: "test-token",
},
},
};
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config,
sourceConfig: config,
});
resolveGatewayAuthMock.mockReturnValue({
mode: "none",
token: "test-token",
password: undefined,
allowTailscale: false,
});
resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0");
await runDaemonInstall({ json: true });
expect(actionState.failed[0]?.message).toContain("Gateway install blocked");
expect(actionState.failed[0]?.message).toContain("gateway.bind=lan");
expect(actionState.failed[0]?.message).toContain("gateway.auth.mode=none");
expect(actionState.failed[0]?.message).toContain("openclaw config set gateway.auth.mode token");
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
});
it.each([
{
name: "custom bind resolving to a network interface",
bind: "custom" as const,
customBindHost: "192.168.1.20",
resolvedHost: "192.168.1.20",
blocked: true,
message: undefined,
},
{
name: "tailnet bind resolving to a tailnet interface",
bind: "tailnet" as const,
customBindHost: undefined,
resolvedHost: "100.64.0.20",
blocked: true,
message: undefined,
},
{
name: "tailnet bind falling back to loopback",
bind: "tailnet" as const,
customBindHost: undefined,
resolvedHost: "127.0.0.1",
blocked: true,
message: "can later resolve to a Tailnet interface",
},
{
name: "loopback bind",
bind: "loopback" as const,
customBindHost: undefined,
resolvedHost: "127.0.0.1",
blocked: false,
message: undefined,
},
])("handles explicit no-auth for $name", async (testCase) => {
const config = {
gateway: {
mode: "local" as const,
bind: testCase.bind,
customBindHost: testCase.customBindHost,
auth: { mode: "none" as const },
},
};
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config,
sourceConfig: config,
});
resolveGatewayAuthMock.mockReturnValue({
mode: "none",
token: undefined,
password: undefined,
allowTailscale: false,
});
resolveGatewayBindHostMock.mockResolvedValue(testCase.resolvedHost);
await runDaemonInstall({ json: true });
expect(resolveGatewayBindHostMock).toHaveBeenCalledWith(testCase.bind, testCase.customBindHost);
if (testCase.blocked) {
expect(actionState.failed[0]?.message).toContain(`gateway.bind=${testCase.bind}`);
if (testCase.message) {
expect(actionState.failed[0]?.message).toContain(testCase.message);
}
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
} else {
expect(actionState.failed).toStrictEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
}
});
it("allows a managed LAN install with trusted-proxy auth", async () => {
const config = {
gateway: {
mode: "local" as const,
bind: "lan" as const,
trustedProxies: ["127.0.0.1"],
auth: { mode: "trusted-proxy" as const },
},
};
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config,
sourceConfig: config,
});
resolveGatewayAuthMock.mockReturnValue({
mode: "trusted-proxy",
token: undefined,
password: undefined,
allowTailscale: false,
});
resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0");
await runDaemonInstall({ json: true });
expect(actionState.failed).toStrictEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
});
it("does not persist gateway mode when runtime validation fails", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
+59
View File
@@ -12,12 +12,19 @@ import { resolveFutureConfigActionBlock } from "../../config/future-version-guar
import { readConfigFileSnapshotForWrite } from "../../config/io.js";
import { replaceConfigFile } from "../../config/mutate.js";
import { resolveGatewayPort } from "../../config/paths.js";
import type { GatewayBindMode } from "../../config/types.gateway.js";
import type { OpenClawConfig } from "../../config/types.js";
import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js";
import { readEmbeddedGatewayToken } from "../../daemon/service-audit.js";
import { resolveGatewayService } from "../../daemon/service.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service.js";
import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import {
defaultGatewayBindMode,
isLoopbackHost,
resolveGatewayBindHost,
} from "../../gateway/net.js";
import {
formatExternalSupervisorActionRequired,
isGatewayExternallySupervised,
@@ -38,6 +45,46 @@ import {
} from "./shared.js";
import type { DaemonInstallOptions } from "./types.js";
function resolveGatewayInstallBindMode(cfg: OpenClawConfig): GatewayBindMode {
return cfg.gateway?.bind ?? defaultGatewayBindMode(cfg.gateway?.tailscale?.mode ?? "off");
}
function formatNoAuthNonLoopbackInstallBlock(params: {
bind: GatewayBindMode;
bindHost: string;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): string | undefined {
const auth = resolveGatewayAuth({
authConfig: params.config.gateway?.auth,
env: params.env,
tailscaleMode: params.config.gateway?.tailscale?.mode ?? "off",
});
const bindCanExposeNetwork = params.bind === "tailnet" || !isLoopbackHost(params.bindHost);
if (auth.mode !== "none" || !bindCanExposeNetwork) {
return undefined;
}
const bindReason =
params.bind === "tailnet" && isLoopbackHost(params.bindHost)
? `gateway.bind=tailnet currently resolves to ${params.bindHost} but can later resolve to a Tailnet interface`
: `gateway.bind=${params.bind} resolves to ${params.bindHost}`;
const hints: string[] = [`${bindReason}, but gateway.auth.mode=none disables Gateway auth.`];
if (normalizeOptionalString(auth.token)) {
hints.push(
`This config already has gateway.auth.token; run ${formatCliCommand("openclaw config set gateway.auth.mode token")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`,
);
} else if (normalizeOptionalString(auth.password)) {
hints.push(
`This config already has gateway.auth.password; run ${formatCliCommand("openclaw config set gateway.auth.mode password")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`,
);
} else {
hints.push(
`Configure token/password auth, use trusted-proxy auth, or set ${formatCliCommand("openclaw config set gateway.bind loopback")} before installing the managed service.`,
);
}
return hints.join(" ");
}
/** Merge safe existing service environment into the current install invocation environment. */
export function mergeInstallInvocationEnv(params: {
env: NodeJS.ProcessEnv;
@@ -205,6 +252,18 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
return;
}
}
const installBind = resolveGatewayInstallBindMode(cfg);
const installBindHost = await resolveGatewayBindHost(installBind, cfg.gateway?.customBindHost);
const noAuthNonLoopbackBlock = formatNoAuthNonLoopbackInstallBlock({
bind: installBind,
bindHost: installBindHost,
config: cfg,
env: installEnv,
});
if (noAuthNonLoopbackBlock) {
fail(`Gateway install blocked: ${noAuthNonLoopbackBlock}`);
return;
}
if (loaded) {
if (!opts.force) {
const autoRefreshMessage = await getGatewayServiceAutoRefreshMessage({