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
This commit is contained in:
xingzhou
2026-07-07 05:35:09 +08:00
committed by GitHub
parent 246b5bbcca
commit e80e8a2b67
2 changed files with 62 additions and 1 deletions

View File

@@ -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({

View File

@@ -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);