fix: allow local HTTP ClickClack setup codes (#109429)

This commit is contained in:
Shakker
2026-07-17 01:00:18 +01:00
committed by Shakker
parent 9899121b61
commit 2f23642719
5 changed files with 190 additions and 29 deletions
+9 -4
View File
@@ -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:
+97 -8
View File
@@ -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({
+44 -2
View File
@@ -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 {
+31 -6
View File
@@ -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");
+9 -9
View File
@@ -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);