From e80e8a2b67307c4e83e7192f234c31522b3464b2 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Tue, 7 Jul 2026 05:35:09 +0800 Subject: [PATCH] fix(device-pair): mobile pairing rejects local IPv6 cleartext URLs (#101008) * fix(device-pair): allow local IPv6 pairing URLs * test(device-pair): cover IPv6 cleartext lower bound --- extensions/device-pair/index.test.ts | 48 ++++++++++++++++++++++++++++ extensions/device-pair/index.ts | 15 ++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index d297fab7aaf2..0d993a47a925 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -847,6 +847,30 @@ describe("device-pair /pair default setup code", () => { expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789"); }); + it.each([ + "ws://[fc00::1]:18789", + "ws://[fd7a:115c:a1e0::1]:18789", + "ws://[fe80::1]:18789", + "ws://[febf::1]:18789", + ])("allows IPv6 ULA and link-local cleartext setup url %s", async (publicUrl) => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(requireText(result)).toContain(`Gateway: ${publicUrl}`); + }); + it("allows mdns cleartext setup urls", async () => { const command = registerPairCommand({ pluginConfig: { @@ -1015,6 +1039,30 @@ describe("device-pair /pair default setup code", () => { expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve"); }); + it.each(["ws://[2001:db8::1]:18789", "ws://[fe7f::1]:18789", "ws://[fec0::1]:18789"])( + "rejects non-LAN IPv6 cleartext setup url %s before issuing setup codes", + async (publicUrl) => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(requireText(result)).toContain( + "Tailscale and public mobile pairing require a secure gateway URL", + ); + }, + ); + it("uses Tailscale Serve MagicDNS as a secure setup url", async () => { vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net"); const command = registerPairCommand({ diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 2011c96e1c46..e58773d86d5f 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,5 +1,6 @@ // Device Pair plugin entrypoint registers its OpenClaw integration. import { rm } from "node:fs/promises"; +import { isIP } from "node:net"; import os from "node:os"; import path from "node:path"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -242,12 +243,24 @@ function isPrivateIPv4(address: string): boolean { return false; } +function isPrivateLanIPv6(address: string): boolean { + if (isIP(address) !== 6) { + return false; + } + const firstHextet = address.split(":", 1)[0] ?? ""; + if (!/^[0-9a-f]{4}$/.test(firstHextet)) { + return false; + } + const value = Number.parseInt(firstHextet, 16); + return (value & 0xfe00) === 0xfc00 || (value & 0xffc0) === 0xfe80; +} + function isPrivateLanCleartextHost(host: string): boolean { const normalized = normalizeHostForIpCheck(host); if (normalized.endsWith(".local")) { return true; } - if (isPrivateIPv4(normalized)) { + if (isPrivateIPv4(normalized) || isPrivateLanIPv6(normalized)) { return true; } const octets = parseIPv4Octets(normalized);