fix(gateway): require shared auth before device bypass

This commit is contained in:
Peter Steinberger
2026-02-02 16:55:53 -08:00
parent d1ecb46076
commit fe81b1d712
5 changed files with 131 additions and 44 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.

View File

@@ -11,6 +11,7 @@ import {
onceMessage,
startGatewayServer,
startServerWithClient,
testTailscaleWhois,
testState,
} from "./test-helpers.js";
@@ -35,6 +36,20 @@ const openWs = async (port: number) => {
return ws;
};
const openTailscaleWs = async (port: number) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: {
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "gateway.tailnet.ts.net",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
});
await new Promise<void>((resolve) => ws.once("open", resolve));
return ws;
};
describe("gateway server auth/connect", () => {
describe("default auth (token)", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
@@ -279,6 +294,44 @@ describe("gateway server auth/connect", () => {
});
});
describe("tailscale auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
beforeAll(async () => {
testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true };
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
});
beforeEach(() => {
testTailscaleWhois.value = { login: "peter", name: "Peter" };
});
afterEach(() => {
testTailscaleWhois.value = null;
});
test("requires device identity when only tailscale auth is available", async () => {
const ws = await openTailscaleWs(port);
const res = await connectReq(ws, { token: "dummy", device: null });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("device identity required");
ws.close();
});
test("allows shared token to skip device when tailscale auth is enabled", async () => {
const ws = await openTailscaleWs(port);
const res = await connectReq(ws, { token: "secret", device: null });
expect(res.ok).toBe(true);
ws.close();
});
});
test("allows control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret");

View File

@@ -377,8 +377,63 @@ export function attachGatewayWsMessageHandler(params: {
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
const device = disableControlUiDeviceAuth ? null : deviceRaw;
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
const sharedAuthResult = hasSharedAuth
? await authorizeGatewayConnect({
auth: { ...resolvedAuth, allowTailscale: false },
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
})
: null;
const sharedAuthOk =
sharedAuthResult?.ok === true &&
(sharedAuthResult.method === "token" || sharedAuthResult.method === "password");
const rejectUnauthorized = () => {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
);
const authProvided: AuthProvidedKind = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: authResult.reason,
client: connectParams.client,
});
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
});
close(1008, truncateCloseReason(authMessage));
};
if (!device) {
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
const canSkipDevice = sharedAuthOk;
if (isControlUi && !allowControlUiBypass) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
@@ -399,8 +454,12 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
// Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity
if (!canSkipDevice) {
if (!authOk && hasSharedAuth) {
rejectUnauthorized();
return;
}
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
@@ -567,15 +626,6 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
@@ -589,42 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (!authOk) {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
);
const authProvided: AuthProvidedKind = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: authResult.reason,
client: connectParams.client,
});
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
});
close(1008, truncateCloseReason(authMessage));
rejectUnauthorized();
return;
}
const skipPairing = allowControlUiBypass && hasSharedAuth;
const skipPairing = allowControlUiBypass && sharedAuthOk;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({

View File

@@ -7,6 +7,7 @@ import { Mock, vi } from "vitest";
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
import type { AgentBinding } from "../config/types.agents.js";
import type { HooksConfig } from "../config/types.hooks.js";
import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
@@ -167,6 +168,7 @@ const hoisted = vi.hoisted(() => ({
waitCalls: [] as string[],
waitResults: new Map<string, boolean>(),
},
testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null },
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
@@ -196,6 +198,7 @@ export const setTestConfigRoot = (root: string) => {
};
export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const testTailscaleWhois = hoisted.testTailscaleWhois;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand: Mock<() => void> = hoisted.agentCommand;
@@ -258,6 +261,15 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv6: () => undefined,
}));
vi.mock("../infra/tailscale.js", async () => {
const actual =
await vi.importActual<typeof import("../infra/tailscale.js")>("../infra/tailscale.js");
return {
...actual,
readTailscaleWhoisIdentity: async () => testTailscaleWhois.value,
};
});
vi.mock("../config/sessions.js", async () => {
const actual =
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");

View File

@@ -28,6 +28,7 @@ import {
sessionStoreSaveDelayMs,
setTestConfigRoot,
testIsNixMode,
testTailscaleWhois,
testState,
testTailnetIPv4,
} from "./test-helpers.mocks.js";
@@ -109,6 +110,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
setTestConfigRoot(tempConfigRoot);
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testTailscaleWhois.value = null;
testState.gatewayBind = undefined;
testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
testState.gatewayControlUi = undefined;