mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat: add secure ClickClack setup code claim client
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { createClickClackClient, normalizeClickClackCorrelationId } from "./http-client.js";
|
||||
import {
|
||||
claimClickClackSetupCode,
|
||||
createClickClackClient,
|
||||
normalizeClickClackCorrelationId,
|
||||
} from "./http-client.js";
|
||||
|
||||
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
|
||||
const CLICKCLACK_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -125,6 +129,100 @@ function streamedErrorResponse(body: string, limit: number) {
|
||||
}
|
||||
|
||||
describe("ClickClack HTTP client", () => {
|
||||
it("claims setup codes over guarded HTTPS without bearer authentication", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
token: "ccb_claimed",
|
||||
bot: {
|
||||
id: "usr_bot",
|
||||
handle: "openclaw",
|
||||
display_name: "OpenClaw",
|
||||
},
|
||||
workspace: {
|
||||
id: "wsp_1",
|
||||
route_id: "clickclack",
|
||||
slug: "default",
|
||||
name: "ClickClack",
|
||||
},
|
||||
defaults: {
|
||||
defaultTo: "channel:general",
|
||||
allowFrom: ["*"],
|
||||
agentActivity: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: "https://clickclack.example",
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "ccb_claimed",
|
||||
bot: {
|
||||
id: "usr_bot",
|
||||
handle: "openclaw",
|
||||
display_name: "OpenClaw",
|
||||
},
|
||||
workspace: {
|
||||
id: "wsp_1",
|
||||
route_id: "clickclack",
|
||||
slug: "default",
|
||||
name: "ClickClack",
|
||||
},
|
||||
defaults: {
|
||||
defaultTo: "channel:general",
|
||||
allowFrom: ["*"],
|
||||
agentActivity: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://clickclack.example/api/bot-setup-codes/claim",
|
||||
);
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(init).toMatchObject({ method: "POST", redirect: "manual" });
|
||||
expect(requestBodyJson(init)).toEqual({ code: "ABCD-EFGH-JKMP" });
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("Authorization")).toBeNull();
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("rejects non-HTTPS setup-code claims before sending a request", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
await expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: "http://clickclack.example",
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("URL must use https");
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed setup-code claim responses", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
token: "ccb_claimed",
|
||||
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
|
||||
workspace: { id: "wsp_1", route_id: "clickclack", slug: "default" },
|
||||
defaults: {},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: "https://clickclack.example",
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("invalid workspace.name");
|
||||
});
|
||||
|
||||
it("replaces the authenticated bot command menu", async () => {
|
||||
const botCommand = {
|
||||
id: "botcmd_1",
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { WebSocket } from "ws";
|
||||
import type {
|
||||
ClickClackBotCommand,
|
||||
@@ -13,6 +17,7 @@ import type {
|
||||
ClickClackEvent,
|
||||
ClickClackMessage,
|
||||
ClickClackMessageProvenance,
|
||||
ClickClackSetupCodeClaim,
|
||||
ClickClackUser,
|
||||
ClickClackWorkspace,
|
||||
} from "./types.js";
|
||||
@@ -58,6 +63,8 @@ type ClientOptions = {
|
||||
};
|
||||
|
||||
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
const CLICKCLACK_SETUP_CODE_CLAIM_JSON_LIMIT_BYTES = 64 * 1024;
|
||||
const CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS = 30_000;
|
||||
const CLICKCLACK_CORRELATION_ID_MAX_LENGTH = 128;
|
||||
const CLICKCLACK_CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:-]+$/u;
|
||||
const CLICKCLACK_CORRELATION_ID_HEADER = "X-Correlation-ID";
|
||||
@@ -80,6 +87,110 @@ class ClickClackHttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ClickClackSetupCodeClaimError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
detail: string,
|
||||
) {
|
||||
super(`ClickClack setup code claim failed (${status}): ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`ClickClack setup code claim returned invalid ${label}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireString(record: Record<string, unknown>, key: string, label: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`ClickClack setup code claim returned invalid ${label}.${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseClickClackSetupCodeClaim(value: unknown): ClickClackSetupCodeClaim {
|
||||
const claim = requireRecord(value, "response");
|
||||
const bot = requireRecord(claim.bot, "bot");
|
||||
const workspace = requireRecord(claim.workspace, "workspace");
|
||||
const defaults = requireRecord(claim.defaults, "defaults");
|
||||
const defaultTo = defaults.defaultTo;
|
||||
const allowFrom = defaults.allowFrom;
|
||||
const agentActivity = defaults.agentActivity;
|
||||
if (defaultTo !== undefined && typeof defaultTo !== "string") {
|
||||
throw new Error("ClickClack setup code claim returned invalid defaults.defaultTo");
|
||||
}
|
||||
if (
|
||||
allowFrom !== undefined &&
|
||||
(!Array.isArray(allowFrom) || !allowFrom.every((entry) => typeof entry === "string"))
|
||||
) {
|
||||
throw new Error("ClickClack setup code claim returned invalid defaults.allowFrom");
|
||||
}
|
||||
if (agentActivity !== undefined && typeof agentActivity !== "boolean") {
|
||||
throw new Error("ClickClack setup code claim returned invalid defaults.agentActivity");
|
||||
}
|
||||
return {
|
||||
token: requireString(claim, "token", "response"),
|
||||
bot: {
|
||||
id: requireString(bot, "id", "bot"),
|
||||
handle: requireString(bot, "handle", "bot"),
|
||||
display_name: requireString(bot, "display_name", "bot"),
|
||||
},
|
||||
workspace: {
|
||||
id: requireString(workspace, "id", "workspace"),
|
||||
route_id: requireString(workspace, "route_id", "workspace"),
|
||||
slug: requireString(workspace, "slug", "workspace"),
|
||||
name: requireString(workspace, "name", "workspace"),
|
||||
},
|
||||
defaults: {
|
||||
...(defaultTo !== undefined ? { defaultTo } : {}),
|
||||
...(allowFrom !== undefined ? { allowFrom } : {}),
|
||||
...(agentActivity !== undefined ? { agentActivity } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Claims a one-time setup code without sending any existing bot credential. */
|
||||
export async function claimClickClackSetupCode(params: {
|
||||
baseUrl: string;
|
||||
code: string;
|
||||
fetch?: typeof fetch;
|
||||
}): Promise<ClickClackSetupCodeClaim> {
|
||||
const baseUrl = params.baseUrl.replace(/\/+$/, "");
|
||||
const url = `${baseUrl}/api/bot-setup-codes/claim`;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
fetchImpl: params.fetch,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code: params.code }),
|
||||
},
|
||||
maxRedirects: 0,
|
||||
timeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
|
||||
requireHttps: true,
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
||||
auditContext: "ClickClack setup code claim",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
|
||||
throw new ClickClackSetupCodeClaimError(response.status, detail);
|
||||
}
|
||||
const value = await readProviderJsonResponse<unknown>(response, "ClickClack setup code claim", {
|
||||
maxBytes: CLICKCLACK_SETUP_CODE_CLAIM_JSON_LIMIT_BYTES,
|
||||
});
|
||||
return parseClickClackSetupCodeClaim(value);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts the same bounded request-correlation shape as the ClickClack API. */
|
||||
export function normalizeClickClackCorrelationId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
|
||||
@@ -87,6 +87,27 @@ export type ClickClackBotCommand = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** One-time bot token and installer context returned by setup-code claim. */
|
||||
export type ClickClackSetupCodeClaim = {
|
||||
token: string;
|
||||
bot: {
|
||||
id: string;
|
||||
handle: string;
|
||||
display_name: string;
|
||||
};
|
||||
workspace: {
|
||||
id: string;
|
||||
route_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
defaults: {
|
||||
defaultTo?: string;
|
||||
allowFrom?: string[];
|
||||
agentActivity?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/** Workspace object returned by the ClickClack API. */
|
||||
export type ClickClackWorkspace = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user