diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index 1c9abfefa1..0c4834e249 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -7,6 +7,7 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { safeParseJson } from "../domain/safeJson.js"; const endpointVersionSchema = z.object({ @@ -230,8 +231,7 @@ const safeCdpWebSocketEndpoint = ( parsed.port !== trustedEndpoint.port || parsed.username !== "" || parsed.password !== "" || - parsed.search !== "" || - parsed.hash !== "" || + urlDeclaresQueryOrFragment(parsed) || path === undefined ) throw new BrowserObservationError(operation, "invalid_endpoint_response"); diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index 8270b669db..c5eb732877 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -19,6 +19,8 @@ describe("browser observation contracts", () => { "https://*.example.com", "https://example.com/path", "https://example.com/?token=secret", + "https://example.com/?", + "https://example.com/#", "https://user:pass@example.com", "file:///tmp/page.html", ]) @@ -37,6 +39,8 @@ describe("browser observation contracts", () => { "http://192.168.1.2:9222", "https://127.0.0.1:9222", "http://127.0.0.1:9222/json/list", + "http://127.0.0.1:9222/?", + "http://127.0.0.1:9222/#", "http://user:pass@127.0.0.1:9222", ]) expect(browserEndpointSchema.safeParse(value).success, value).toBe(false); diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index 0a9fc8e32d..f4b8984b69 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { urlDeclaresQueryOrFragment } from "./urlComponents.js"; + const parseExactOrigin = (value: string): string | undefined => { let url: URL; try { @@ -15,8 +17,7 @@ const parseExactOrigin = (value: string): string | undefined => { url.username !== "" || url.password !== "" || url.pathname !== "/" || - url.search !== "" || - url.hash !== "" + urlDeclaresQueryOrFragment(url) ) return undefined; return url.origin; @@ -63,8 +64,7 @@ export const browserEndpointSchema = z url.username !== "" || url.password !== "" || url.pathname !== "/" || - url.search !== "" || - url.hash !== "" + urlDeclaresQueryOrFragment(url) ) { context.addIssue({ code: "custom", diff --git a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts index e60ecb7845..ada1c8048e 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts @@ -6,6 +6,7 @@ import { javascriptRuntimeUnresolvedLocationSchema } from "./javascriptRuntimeOb import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; +import { urlDeclaresQueryOrFragment } from "../urlComponents.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const nodeIdSchema = prefixedDigestSchema("jag_node"); @@ -53,8 +54,7 @@ const normalizeRuntimeUrlPrefix = (value: string): string | null => { !["http:", "https:"].includes(url.protocol) || url.username !== "" || url.password !== "" || - url.search !== "" || - url.hash !== "" + urlDeclaresQueryOrFragment(url) ) return null; if (!url.pathname.endsWith("/")) url.pathname += "/"; diff --git a/src/domain/urlComponents.test.ts b/src/domain/urlComponents.test.ts new file mode 100644 index 0000000000..5c2b522234 --- /dev/null +++ b/src/domain/urlComponents.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { urlDeclaresQueryOrFragment } from "./urlComponents.js"; + +describe("URL query and fragment spelling", () => { + it("treats an explicit empty query or fragment as present", () => { + expect(urlDeclaresQueryOrFragment(new URL("https://a.test/main.js"))).toBe( + false, + ); + expect( + urlDeclaresQueryOrFragment(new URL("https://a.test/%3Ffile.js")), + ).toBe(false); + for (const value of [ + "https://a.test/main.js?", + "https://a.test/main.js#", + "https://a.test/main.js?#", + "https://a.test/main.js?v=1", + "https://a.test/main.js#part", + ]) + expect(urlDeclaresQueryOrFragment(new URL(value)), value).toBe(true); + }); +}); diff --git a/src/domain/urlComponents.ts b/src/domain/urlComponents.ts new file mode 100644 index 0000000000..d4756abd8b --- /dev/null +++ b/src/domain/urlComponents.ts @@ -0,0 +1,7 @@ +/** + * True when the URL spells a query or fragment, including an empty one. + * `URL.search` and `URL.hash` are empty both when that component is absent + * and when it is present but empty, so href is the spelling that keeps them apart. + */ +export const urlDeclaresQueryOrFragment = (url: URL): boolean => + url.href.includes("?") || url.href.includes("#"); diff --git a/src/domain/webScriptExportPlan.ts b/src/domain/webScriptExportPlan.ts index e8b129c930..01115c6b1b 100644 --- a/src/domain/webScriptExportPlan.ts +++ b/src/domain/webScriptExportPlan.ts @@ -4,6 +4,7 @@ import type { CapturedWebScript, ExportedWebScript, } from "./webScriptExport.js"; +import { urlDeclaresQueryOrFragment } from "./urlComponents.js"; const hash = (text: string): string => createHash("sha256").update(text).digest("hex"); @@ -94,9 +95,7 @@ const candidatePath = (script: CapturedWebScript): CandidatePath => { return { path: `origins/${hash(url.origin)}/${parts.join("/")}`, reason: - // URL.search/hash collapse absent and explicitly empty components. - url.href.includes("?") || - url.href.includes("#") || + urlDeclaresQueryOrFragment(url) || url.username !== "" || url.password !== "" ? "URL query, fragment, or credentials cannot select a canonical local module." diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index c6718baf27..53f15e0736 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -6,6 +6,7 @@ import type { } from "../domain/javascript/javascriptRuntimeObservation.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; +import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { parseCdpEndpointValue, readCdpJson } from "../browser/CdpEndpoint.js"; const versionSchema = z @@ -98,8 +99,7 @@ const validatedInspectorWebSocket = ( socket.port !== trusted.port || socket.username !== "" || socket.password !== "" || - socket.search !== "" || - socket.hash !== "" || + urlDeclaresQueryOrFragment(socket) || !allowedPaths.has(socket.pathname) ) throw new BrowserObservationError(operation, "invalid_endpoint_response"); diff --git a/src/jeb/JebMcpEndpoint.test.ts b/src/jeb/JebMcpEndpoint.test.ts new file mode 100644 index 0000000000..0252632d86 --- /dev/null +++ b/src/jeb/JebMcpEndpoint.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { parseJebMcpEndpoint } from "./JebMcpEndpoint.js"; + +describe("JEB MCP endpoint", () => { + it("rejects an explicit empty query or fragment instead of dropping it", () => { + expect(parseJebMcpEndpoint("http://127.0.0.1:8425/mcp").ok).toBe(true); + for (const value of [ + "http://127.0.0.1:8425/mcp?", + "http://127.0.0.1:8425/mcp#", + "http://127.0.0.1:8425/mcp?#", + ]) + expect(parseJebMcpEndpoint(value).ok, value).toBe(false); + }); +}); diff --git a/src/jeb/JebMcpEndpoint.ts b/src/jeb/JebMcpEndpoint.ts index 448d000f8e..bae4a00a9c 100644 --- a/src/jeb/JebMcpEndpoint.ts +++ b/src/jeb/JebMcpEndpoint.ts @@ -1,6 +1,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; +import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; /** Default JEB MCP endpoint documented by the engine's headless client. */ export const DEFAULT_JEB_MCP_ENDPOINT = "http://127.0.0.1:8425/mcp"; @@ -46,7 +47,7 @@ export const parseJebMcpEndpoint = ( "Embedding credentials in the JEB MCP endpoint is not supported; configure authentication with the engine", }), ); - if (url.search !== "" || url.hash !== "") + if (urlDeclaresQueryOrFragment(url)) return err( new AnalysisInputError("jeb_mcp_endpoint", { cause: "JEB MCP endpoint query strings and fragments are not supported",