fix(ios): harden gateway pairing setup

Harden iOS gateway setup-code pairing by rejecting non-loopback plaintext ws:// setup URLs before bootstrap token issuance, consolidating iOS setup parsing, and adding QR scan support from Settings.

Verification:
- pnpm test extensions/device-pair/index.test.ts
- swift test --package-path apps/shared/OpenClawKit --filter DeepLinksSecurityTests
- XcodeBuildMCP OpenClawLogicTests/DeepLinkParserTests
- targeted SwiftLint for touched iOS/OpenClawKit files
- pnpm exec oxfmt --check --threads=1 extensions/device-pair/index.ts extensions/device-pair/index.test.ts
- git diff --check origin/main...HEAD
- GitHub PR checks green on 58e5e60a5c
This commit is contained in:
Val Alexander
2026-05-04 02:11:47 -05:00
committed by GitHub
parent 5fe8cde28f
commit b2efd19648
15 changed files with 556 additions and 188 deletions

View File

@@ -56,7 +56,12 @@ vi.mock("./notify.js", () => ({
registerPairingNotifierService: vi.fn(),
}));
import { approveDevicePairing, listDevicePairing } from "./api.js";
import {
approveDevicePairing,
listDevicePairing,
resolveGatewayBindUrl,
resolveTailnetHostWithRunner,
} from "./api.js";
import registerDevicePair from "./index.js";
type ListedPendingPairingRequest = Awaited<ReturnType<typeof listDevicePairing>>["pending"][number];
@@ -87,7 +92,7 @@ function createApi(params?: {
},
},
pluginConfig: {
publicUrl: "ws://51.79.175.165:18789",
publicUrl: "wss://gateway.example.test",
...params?.pluginConfig,
},
runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"],
@@ -611,8 +616,17 @@ describe("device-pair /pair default setup code", () => {
});
});
it("normalizes bare publicUrl host ports before issuing setup codes", async () => {
it("normalizes secure bare publicUrl host ports before issuing setup codes", async () => {
const command = registerPairCommand({
config: {
gateway: {
tls: { enabled: true },
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: "gateway.example.test:18789/setup",
},
@@ -628,7 +642,131 @@ describe("device-pair /pair default setup code", () => {
const text = requireText(result);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(text).toContain("Gateway: ws://gateway.example.test:18789");
expect(text).toContain("Gateway: wss://gateway.example.test:18789");
});
it("allows loopback cleartext setup urls", async () => {
const command = registerPairCommand({
pluginConfig: {
publicUrl: "ws://127.0.0.1:18789",
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const text = requireText(result);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(text).toContain("Gateway: ws://127.0.0.1:18789");
});
it("rejects private LAN cleartext setup urls before issuing setup codes", async () => {
const command = registerPairCommand({
pluginConfig: {
publicUrl: "ws://192.168.1.20:18789",
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(requireText(result)).toContain(
"Mobile pairing over non-loopback networks requires a secure gateway URL",
);
});
it("rejects public cleartext setup urls before issuing setup codes", async () => {
const command = registerPairCommand({
pluginConfig: {
publicUrl: "ws://gateway.example.test:18789",
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(requireText(result)).toContain(
"Mobile pairing over non-loopback networks requires a secure gateway URL",
);
});
it("rejects tailnet cleartext setup urls before issuing setup codes", async () => {
vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({
url: "ws://100.64.0.9:18789",
source: "gateway.bind=tailnet",
});
const command = registerPairCommand({
config: {
gateway: {
bind: "tailnet",
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: undefined,
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve");
});
it("uses Tailscale Serve MagicDNS as a secure setup url", async () => {
vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net");
const command = registerPairCommand({
config: {
gateway: {
tailscale: { mode: "serve" },
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: undefined,
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const text = requireText(result);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(text).toContain("Gateway: wss://gateway.tailnet.ts.net");
});
it("rejects invalid bare publicUrl host ports", async () => {

View File

@@ -172,6 +172,47 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
}
}
function describeSecureMobilePairingFix(source?: string): string {
const sourceNote = source ? ` Resolved source: ${source}.` : "";
return (
"Mobile pairing over non-loopback networks requires a secure gateway URL (wss://) or Tailscale Serve/Funnel." +
sourceNote +
" Fix: prefer gateway.tailscale.mode=serve, or set " +
"gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " +
"ws:// setup codes are only valid for localhost/loopback or the Android emulator."
);
}
function normalizeHostForIpCheck(host: string): string {
let normalized = normalizeLowercaseStringOrEmpty(host);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (normalized.endsWith(".")) {
normalized = normalized.slice(0, -1);
}
const zoneIndex = normalized.indexOf("%");
if (zoneIndex >= 0) {
normalized = normalized.slice(0, zoneIndex);
}
return normalized;
}
function isLoopbackHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
if (!normalized) {
return false;
}
if (normalized === "localhost" || normalized === "0.0.0.0" || normalized === "::") {
return true;
}
const octets = parseIPv4Octets(normalized);
if (octets) {
return octets[0] === 127;
}
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },
@@ -187,6 +228,9 @@ function parseIPv4Octets(address: string): [number, number, number, number] | nu
if (parts.length !== 4) {
return null;
}
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return null;
@@ -221,6 +265,29 @@ function isTailnetIPv4(address: string): boolean {
return a === 100 && b >= 64 && b <= 127;
}
function isMobilePairingCleartextAllowedHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
return isLoopbackHost(normalized) || normalized === "10.0.2.2";
}
function validateMobilePairingUrl(url: string, source?: string): string | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "Resolved mobile pairing URL is invalid.";
}
const protocol =
parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol;
if (protocol === "wss:") {
return null;
}
if (protocol !== "ws:" || isMobilePairingCleartextAllowedHost(parsed.hostname)) {
return null;
}
return describeSecureMobilePairingFix(source);
}
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
@@ -362,6 +429,18 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
};
}
async function resolveMobilePairingGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const result = await resolveGatewayUrl(api);
if (!result.url) {
return result;
}
const mobilePairingUrlError = validateMobilePairingUrl(result.url, result.source);
if (mobilePairingUrlError) {
return { error: mobilePairingUrlError };
}
return result;
}
function encodeSetupCode(payload: SetupPayload): string {
const json = JSON.stringify(payload);
const base64 = Buffer.from(json, "utf8").toString("base64");
@@ -668,7 +747,7 @@ export default definePluginEntry({
return buildMissingPairingScopeReply();
}
const urlResult = await resolveGatewayUrl(api);
const urlResult = await resolveMobilePairingGatewayUrl(api);
if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
}