From 2dd931c8bdd3714b1c958688b3a3d9f9659fb8fd Mon Sep 17 00:00:00 2001 From: QTom <22166516+DaydreamCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:06:12 +0800 Subject: [PATCH] fix(app): accept host:port and URLs in the direct connection host field (#8223) The direct connection form treated any host containing a colon as a bare IPv6 address, so entering "100.66.69.36:6767" or "tcp://host:6767" built "tcp://[100.66.69.36:6767]:6767" and surfaced the browser's raw "Invalid URL" error. Normalize the host input first: strip a leading scheme and any path/query/fragment, split "host:port" (the inline port wins), and only treat unbracketed hosts with two or more colons as IPv6. Co-authored-by: Claude Opus 5.5 --- .../app/src/components/add-host-modal.tsx | 37 ++-------- .../components/direct-connection-host.test.ts | 68 ++++++++++++++++++ .../src/components/direct-connection-host.ts | 70 +++++++++++++++++++ 3 files changed, 142 insertions(+), 33 deletions(-) create mode 100644 packages/app/src/components/direct-connection-host.test.ts create mode 100644 packages/app/src/components/direct-connection-host.ts diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 653d69c4f1..aa0c65b638 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -6,15 +6,12 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { - parseConnectionUri, - serializeConnectionUri, - serializeConnectionUriForStorage, -} from "@/utils/daemon-endpoints"; +import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; import { buildConnectionFailureCopy } from "./connection-failure-copy"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { PairingTargetTracker } from "./pair-link-credentials"; +import { buildDirectConnectionUri } from "./direct-connection-host"; const FLEX_ONE_STYLE = { flex: 1 } as const; @@ -139,37 +136,11 @@ const styles = StyleSheet.create((theme) => ({ }, })); -function isIpv6Host(host: string): boolean { - return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); -} - -function buildConnectionUriFromDraft( - draft: DirectConnectionDraft, - labels: DirectConnectionLabels, -): string { - const host = draft.host.trim(); - const port = Number(draft.port.trim()); - if (!host) { - throw new Error(labels.hostRequired); - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(labels.invalidPort); - } - - return serializeConnectionUriForStorage({ - host, - port, - isIpv6: isIpv6Host(host), - useTls: draft.useTls, - ...(draft.password ? { password: draft.password } : {}), - }); -} - function prepareDirectConnection( draft: DirectConnectionDraft, labels: DirectConnectionLabels, ): PreparedDirectConnection { - const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels)); + const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels)); const endpoint = parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`; @@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod advancedTarget.current = new PairingTargetTracker("", true); try { setAdvancedUri( - buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels), + buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels), ); } catch { setAdvancedUri(""); diff --git a/packages/app/src/components/direct-connection-host.test.ts b/packages/app/src/components/direct-connection-host.test.ts new file mode 100644 index 0000000000..0a2411215a --- /dev/null +++ b/packages/app/src/components/direct-connection-host.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host"; + +const labels = { + hostRequired: "host required", + invalidPort: "invalid port", +}; + +function buildUri(host: string, port = "6767"): string { + return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels); +} + +describe("normalizeDirectConnectionHost", () => { + it.each([ + ["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }], + [" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }], + ["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }], + ["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }], + ["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }], + ["[::1]", { host: "::1", port: null, isIpv6: true }], + ["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }], + ["::1", { host: "::1", port: null, isIpv6: true }], + ["fe80::1", { host: "fe80::1", port: null, isIpv6: true }], + ["", { host: "", port: null, isIpv6: false }], + ])("normalizes %j", (input, expected) => { + expect(normalizeDirectConnectionHost(input)).toEqual(expected); + }); +}); + +describe("buildDirectConnectionUri", () => { + it.each([ + ["100.66.69.36", "tcp://100.66.69.36:6767"], + ["100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"], + ["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"], + ["my-host.local:6767", "tcp://my-host.local:6767"], + ["[::1]", "tcp://[::1]:6767"], + ["[::1]:7000", "tcp://[::1]:7000"], + ["::1", "tcp://[::1]:6767"], + ["fe80::1", "tcp://[fe80::1]:6767"], + ])("builds a tcp URI from host %j", (host, expected) => { + expect(buildUri(host)).toBe(expected); + }); + + it("lets a port typed in the host field override the port field", () => { + expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000"); + }); + + it("keeps TLS and password settings", () => { + expect( + buildDirectConnectionUri( + { host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" }, + labels, + ), + ).toBe("tcp://example.test:443?ssl=true&password=secret"); + }); + + it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => { + expect(() => buildUri(host)).toThrow("host required"); + }); + + it("rejects an invalid port", () => { + expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port"); + expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port"); + }); +}); diff --git a/packages/app/src/components/direct-connection-host.ts b/packages/app/src/components/direct-connection-host.ts new file mode 100644 index 0000000000..db694dd7c2 --- /dev/null +++ b/packages/app/src/components/direct-connection-host.ts @@ -0,0 +1,70 @@ +import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints"; + +export interface DirectConnectionHostInput { + host: string; + port: string; + useTls: boolean; + password: string; +} + +export interface DirectConnectionHostLabels { + hostRequired: string; + invalidPort: string; +} + +export interface NormalizedDirectConnectionHost { + host: string; + /** Port typed into the host field, if any. Overrides the separate port field. */ + port: string | null; + isIpv6: boolean; +} + +const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; +const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/; +const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/; + +/** + * Users often paste `host:port` or a full URL into the host field. + * Reduce it to a bare host (plus an optional port) before building the URI. + */ +export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost { + let host = input.trim().replace(SCHEME_PATTERN, ""); + const suffixIndex = host.search(/[/?#]/); + if (suffixIndex !== -1) { + host = host.slice(0, suffixIndex); + } + + const bracketed = BRACKETED_IPV6_PATTERN.exec(host); + if (bracketed) { + return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true }; + } + + const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host); + if (hostWithPort) { + return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false }; + } + + return { host, port: null, isIpv6: host.split(":").length > 2 }; +} + +export function buildDirectConnectionUri( + draft: DirectConnectionHostInput, + labels: DirectConnectionHostLabels, +): string { + const normalized = normalizeDirectConnectionHost(draft.host); + const port = Number((normalized.port ?? draft.port).trim()); + if (!normalized.host) { + throw new Error(labels.hostRequired); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(labels.invalidPort); + } + + return serializeConnectionUriForStorage({ + host: normalized.host, + port, + isIpv6: normalized.isIpv6, + useTls: draft.useTls, + ...(draft.password ? { password: draft.password } : {}), + }); +}