diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 54c78907421..24e78757009 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -72,6 +72,53 @@ describe("remote", () => { }); }); + it("rejects unsupported direct pairing URL protocols", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: "ftp://remote.example.com/pair#token=pairing-token", + }); + } catch (cause) { + pairingUrlError = cause; + } + + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect(pairingUrlError).toMatchObject({ protocol: "ftp:" }); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported hosted pairing backend protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported direct host protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + host: "ftp://remote.example.com", + pairingCode: "pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + it("uses distinct structural errors for missing pairing inputs", () => { expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); expect(() => diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 703811609b8..7347dbc74a1 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; +const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( "RemotePairingUrlInvalidError", - { cause: Schema.Defect() }, + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, ) { override get message(): string { return "Pairing URL is invalid."; @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -83,6 +91,12 @@ const normalizeRemoteBaseUrl = ( } catch (cause) { throw new RemoteBackendUrlInvalidError({ source, cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemoteBackendUrlInvalidError({ + source, + protocol: url.protocol, + }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -184,6 +198,11 @@ export const resolveRemotePairingTarget = (input: { } catch (cause) { throw new RemotePairingUrlInvalidError({ cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemotePairingUrlInvalidError({ + protocol: url.protocol, + }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { const hostedBackendUrl = normalizeRemoteBaseUrl(