diff --git a/jsonrpc/utils/src/url.ts b/jsonrpc/utils/src/url.ts index 2ae81c41..edff28f2 100644 --- a/jsonrpc/utils/src/url.ts +++ b/jsonrpc/utils/src/url.ts @@ -23,5 +23,19 @@ export function isWsUrl(url: string): boolean { } export function isLocalhostUrl(url: string): boolean { - return new RegExp("wss?://localhost(:d{2,5})?").test(url); + // Used to relax TLS verification for local websockets only. Must match an + // exact loopback host — an unanchored "localhost" prefix would treat + // wss://localhost.evil.example as local and disable certificate checks. + if (!isWsUrl(url)) return false; + try { + const { hostname } = new URL(url); + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "::1" + ); + } catch { + return false; + } } diff --git a/jsonrpc/utils/test/url.test.ts b/jsonrpc/utils/test/url.test.ts new file mode 100644 index 00000000..4b37c08a --- /dev/null +++ b/jsonrpc/utils/test/url.test.ts @@ -0,0 +1,27 @@ +import "mocha"; +import * as chai from "chai"; + +import { isLocalhostUrl } from "../src/url"; + +describe("URL", () => { + describe("isLocalhostUrl", () => { + it("accepts exact loopback websocket hosts", () => { + chai.expect(isLocalhostUrl("ws://localhost")).to.be.true; + chai.expect(isLocalhostUrl("wss://localhost:8080")).to.be.true; + chai.expect(isLocalhostUrl("ws://127.0.0.1:8545")).to.be.true; + chai.expect(isLocalhostUrl("wss://[::1]:8080")).to.be.true; + }); + + it("rejects localhost prefix lookalikes", () => { + chai.expect(isLocalhostUrl("wss://localhost.evil.example")).to.be.false; + chai.expect(isLocalhostUrl("wss://localhostfoo")).to.be.false; + chai.expect(isLocalhostUrl("wss://evil-localhost")).to.be.false; + chai.expect(isLocalhostUrl("wss://example.com")).to.be.false; + }); + + it("rejects non-websocket schemes", () => { + chai.expect(isLocalhostUrl("https://localhost")).to.be.false; + chai.expect(isLocalhostUrl("http://127.0.0.1")).to.be.false; + }); + }); +});