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.
|
||||
- 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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user