From fae681747da04c1a95c1dcaa56f9da8371034792 Mon Sep 17 00:00:00 2001 From: DevBot Date: Sat, 21 Feb 2026 08:56:37 +0000 Subject: [PATCH 1/3] fix: handle default ports in gateway URL validation JavaScript's URL API omits .port for standard ports (443 for wss:, 80 for ws:) even when explicitly specified. This caused valid URLs like wss://host.ts.net:443 to fail validation with 'Gateway URL must include an explicit port.' Fix by checking default ports when url.port is empty. Closes #148 --- frontend/src/lib/gateway-form.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/gateway-form.ts b/frontend/src/lib/gateway-form.ts index 7832c03e..e247f39e 100644 --- a/frontend/src/lib/gateway-form.ts +++ b/frontend/src/lib/gateway-form.ts @@ -12,7 +12,10 @@ export const validateGatewayUrl = (value: string) => { if (url.protocol !== "ws:" && url.protocol !== "wss:") { return "Gateway URL must start with ws:// or wss://."; } - if (!url.port) { + // url.port is empty for default ports (80 for ws:, 443 for wss:) — allow those + const defaultPorts: Record = { "ws:": "80", "wss:": "443" }; + const effectivePort = url.port || defaultPorts[url.protocol] || ""; + if (!effectivePort) { return "Gateway URL must include an explicit port."; } return null; From c13915bbdf3e7a10ce1662957e6125fe1768a3be Mon Sep 17 00:00:00 2001 From: DevBot Date: Sat, 21 Feb 2026 16:25:12 +0000 Subject: [PATCH 2/3] fix: detect explicit port from URL string; add Vitest tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix fell back to the scheme's default port (443/80) when url.port was empty, but url.port is empty for *both* 'wss://host:443' and 'wss://host' — causing the validation to wrongly accept a URL with no port at all. Fix: inspect the raw authority segment of the URL string to check whether a ':port' component is actually present, regardless of whether that port is the scheme default. Add gateway-form.test.ts covering: - explicit non-default ports (accepted) - explicit default ports :443 / :80 (accepted — regression case) - missing port (rejected) - wrong scheme (rejected) - invalid URL (rejected) - whitespace trimming Closes #148 --- frontend/src/lib/gateway-form.test.ts | 64 ++++++++++++++++++++++++--- frontend/src/lib/gateway-form.ts | 29 ++++++++++-- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts index e2803eca..63fa4a59 100644 --- a/frontend/src/lib/gateway-form.test.ts +++ b/frontend/src/lib/gateway-form.test.ts @@ -13,14 +13,66 @@ const mockedGatewaysStatusApiV1GatewaysStatusGet = vi.mocked( ); describe("validateGatewayUrl", () => { - it("requires ws/wss with an explicit port", () => { - expect(validateGatewayUrl("https://gateway.example")).toBe( - "Gateway URL must start with ws:// or wss://.", - ); - expect(validateGatewayUrl("ws://gateway.example")).toBe( + it("accepts ws:// with explicit non-default port", () => { + expect(validateGatewayUrl("ws://localhost:18789")).toBeNull(); + }); + + it("accepts wss:// with explicit non-default port", () => { + expect(validateGatewayUrl("wss://gateway.example.com:8443")).toBeNull(); + }); + + it("accepts wss:// with explicit default port 443", () => { + expect(validateGatewayUrl("wss://devbot.tailcc2080.ts.net:443")).toBeNull(); + }); + + it("accepts ws:// with explicit default port 80", () => { + expect(validateGatewayUrl("ws://localhost:80")).toBeNull(); + }); + + it("accepts URLs with a path after the port", () => { + expect(validateGatewayUrl("wss://host.example.com:443/gateway")).toBeNull(); + }); + + it("trims surrounding whitespace before validating", () => { + expect(validateGatewayUrl(" wss://host:443 ")).toBeNull(); + }); + + it("rejects empty string", () => { + expect(validateGatewayUrl("")).toBe("Gateway URL is required."); + }); + + it("rejects wss:// with no port at all", () => { + expect(validateGatewayUrl("wss://gateway.example.com")).toBe( "Gateway URL must include an explicit port.", ); - expect(validateGatewayUrl("ws://gateway.example:18789")).toBeNull(); + }); + + it("rejects ws:// with no port at all", () => { + expect(validateGatewayUrl("ws://localhost")).toBe( + "Gateway URL must include an explicit port.", + ); + }); + + it("rejects https:// scheme", () => { + expect(validateGatewayUrl("https://gateway.example.com:443")).toBe( + "Gateway URL must start with ws:// or wss://.", + ); + }); + + it("rejects http:// scheme", () => { + expect(validateGatewayUrl("http://localhost:8080")).toBe( + "Gateway URL must start with ws:// or wss://.", + ); + }); + + it("rejects completely invalid URL", () => { + expect(validateGatewayUrl("not-a-url")).toBe( + "Enter a valid gateway URL including port.", + ); + }); + + it("rejects URL with only whitespace", () => { + expect(validateGatewayUrl(" ")).toBe("Gateway URL is required."); }); }); diff --git a/frontend/src/lib/gateway-form.ts b/frontend/src/lib/gateway-form.ts index e247f39e..e3a48846 100644 --- a/frontend/src/lib/gateway-form.ts +++ b/frontend/src/lib/gateway-form.ts @@ -4,6 +4,30 @@ export const DEFAULT_WORKSPACE_ROOT = "~/.openclaw"; export type GatewayCheckStatus = "idle" | "checking" | "success" | "error"; +/** + * Returns true only when the URL string contains an explicit ":port" segment. + * + * JavaScript's URL API sets `.port` to "" for *both* an omitted port and a + * port that equals the scheme's default (e.g. 443 for wss:). We therefore + * inspect the raw host+port token from the URL string instead. + */ +function hasExplicitPort(urlString: string): boolean { + try { + const { hostname } = new URL(urlString); + // Extract the authority portion (between // and the first / ? or #) + const withoutScheme = urlString.slice(urlString.indexOf("//") + 2); + const authority = withoutScheme.split(/[/?#]/)[0]; + // authority is either "host", "host:port", or "[ipv6]:port" + // Remove a leading IPv6 bracket group before checking for ":" + const withoutIPv6 = authority.startsWith("[") + ? authority.slice(authority.indexOf("]") + 1) + : authority.slice(hostname.length); + return withoutIPv6.startsWith(":") && /^:\d+$/.test(withoutIPv6); + } catch { + return false; + } +} + export const validateGatewayUrl = (value: string) => { const trimmed = value.trim(); if (!trimmed) return "Gateway URL is required."; @@ -12,10 +36,7 @@ export const validateGatewayUrl = (value: string) => { if (url.protocol !== "ws:" && url.protocol !== "wss:") { return "Gateway URL must start with ws:// or wss://."; } - // url.port is empty for default ports (80 for ws:, 443 for wss:) — allow those - const defaultPorts: Record = { "ws:": "80", "wss:": "443" }; - const effectivePort = url.port || defaultPorts[url.protocol] || ""; - if (!effectivePort) { + if (!hasExplicitPort(trimmed)) { return "Gateway URL must include an explicit port."; } return null; From 2e36630df45d7924452577b06ec7d2232b0fd3de Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 23 Feb 2026 02:00:07 +0530 Subject: [PATCH 3/3] fix: address PR #149 review comments --- frontend/src/lib/gateway-form.test.ts | 28 +++++++++++++++++++ frontend/src/lib/gateway-form.ts | 40 ++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts index 63fa4a59..80164d5e 100644 --- a/frontend/src/lib/gateway-form.test.ts +++ b/frontend/src/lib/gateway-form.test.ts @@ -37,6 +37,22 @@ describe("validateGatewayUrl", () => { expect(validateGatewayUrl(" wss://host:443 ")).toBeNull(); }); + it("accepts IPv6 URLs with explicit non-default port", () => { + expect(validateGatewayUrl("wss://[::1]:8080")).toBeNull(); + }); + + it("accepts IPv6 URLs with explicit default port", () => { + expect(validateGatewayUrl("wss://[2001:db8::1]:443")).toBeNull(); + }); + + it("accepts userinfo URLs with explicit port", () => { + expect(validateGatewayUrl("ws://user:pass@gateway.example.com:8080")).toBeNull(); + }); + + it("accepts userinfo URLs with IPv6 host and explicit port", () => { + expect(validateGatewayUrl("wss://user@[::1]:443")).toBeNull(); + }); + it("rejects empty string", () => { expect(validateGatewayUrl("")).toBe("Gateway URL is required."); }); @@ -71,6 +87,18 @@ describe("validateGatewayUrl", () => { ); }); + it("rejects out-of-range ports", () => { + expect(validateGatewayUrl("wss://gateway.example.com:65536")).toBe( + "Enter a valid gateway URL including port.", + ); + }); + + it("rejects userinfo URLs with no explicit port", () => { + expect(validateGatewayUrl("ws://user:pass@gateway.example.com")).toBe( + "Gateway URL must include an explicit port.", + ); + }); + it("rejects URL with only whitespace", () => { expect(validateGatewayUrl(" ")).toBe("Gateway URL is required."); }); diff --git a/frontend/src/lib/gateway-form.ts b/frontend/src/lib/gateway-form.ts index e3a48846..d63f37ea 100644 --- a/frontend/src/lib/gateway-form.ts +++ b/frontend/src/lib/gateway-form.ts @@ -13,16 +13,42 @@ export type GatewayCheckStatus = "idle" | "checking" | "success" | "error"; */ function hasExplicitPort(urlString: string): boolean { try { - const { hostname } = new URL(urlString); // Extract the authority portion (between // and the first / ? or #) const withoutScheme = urlString.slice(urlString.indexOf("//") + 2); const authority = withoutScheme.split(/[/?#]/)[0]; - // authority is either "host", "host:port", or "[ipv6]:port" - // Remove a leading IPv6 bracket group before checking for ":" - const withoutIPv6 = authority.startsWith("[") - ? authority.slice(authority.indexOf("]") + 1) - : authority.slice(hostname.length); - return withoutIPv6.startsWith(":") && /^:\d+$/.test(withoutIPv6); + if (!authority) { + return false; + } + + // authority may be: + // - host[:port] + // - [ipv6][:port] + // - userinfo@host[:port] + // - userinfo@[ipv6][:port] + const atIndex = authority.lastIndexOf("@"); + const hostPort = atIndex === -1 ? authority : authority.slice(atIndex + 1); + + let portSegment = ""; + if (hostPort.startsWith("[")) { + const closingBracketIndex = hostPort.indexOf("]"); + if (closingBracketIndex === -1) { + return false; + } + portSegment = hostPort.slice(closingBracketIndex + 1); + } else { + const lastColonIndex = hostPort.lastIndexOf(":"); + if (lastColonIndex === -1) { + return false; + } + portSegment = hostPort.slice(lastColonIndex); + } + + if (!portSegment.startsWith(":") || !/^:\d+$/.test(portSegment)) { + return false; + } + + const port = Number.parseInt(portSegment.slice(1), 10); + return Number.isInteger(port) && port >= 0 && port <= 65535; } catch { return false; }