mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:11:36 +00:00
fix(gateway): require shared auth before device bypass
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
- 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: 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).
|
- 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.
|
- 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.
|
- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
onceMessage,
|
onceMessage,
|
||||||
startGatewayServer,
|
startGatewayServer,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
|
testTailscaleWhois,
|
||||||
testState,
|
testState,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
@@ -35,6 +36,20 @@ const openWs = async (port: number) => {
|
|||||||
return ws;
|
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("gateway server auth/connect", () => {
|
||||||
describe("default auth (token)", () => {
|
describe("default auth (token)", () => {
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
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 () => {
|
test("allows control ui without device identity when insecure auth is enabled", async () => {
|
||||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||||
|
|||||||
@@ -377,8 +377,63 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
|
||||||
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
|
||||||
const device = disableControlUiDeviceAuth ? null : deviceRaw;
|
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) {
|
if (!device) {
|
||||||
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
|
const canSkipDevice = sharedAuthOk;
|
||||||
|
|
||||||
if (isControlUi && !allowControlUiBypass) {
|
if (isControlUi && !allowControlUiBypass) {
|
||||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
@@ -399,8 +454,12 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
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 (!canSkipDevice) {
|
||||||
|
if (!authOk && hasSharedAuth) {
|
||||||
|
rejectUnauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setHandshakeState("failed");
|
setHandshakeState("failed");
|
||||||
setCloseCause("device-required", {
|
setCloseCause("device-required", {
|
||||||
client: connectParams.client.id,
|
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) {
|
if (!authOk && connectParams.auth?.token && device) {
|
||||||
const tokenCheck = await verifyDeviceToken({
|
const tokenCheck = await verifyDeviceToken({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
@@ -589,42 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!authOk) {
|
if (!authOk) {
|
||||||
setHandshakeState("failed");
|
rejectUnauthorized();
|
||||||
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));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipPairing = allowControlUiBypass && hasSharedAuth;
|
const skipPairing = allowControlUiBypass && sharedAuthOk;
|
||||||
if (device && devicePublicKey && !skipPairing) {
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Mock, vi } from "vitest";
|
|||||||
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
|
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
|
||||||
import type { AgentBinding } from "../config/types.agents.js";
|
import type { AgentBinding } from "../config/types.agents.js";
|
||||||
import type { HooksConfig } from "../config/types.hooks.js";
|
import type { HooksConfig } from "../config/types.hooks.js";
|
||||||
|
import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||||
import type { PluginRegistry } from "../plugins/registry.js";
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
@@ -167,6 +168,7 @@ const hoisted = vi.hoisted(() => ({
|
|||||||
waitCalls: [] as string[],
|
waitCalls: [] as string[],
|
||||||
waitResults: new Map<string, boolean>(),
|
waitResults: new Map<string, boolean>(),
|
||||||
},
|
},
|
||||||
|
testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null },
|
||||||
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
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 testTailnetIPv4 = hoisted.testTailnetIPv4;
|
||||||
|
export const testTailscaleWhois = hoisted.testTailscaleWhois;
|
||||||
export const piSdkMock = hoisted.piSdkMock;
|
export const piSdkMock = hoisted.piSdkMock;
|
||||||
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
||||||
export const agentCommand: Mock<() => void> = hoisted.agentCommand;
|
export const agentCommand: Mock<() => void> = hoisted.agentCommand;
|
||||||
@@ -258,6 +261,15 @@ vi.mock("../infra/tailnet.js", () => ({
|
|||||||
pickPrimaryTailnetIPv6: () => undefined,
|
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 () => {
|
vi.mock("../config/sessions.js", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
|
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
sessionStoreSaveDelayMs,
|
sessionStoreSaveDelayMs,
|
||||||
setTestConfigRoot,
|
setTestConfigRoot,
|
||||||
testIsNixMode,
|
testIsNixMode,
|
||||||
|
testTailscaleWhois,
|
||||||
testState,
|
testState,
|
||||||
testTailnetIPv4,
|
testTailnetIPv4,
|
||||||
} from "./test-helpers.mocks.js";
|
} from "./test-helpers.mocks.js";
|
||||||
@@ -109,6 +110,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
|||||||
setTestConfigRoot(tempConfigRoot);
|
setTestConfigRoot(tempConfigRoot);
|
||||||
sessionStoreSaveDelayMs.value = 0;
|
sessionStoreSaveDelayMs.value = 0;
|
||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
|
testTailscaleWhois.value = null;
|
||||||
testState.gatewayBind = undefined;
|
testState.gatewayBind = undefined;
|
||||||
testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
|
testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
|
||||||
testState.gatewayControlUi = undefined;
|
testState.gatewayControlUi = undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user