Skip to content

Commit e80e8a2

Browse files
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
1 parent 246b5bb commit e80e8a2

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

extensions/device-pair/index.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,30 @@ describe("device-pair /pair default setup code", () => {
847847
expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789");
848848
});
849849

850+
it.each([
851+
"ws://[fc00::1]:18789",
852+
"ws://[fd7a:115c:a1e0::1]:18789",
853+
"ws://[fe80::1]:18789",
854+
"ws://[febf::1]:18789",
855+
])("allows IPv6 ULA and link-local cleartext setup url %s", async (publicUrl) => {
856+
const command = registerPairCommand({
857+
pluginConfig: {
858+
publicUrl,
859+
},
860+
});
861+
const result = await command.handler(
862+
createCommandContext({
863+
channel: "webchat",
864+
args: "",
865+
commandBody: "/pair",
866+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
867+
}),
868+
);
869+
870+
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
871+
expect(requireText(result)).toContain(`Gateway: ${publicUrl}`);
872+
});
873+
850874
it("allows mdns cleartext setup urls", async () => {
851875
const command = registerPairCommand({
852876
pluginConfig: {
@@ -1015,6 +1039,30 @@ describe("device-pair /pair default setup code", () => {
10151039
expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve");
10161040
});
10171041

1042+
it.each(["ws://[2001:db8::1]:18789", "ws://[fe7f::1]:18789", "ws://[fec0::1]:18789"])(
1043+
"rejects non-LAN IPv6 cleartext setup url %s before issuing setup codes",
1044+
async (publicUrl) => {
1045+
const command = registerPairCommand({
1046+
pluginConfig: {
1047+
publicUrl,
1048+
},
1049+
});
1050+
const result = await command.handler(
1051+
createCommandContext({
1052+
channel: "webchat",
1053+
args: "",
1054+
commandBody: "/pair",
1055+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
1056+
}),
1057+
);
1058+
1059+
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
1060+
expect(requireText(result)).toContain(
1061+
"Tailscale and public mobile pairing require a secure gateway URL",
1062+
);
1063+
},
1064+
);
1065+
10181066
it("uses Tailscale Serve MagicDNS as a secure setup url", async () => {
10191067
vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net");
10201068
const command = registerPairCommand({

extensions/device-pair/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Device Pair plugin entrypoint registers its OpenClaw integration.
22
import { rm } from "node:fs/promises";
3+
import { isIP } from "node:net";
34
import os from "node:os";
45
import path from "node:path";
56
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
@@ -242,12 +243,24 @@ function isPrivateIPv4(address: string): boolean {
242243
return false;
243244
}
244245

246+
function isPrivateLanIPv6(address: string): boolean {
247+
if (isIP(address) !== 6) {
248+
return false;
249+
}
250+
const firstHextet = address.split(":", 1)[0] ?? "";
251+
if (!/^[0-9a-f]{4}$/.test(firstHextet)) {
252+
return false;
253+
}
254+
const value = Number.parseInt(firstHextet, 16);
255+
return (value & 0xfe00) === 0xfc00 || (value & 0xffc0) === 0xfe80;
256+
}
257+
245258
function isPrivateLanCleartextHost(host: string): boolean {
246259
const normalized = normalizeHostForIpCheck(host);
247260
if (normalized.endsWith(".local")) {
248261
return true;
249262
}
250-
if (isPrivateIPv4(normalized)) {
263+
if (isPrivateIPv4(normalized) || isPrivateLanIPv6(normalized)) {
251264
return true;
252265
}
253266
const octets = parseIPv4Octets(normalized);

0 commit comments

Comments
 (0)