Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/server/src/cloud/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
consumeCloudReplayGuards,
isSupportedLinkProviderKind,
linkProofScopes,
managedTunnelOriginForAddress,
pendingServiceUpdateExists,
reconcileDesiredCloudLink,
releaseManagedTunnelOnShutdown,
Expand Down Expand Up @@ -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();
});
});
58 changes: 55 additions & 3 deletions apps/server/src/cloud/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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;
},
Expand Down
129 changes: 129 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1524,6 +1525,52 @@ const responseJsonEffect = <A>(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<NodeNet.Socket>;
}>((resolve, reject) => {
const sockets = new Set<NodeNet.Socket>();
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<void>((resolve) => {
for (const socket of sockets) socket.destroy();
server.close(() => resolve());
}),
),
);

const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>
Effect.gen(function* () {
const { response, cookie } = yield* bootstrapBrowserSession(credential);
Expand Down Expand Up @@ -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<unknown>(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 })),
);
Comment thread
mateo-m marked this conversation as resolved.

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();
Expand Down
Loading