mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user