diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index ddf03ddad6..6df175f653 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -11,6 +11,32 @@ import { } from "./browserObservation.js"; describe("browser observation contracts", () => { + it.each(["127.0.0.1", "[::1]"])( + "preserves explicitly supplied default endpoint ports on %s", + (host) => { + const http = `http://${host}:80`; + expect(browserEndpointSchema.parse(http)).toBe(http); + expect( + browserEndpointSchema.parse(browserEndpointSchema.parse(http)), + ).toBe(http); + expect(recordedBrowserObservationEndpointSchema.parse(http)).toBe(http); + const socket = `ws://${host}:80/devtools/browser/id`; + expect(browserObservationEndpointSchema.parse(socket)).toEqual({ + transport: "browser_websocket", + url: socket, + recorded: `ws://${host}:80/devtools/browser/[REDACTED]`, + }); + for (const value of [ + `http://${host}`, + `ws://${host}/devtools/browser/id`, + "http://192.0.2.1:80", + ]) + expect( + browserObservationEndpointSchema.safeParse(value).success, + value, + ).toBe(false); + }, + ); it("normalizes exact HTTP origins without accepting broader URL scopes", () => { expect(browserOriginSchema.parse("https://Example.COM:443")).toBe( "https://example.com", diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index 691b68c70a..94f6ee2931 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -65,31 +65,45 @@ const HTTP_ENDPOINT_MESSAGE = const BROWSER_SOCKET_PATH = "/devtools/browser/"; const REDACTED_BROWSER_SOCKET_PATH = `${BROWSER_SOCKET_PATH}[REDACTED]`; -/** Normalized origin of an accepted loopback CDP HTTP discovery endpoint. */ -const httpDiscoveryOrigin = (url: URL): string | undefined => +/** Retain an explicit default port, which URL.port and URL.origin erase. */ +const explicitEndpointAuthority = ( + value: string, + url: URL, +): string | undefined => { + const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/?#\\]*)/iu.exec(value)?.[1]; + const port = + authority === undefined ? undefined : /:([0-9]+)$/u.exec(authority)?.[1]; + return port === undefined + ? undefined + : `${url.protocol}//${url.hostname}:${String(Number(port))}`; +}; + +/** Normalized explicit-port origin of a loopback CDP HTTP discovery endpoint. */ +const httpDiscoveryOrigin = (value: string, url: URL): string | undefined => url.protocol === "http:" && isLiteralLoopbackHostname(url.hostname) && - url.port !== "" && url.username === "" && url.password === "" && url.pathname === "/" && !urlDeclaresQueryOrFragment(url) - ? url.origin + ? explicitEndpointAuthority(value, url) : undefined; /** Scheme and authority of an accepted loopback CDP browser WebSocket. */ -const browserSocketAuthority = (url: URL): string | undefined => { +const browserSocketAuthority = ( + value: string, + url: URL, +): string | undefined => { const id = url.pathname.slice(BROWSER_SOCKET_PATH.length); return url.protocol === "ws:" && isLiteralLoopbackHostname(url.hostname) && - url.port !== "" && url.username === "" && url.password === "" && !urlDeclaresQueryOrFragment(url) && url.pathname.startsWith(BROWSER_SOCKET_PATH) && id.length > 0 && !id.includes("/") - ? `${url.protocol}//${url.host}` + ? explicitEndpointAuthority(value, url) : undefined; }; @@ -99,7 +113,7 @@ export const browserEndpointSchema = z .min(1) .transform((value, context) => { const url = URL.parse(value); - const origin = url === null ? undefined : httpDiscoveryOrigin(url); + const origin = url === null ? undefined : httpDiscoveryOrigin(value, url); if (origin !== undefined) return origin; context.addIssue({ code: "custom", @@ -132,14 +146,14 @@ export const browserObservationEndpointSchema = z context.addIssue({ code: "custom", message: "Invalid CDP endpoint URL" }); return z.NEVER; } - const origin = httpDiscoveryOrigin(url); + const origin = httpDiscoveryOrigin(value, url); if (origin !== undefined) return { transport: "http_discovery", url: origin, recorded: origin }; - const authority = browserSocketAuthority(url); + const authority = browserSocketAuthority(value, url); if (authority !== undefined) return { transport: "browser_websocket", - url: url.href, + url: `${authority}${url.pathname}`, recorded: `${authority}${REDACTED_BROWSER_SOCKET_PATH}`, }; context.addIssue({ @@ -161,8 +175,8 @@ export const recordedBrowserObservationEndpointSchema = z const url = URL.parse(value); return ( url !== null && - (httpDiscoveryOrigin(url) !== undefined || - (browserSocketAuthority(url) !== undefined && + (httpDiscoveryOrigin(value, url) !== undefined || + (browserSocketAuthority(value, url) !== undefined && url.pathname === REDACTED_BROWSER_SOCKET_PATH)) ); },