mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:10:52 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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."}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user