mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: allow local HTTP ClickClack setup codes (#109429)
This commit is contained in:
@@ -19,10 +19,15 @@ bot using **Setup code (recommended)**, and copy the generated command:
|
||||
openclaw channels add clickclack --code 'https://clickclack.example.com/#XXXX-XXXX-XXXX'
|
||||
```
|
||||
|
||||
The setup code is single-use and expires after 10 minutes. OpenClaw claims it
|
||||
over HTTPS, receives the newly minted bot token and workspace settings, saves
|
||||
the account, verifies the connection, and reports whether the running gateway
|
||||
picked it up. The setup code itself is not stored in OpenClaw config.
|
||||
The setup code is single-use and expires after 10 minutes. OpenClaw claims it,
|
||||
receives the newly minted bot token and workspace settings, saves the account,
|
||||
verifies the connection, and reports whether the running gateway picked it up.
|
||||
The setup code itself is not stored in OpenClaw config.
|
||||
|
||||
Setup-code claims use HTTPS for public servers. Plain HTTP is also supported for
|
||||
local installations on loopback or private networks, including `localhost`,
|
||||
private IP addresses, and internal hostnames that resolve only to private
|
||||
addresses.
|
||||
|
||||
If OpenClaw is already running, ClickClack connects automatically and no second
|
||||
command is needed. Otherwise, start it with:
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { claimClickClackSetupCode } from "./setup-claim.js";
|
||||
|
||||
function createLookupFn(...addresses: string[]): LookupFn {
|
||||
let index = 0;
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
const address = addresses[Math.min(index, addresses.length - 1)];
|
||||
index += 1;
|
||||
if (!address) {
|
||||
throw new Error("missing mocked DNS address");
|
||||
}
|
||||
const result = { address, family: 4 as const };
|
||||
if (typeof options === "object" && options && (options as { all?: boolean }).all) {
|
||||
return [result];
|
||||
}
|
||||
return result;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
function requestBodyJson(init: RequestInit | undefined): unknown {
|
||||
const body = init?.body;
|
||||
if (typeof body !== "string") {
|
||||
@@ -71,20 +90,90 @@ describe("ClickClack setup-code claim", () => {
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("rejects non-HTTPS claims before sending a request", async () => {
|
||||
it("pins the validated private address when claiming over HTTP", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
token: "test-token",
|
||||
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
|
||||
workspace: {
|
||||
id: "wsp_1",
|
||||
route_id: "clickclack",
|
||||
slug: "default",
|
||||
name: "ClickClack",
|
||||
},
|
||||
defaults: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const lookupFn = createLookupFn("127.0.0.1", "93.184.216.34");
|
||||
|
||||
try {
|
||||
await expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
lookupFn,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
token: "test-token",
|
||||
workspace: { id: "wsp_1" },
|
||||
});
|
||||
expect(lookupFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects public HTTP 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");
|
||||
for (const address of ["93.184.216.34", "198.18.0.1"]) {
|
||||
await expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: "http://clickclack.example",
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
lookupFn: createLookupFn(address),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"ClickClack setup codes require HTTPS unless the server is on a private or loopback network.",
|
||||
);
|
||||
}
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds private-host DNS resolution with the claim timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn();
|
||||
const lookupFn = vi.fn(() => new Promise<never>(() => {})) as unknown as LookupFn;
|
||||
|
||||
try {
|
||||
const claim = expect(
|
||||
claimClickClackSetupCode({
|
||||
baseUrl: "http://clickclack.internal",
|
||||
code: "ABCD-EFGH-JKMP",
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
lookupFn,
|
||||
}),
|
||||
).rejects.toThrow("ClickClack setup code claim timed out after 30000ms");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await claim;
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed claim responses", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// ClickClack plugin module claims short-lived setup codes without loading gateway clients.
|
||||
import {
|
||||
createProviderOperationDeadline,
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
isPrivateOrLoopbackHost,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
type LookupFn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { ClickClackSetupCodeClaim } from "./types.js";
|
||||
|
||||
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
@@ -83,8 +89,37 @@ export async function claimClickClackSetupCode(params: {
|
||||
baseUrl: string;
|
||||
code: string;
|
||||
fetch?: typeof fetch;
|
||||
lookupFn?: LookupFn;
|
||||
}): Promise<ClickClackSetupCodeClaim> {
|
||||
const baseUrl = params.baseUrl.replace(/\/+$/, "");
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
const deadline = createProviderOperationDeadline({
|
||||
label: "ClickClack setup code claim",
|
||||
timeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
|
||||
});
|
||||
let pinnedHttpTarget: { hostname: string; addresses: string[] } | undefined;
|
||||
if (parsedBaseUrl.protocol === "http:") {
|
||||
const resolveTimeoutMs = resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
|
||||
});
|
||||
const pinned = await withTimeout(
|
||||
resolvePinnedHostnameWithPolicy(parsedBaseUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: { dangerouslyAllowPrivateNetwork: true },
|
||||
}),
|
||||
resolveTimeoutMs,
|
||||
{
|
||||
message: `ClickClack setup code claim timed out after ${CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS}ms`,
|
||||
},
|
||||
);
|
||||
if (!pinned.addresses.every((address) => isPrivateOrLoopbackHost(address))) {
|
||||
throw new Error(
|
||||
"ClickClack setup codes require HTTPS unless the server is on a private or loopback network.",
|
||||
);
|
||||
}
|
||||
pinnedHttpTarget = { hostname: pinned.hostname, addresses: pinned.addresses };
|
||||
}
|
||||
const url = `${baseUrl}/api/bot-setup-codes/claim`;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
@@ -98,9 +133,16 @@ export async function claimClickClackSetupCode(params: {
|
||||
body: JSON.stringify({ code: params.code }),
|
||||
},
|
||||
maxRedirects: 0,
|
||||
timeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
|
||||
requireHttps: true,
|
||||
timeoutMs: resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: CLICKCLACK_SETUP_CODE_CLAIM_TIMEOUT_MS,
|
||||
}),
|
||||
requireHttps: parsedBaseUrl.protocol === "https:",
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl),
|
||||
lookupFn: params.lookupFn,
|
||||
...(pinnedHttpTarget
|
||||
? { dispatcherPolicy: { mode: "direct", pinnedHostname: pinnedHttpTarget } }
|
||||
: {}),
|
||||
auditContext: "ClickClack setup code claim",
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -131,6 +131,34 @@ describe("ClickClack setup adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts setup-code URLs for local HTTP installations", async () => {
|
||||
claimClickClackSetupCode.mockResolvedValue({
|
||||
token: "test-token",
|
||||
bot: { id: "usr_bot", handle: "openclaw", display_name: "OpenClaw" },
|
||||
workspace: {
|
||||
id: "wsp_1",
|
||||
route_id: "clickclack",
|
||||
slug: "default",
|
||||
name: "ClickClack",
|
||||
},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepare({
|
||||
code: "http://localhost:3000/#abcd-efgh-jkmn",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
baseUrl: "http://localhost:3000",
|
||||
token: "test-token",
|
||||
workspace: "wsp_1",
|
||||
});
|
||||
expect(claimClickClackSetupCode).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:3000",
|
||||
code: "ABCDEFGHJKMN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects conflicting credentials before claiming a setup code", async () => {
|
||||
for (const input of [
|
||||
{ code: "ABCD-EFGH-JKMN", baseUrl: "https://clickclack.example", token: "test-token" },
|
||||
@@ -148,9 +176,9 @@ describe("ClickClack setup adapter", () => {
|
||||
expect(claimClickClackSetupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects insecure, mismatched, and malformed setup-code inputs before claiming", async () => {
|
||||
await expect(prepare({ code: "http://clickclack.example/#ABCD-EFGH-JKMN" })).rejects.toThrow(
|
||||
"ClickClack setup codes require HTTPS.",
|
||||
it("rejects mismatched and malformed setup-code inputs before claiming", async () => {
|
||||
await expect(prepare({ code: "ftp://clickclack.example/#ABCD-EFGH-JKMN" })).rejects.toThrow(
|
||||
"HTTP(S)",
|
||||
);
|
||||
await expect(
|
||||
prepare({
|
||||
@@ -158,9 +186,6 @@ describe("ClickClack setup adapter", () => {
|
||||
baseUrl: "https://other.example",
|
||||
}),
|
||||
).rejects.toThrow("does not match");
|
||||
await expect(
|
||||
prepare({ code: "ABCD-EFGH-JKMN", baseUrl: "http://clickclack.example" }),
|
||||
).rejects.toThrow("require an HTTPS base URL");
|
||||
await expect(
|
||||
prepare({ code: "not-a-code", baseUrl: "https://clickclack.example" }),
|
||||
).rejects.toThrow("12 valid base32 characters");
|
||||
|
||||
@@ -49,10 +49,10 @@ function normalizeClickClackSetupCode(value: string): string | undefined {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireHttpsClickClackBaseUrl(value: string | undefined): string {
|
||||
function requireClickClackSetupCodeBaseUrl(value: string | undefined): string {
|
||||
const baseUrl = normalizeClickClackBaseUrl(value);
|
||||
if (!baseUrl || new URL(baseUrl).protocol !== "https:") {
|
||||
throw new Error("ClickClack setup codes require an HTTPS base URL.");
|
||||
if (!baseUrl) {
|
||||
throw new Error("ClickClack setup codes require a valid HTTP(S) base URL.");
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
@@ -73,10 +73,10 @@ function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string
|
||||
try {
|
||||
setupUrl = new URL(rawCode);
|
||||
} catch {
|
||||
throw new Error("ClickClack --code must be a valid HTTPS setup URL or a bare setup code.");
|
||||
throw new Error("ClickClack --code must be a valid HTTP(S) setup URL or a bare setup code.");
|
||||
}
|
||||
if (setupUrl.protocol !== "https:") {
|
||||
throw new Error("ClickClack setup codes require HTTPS.");
|
||||
if (setupUrl.protocol !== "http:" && setupUrl.protocol !== "https:") {
|
||||
throw new Error("ClickClack setup codes require an HTTP(S) URL.");
|
||||
}
|
||||
if (setupUrl.username || setupUrl.password) {
|
||||
throw new Error("ClickClack setup URLs must not include credentials.");
|
||||
@@ -87,9 +87,9 @@ function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string
|
||||
}
|
||||
setupUrl.hash = "";
|
||||
setupUrl.search = "";
|
||||
baseUrl = requireHttpsClickClackBaseUrl(setupUrl.toString());
|
||||
baseUrl = requireClickClackSetupCodeBaseUrl(setupUrl.toString());
|
||||
if (params.baseUrl) {
|
||||
const suppliedBaseUrl = requireHttpsClickClackBaseUrl(params.baseUrl);
|
||||
const suppliedBaseUrl = requireClickClackSetupCodeBaseUrl(params.baseUrl);
|
||||
if (suppliedBaseUrl !== baseUrl) {
|
||||
throw new Error("ClickClack --base-url does not match the server in the setup-code URL.");
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function parseClickClackSetupCodeInput(params: { code: string; baseUrl?: string
|
||||
if (!params.baseUrl) {
|
||||
throw new Error("A bare ClickClack setup code requires --base-url.");
|
||||
}
|
||||
baseUrl = requireHttpsClickClackBaseUrl(params.baseUrl);
|
||||
baseUrl = requireClickClackSetupCodeBaseUrl(params.baseUrl);
|
||||
}
|
||||
|
||||
const normalizedCode = normalizeClickClackSetupCode(code);
|
||||
|
||||
Reference in New Issue
Block a user