From 45eae6ba6997d3b5e87561b613e510432275dfaa Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 06:00:30 +0200 Subject: [PATCH 1/4] fix(server): use listener port for managed tunnel origins --- apps/server/src/cloud/http.ts | 39 ++++++++++++-- apps/server/src/server.test.ts | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 29fdfe8ece2f..04861a2ee304 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"; @@ -296,7 +296,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 { @@ -309,6 +309,18 @@ 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); } @@ -433,7 +445,28 @@ 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; + if ( + server.address._tag !== "TcpAddress" || + !hasAllowedEndpointOriginHost({ origin: request.origin, requestUrl }) + ) { + return yield* new EnvironmentHttpBadRequestError({ + message: "Invalid managed endpoint origin.", + }); + } + proofRequest = { + ...request, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: server.address.port, + }, + }; + proofRequestUrl = `http://127.0.0.1:${server.address.port}`; + } + 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 a9a2c3fa10d6..afb1b6fc8ff8 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 { @@ -1301,6 +1302,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); @@ -2003,6 +2050,52 @@ 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 cloud link proofs requested through a public managed endpoint", () => Effect.gen(function* () { yield* buildAppUnderTest(); From 43366a9e2557787fcf46c74014c152a040523fbd Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 06:06:10 +0200 Subject: [PATCH 2/4] fix(server): preserve managed tunnel listener host --- apps/server/src/cloud/http.test.ts | 46 ++++++++++++++++++++++++++++++ apps/server/src/cloud/http.ts | 31 ++++++++++++++++---- 2 files changed, 71 insertions(+), 6 deletions(-) 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 04861a2ee304..7bdf2d31b5ba 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -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, @@ -324,6 +325,26 @@ function isAllowedEndpointOrigin(input: { 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 @@ -449,8 +470,9 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( let proofRequestUrl = requestUrl; if (request.endpoint.providerKind === "cloudflare_tunnel") { const server = yield* HttpServer.HttpServer; + const listenerOrigin = managedTunnelOriginForAddress(server.address); if ( - server.address._tag !== "TcpAddress" || + listenerOrigin === null || !hasAllowedEndpointOriginHost({ origin: request.origin, requestUrl }) ) { return yield* new EnvironmentHttpBadRequestError({ @@ -459,12 +481,9 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( } proofRequest = { ...request, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: server.address.port, - }, + origin: listenerOrigin, }; - proofRequestUrl = `http://127.0.0.1:${server.address.port}`; + proofRequestUrl = `http://${formatHostForUrl(listenerOrigin.localHttpHost)}:${listenerOrigin.localHttpPort}`; } const proof = yield* makeCloudLinkProof(dependencies, proofRequest, proofRequestUrl); yield* appendCloudCredentialResponseHeaders; From 3ac23f2bdba233d6e762743223796197853b5a45 Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 09:51:08 +0200 Subject: [PATCH 3/4] test(server): preserve managed link authentication --- apps/server/src/server.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index afb1b6fc8ff8..ed887ce8f0e4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2096,6 +2096,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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 linkProofResponse = yield* fetchEffect( + `http://127.0.0.1:${forwardedPort}/api/connect/link-proof`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: 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, + }, + }), + }, + ); + + 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(); From b44cd3248325ebce0f0d5f9bdeeceda782daa0bd Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Thu, 27 Aug 2026 09:58:50 +0200 Subject: [PATCH 4/4] test(server): exercise forwarded authentication path --- apps/server/src/server.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ed887ce8f0e4..e0a70b630165 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2103,12 +2103,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); const { port: forwardedPort } = yield* openTcpForward(serverPort); - const linkProofResponse = yield* fetchEffect( + const linkProofRequest = HttpClientRequest.post( `http://127.0.0.1:${forwardedPort}/api/connect/link-proof`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: jsonRequestBody({ + ).pipe( + HttpClientRequest.bodyText( + jsonRequestBody({ challenge: "relay-link-challenge", relayIssuer: "https://relay.example.test", endpoint: { @@ -2121,7 +2120,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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);