diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 0f24e6f34176..fee3c76b3b95 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -35,6 +35,7 @@ import { consumeCloudReplayGuards, isSupportedLinkProviderKind, linkProofScopes, + managedTunnelOriginForAddress, pendingServiceUpdateExists, reconcileDesiredCloudLink, releaseManagedTunnelOnShutdown, @@ -609,3 +610,48 @@ describe("link proof provider kinds", () => { expect(linkProofScopes(proofRequest("manual"))).toEqual(["agent_activity_notifications"]); }); }); + +describe("managed tunnel listener origins", () => { + it("uses an IPv6 loopback listener", () => { + expect( + managedTunnelOriginForAddress({ + _tag: "TcpAddress", + hostname: "::1", + port: 7331, + }), + ).toEqual({ localHttpHost: "::1", localHttpPort: 7331 }); + }); + + it("maps wildcard listeners to loopback hosts", () => { + expect( + managedTunnelOriginForAddress({ + _tag: "TcpAddress", + hostname: "0.0.0.0", + port: 7331, + }), + ).toEqual({ localHttpHost: "127.0.0.1", localHttpPort: 7331 }); + expect( + managedTunnelOriginForAddress({ + _tag: "TcpAddress", + hostname: "::", + port: 7331, + }), + ).toEqual({ localHttpHost: "::1", localHttpPort: 7331 }); + }); + + it("rejects listeners that have no loopback origin", () => { + expect( + managedTunnelOriginForAddress({ + _tag: "TcpAddress", + hostname: "192.168.1.42", + port: 7331, + }), + ).toBeNull(); + expect( + managedTunnelOriginForAddress({ + _tag: "UnixAddress", + path: "/tmp/t3.sock", + }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index e0d458b4b97c..3b521046a90a 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -51,7 +51,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -60,6 +60,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { formatHostForUrl } from "../startupAccess.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { SERVICE_STATE_FILE, @@ -297,7 +298,7 @@ function endpointRequestPort(url: URL): number { return Number(url.port || (url.protocol === "https:" ? 443 : 80)); } -function isAllowedEndpointOrigin(input: { +function hasAllowedEndpointOriginHost(input: { readonly origin: RelayManagedEndpointOrigin; readonly requestUrl: string; }): boolean { @@ -310,9 +311,41 @@ function isAllowedEndpointOrigin(input: { return false; } + return true; +} + +function isAllowedEndpointOrigin(input: { + readonly origin: RelayManagedEndpointOrigin; + readonly requestUrl: string; +}): boolean { + if (!hasAllowedEndpointOriginHost(input)) { + return false; + } + + const url = new URL(input.requestUrl); return input.origin.localHttpPort === endpointRequestPort(url); } +export function managedTunnelOriginForAddress( + address: HttpServer.Address, +): RelayManagedEndpointOrigin | null { + if (address._tag !== "TcpAddress") { + return null; + } + + const listenerHost = normalizeHostname(address.hostname); + const localHttpHost = + listenerHost === "0.0.0.0" ? "127.0.0.1" : listenerHost === "::" ? "::1" : listenerHost; + if (!isLoopbackHostname(localHttpHost)) { + return null; + } + + return { + localHttpHost, + localHttpPort: address.port, + }; +} + // A managed (Cloudflare tunnel) endpoint is provisioned by the relay and must // point at a loopback origin. A manual endpoint is reached out of band (e.g. // Tailscale) or not advertised at all for publish-only links, so it is not @@ -434,7 +467,26 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( message: "Invalid managed endpoint origin.", }); } - const proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); + let proofRequest = request; + let proofRequestUrl = requestUrl; + if (request.endpoint.providerKind === "cloudflare_tunnel") { + const server = yield* HttpServer.HttpServer; + const listenerOrigin = managedTunnelOriginForAddress(server.address); + if ( + listenerOrigin === null || + !hasAllowedEndpointOriginHost({ origin: request.origin, requestUrl }) + ) { + return yield* new EnvironmentHttpBadRequestError({ + message: "Invalid managed endpoint origin.", + }); + } + proofRequest = { + ...request, + origin: listenerOrigin, + }; + proofRequestUrl = `http://${formatHostForUrl(listenerOrigin.localHttpHost)}:${listenerOrigin.localHttpPort}`; + } + const proof = yield* makeCloudLinkProof(dependencies, proofRequest, proofRequestUrl); yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 64bfea1b9805..6ffbfcb41969 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { @@ -1524,6 +1525,52 @@ const responseJsonEffect = (response: HttpClientResponse.HttpClientResponse) const responseOk = (response: HttpClientResponse.HttpClientResponse) => response.status >= 200 && response.status < 300; +const openTcpForward = (targetPort: number) => + Effect.acquireRelease( + Effect.tryPromise({ + try: () => + new Promise<{ + readonly port: number; + readonly server: NodeNet.Server; + readonly sockets: Set; + }>((resolve, reject) => { + const sockets = new Set(); + const server = NodeNet.createServer((client) => { + const upstream = NodeNet.createConnection({ + host: "127.0.0.1", + port: targetPort, + }); + sockets.add(client); + sockets.add(upstream); + client.on("close", () => sockets.delete(client)); + upstream.on("close", () => sockets.delete(upstream)); + client.on("error", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); + client.pipe(upstream); + upstream.pipe(client); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Expected a TCP forward address.")); + return; + } + resolve({ port: address.port, server, sockets }); + }); + }), + catch: (cause) => new TestHttpRequestError({ cause }), + }), + ({ server, sockets }) => + Effect.promise( + () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + ), + ); + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); @@ -2722,6 +2769,88 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uses the server listener port for managed tunnel link proofs", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const { port: forwardedPort } = yield* openTcpForward(serverPort); + const forwardedLinkProofUrl = `http://127.0.0.1:${forwardedPort}/api/connect/link-proof`; + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const linkProofRequest = HttpClientRequest.post(forwardedLinkProofUrl, { + headers: { cookie: ownerCookie }, + }).pipe( + HttpClientRequest.bodyText( + jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: `http://127.0.0.1:${forwardedPort}`, + wsBaseUrl: `ws://127.0.0.1:${forwardedPort}/ws`, + providerKind: "cloudflare_tunnel", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: forwardedPort, + }, + }), + "application/json", + ), + ); + const linkProofResponse = yield* HttpClient.execute(linkProofRequest).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((cause) => new TestHttpRequestError({ cause })), + ); + const proof = yield* responseJsonEffect(linkProofResponse); + + assert.equal(linkProofResponse.status, 200); + assert.equal(typeof proof, "string"); + const payload = decodeCompactJwtPayload<{ + readonly origin?: { readonly localHttpHost?: string; readonly localHttpPort?: number }; + }>(proof as string); + + assert.equal(payload.origin?.localHttpHost, "127.0.0.1"); + assert.equal(payload.origin?.localHttpPort, serverPort); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects unauthenticated managed tunnel link proofs through a TCP forward", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const { port: forwardedPort } = yield* openTcpForward(serverPort); + const linkProofRequest = HttpClientRequest.post( + `http://127.0.0.1:${forwardedPort}/api/connect/link-proof`, + ).pipe( + HttpClientRequest.bodyText( + jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: `http://127.0.0.1:${forwardedPort}`, + wsBaseUrl: `ws://127.0.0.1:${forwardedPort}/ws`, + providerKind: "cloudflare_tunnel", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: forwardedPort, + }, + }), + "application/json", + ), + ); + const linkProofResponse = yield* HttpClient.execute(linkProofRequest).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((cause) => new TestHttpRequestError({ cause })), + ); + + assert.equal(linkProofResponse.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects cloud link proofs requested through a public managed endpoint", () => Effect.gen(function* () { yield* buildAppUnderTest();