Skip to content

Commit 69ceea9

Browse files
aididhaiqalclaudejuliusmarminge
authored
fix(server): T3 Connect links from the desktop app when the backend runs in WSL (#19271)
Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Julius Marminge <[email protected]>
1 parent 0979221 commit 69ceea9

3 files changed

Lines changed: 276 additions & 6 deletions

File tree

‎apps/server/src/cloud/CloudLink.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import * as NodeCrypto from "node:crypto";
33
import {
44
AuthStandardClientScopes,
5+
EnvironmentAuthenticatedPrincipal,
56
type EnvironmentCloudLinkStateResult,
67
type EnvironmentCloudPreferencesRequest,
78
type EnvironmentCloudRelayConfigResult,
@@ -417,14 +418,18 @@ export class CloudLink extends Context.Service<
417418
{
418419
/**
419420
* Signs the proof the relay asks for to link this environment. The request
420-
* must reach this server directly on the loopback origin it names.
421+
* must reach this server directly. Desktop bootstrap sessions may use the
422+
* WSL IP; all other sessions must use the loopback origin they name.
421423
*/
422424
readonly linkProof: (
423425
request: RelayLinkProofRequest,
424426
httpRequest: HttpServerRequest.HttpServerRequest,
425427
) => Effect.Effect<
426428
RelayEnvironmentLinkProof,
427-
CloudLinkOriginInvalidError | CloudLinkInternalError | EnvironmentAuth.ServerAuthInternalError
429+
| CloudLinkOriginInvalidError
430+
| CloudLinkInternalError
431+
| EnvironmentAuth.ServerAuthInternalError,
432+
EnvironmentAuthenticatedPrincipal
428433
>;
429434
readonly applyRelayConfig: (
430435
payload: RelayEnvironmentConfigRequest,
@@ -580,13 +585,15 @@ const make = Effect.gen(function* () {
580585
const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* (
581586
request: RelayLinkProofRequest,
582587
requestUrl: string,
588+
allowNonLoopbackRequestHost = false,
583589
) {
584590
const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
585591
if (
586592
!isSupportedLinkProviderKind(request) ||
587593
!isAllowedEndpointOrigin({
588594
origin: request.origin,
589595
requestUrl,
596+
allowNonLoopbackRequestHost,
590597
})
591598
) {
592599
return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
@@ -630,8 +637,13 @@ const make = Effect.gen(function* () {
630637
if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) {
631638
return yield* new CloudLinkOriginInvalidError({ origin: "endpoint" });
632639
}
633-
const proof = yield* makeCloudLinkProof(request, requestUrl);
634-
return proof satisfies RelayEnvironmentLinkProof;
640+
const principal = yield* EnvironmentAuthenticatedPrincipal;
641+
// WSL desktop requests use the distro IP; only the server-issued bootstrap subject qualifies.
642+
return yield* makeCloudLinkProof(
643+
request,
644+
requestUrl,
645+
principal.subject === "desktop-bootstrap",
646+
);
635647
},
636648
Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")),
637649
Effect.catchTags({ PlatformError: internalError("generate-link-proof") }),

‎apps/server/src/cloud/http.test.ts‎

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,275 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
2-
import { EnvironmentHttpApi } from "@t3tools/contracts";
32
import { describe, expect, it } from "@effect/vitest";
3+
import {
4+
AuthAccessTokenType,
5+
AuthEnvironmentBootstrapTokenType,
6+
AuthTokenExchangeGrantType,
7+
EnvironmentHttpApi,
8+
EnvironmentId,
9+
} from "@t3tools/contracts";
10+
import {
11+
RelayEnvironmentLinkProofPayload,
12+
type RelayLinkProofRequest,
13+
} from "@t3tools/contracts/relay";
414
import * as Effect from "effect/Effect";
515
import * as Layer from "effect/Layer";
16+
import * as Schema from "effect/Schema";
17+
import { HttpClient, HttpServer } from "effect/http";
618
import * as Etag from "effect/http/Etag";
719
import * as HttpPlatform from "effect/http/HttpPlatform";
820
import * as HttpRouter from "effect/http/HttpRouter";
921
import * as HttpApi from "effect/http-api/HttpApi";
1022
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
23+
import * as NetAddress from "effect/net/NetAddress";
24+
import { decodeJwt, importSPKI, jwtVerify } from "jose";
25+
import { RELAY_LINK_PROOF_TYP } from "@t3tools/shared/relayJwt";
1126

1227
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1328
import * as AuthHttp from "../auth/http.ts";
29+
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30+
import * as ServerConfig from "../config.ts";
31+
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
32+
import * as SqlitePersistence from "../persistence/Sqlite.ts";
33+
import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
34+
import * as CliTokenManager from "./CliTokenManager.ts";
1435
import * as CloudLink from "./CloudLink.ts";
1536
import * as ConnectHttp from "./http.ts";
37+
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
38+
39+
const DESKTOP_BOOTSTRAP_TOKEN = "test-desktop-bootstrap-token";
40+
const NON_LOOPBACK_ORIGIN = "http://172.24.162.233:3773";
41+
const LOOPBACK_ORIGIN = "http://127.0.0.1:3773";
42+
const proofRequest: RelayLinkProofRequest = {
43+
challenge: "test-challenge",
44+
relayIssuer: "https://relay.example.test",
45+
endpoint: {
46+
providerKind: "cloudflare_tunnel",
47+
httpBaseUrl: "https://environment.example.test",
48+
wsBaseUrl: "wss://environment.example.test",
49+
},
50+
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
51+
};
52+
const decodeLinkProofPayload = Schema.decodeUnknownSync(RelayEnvironmentLinkProofPayload);
53+
54+
class LinkProofTestApi extends HttpApi.make("environment").add(
55+
EnvironmentHttpApi.groups.auth,
56+
EnvironmentHttpApi.groups.connect,
57+
) {}
58+
59+
const layerConfig = Layer.effect(
60+
ServerConfig.ServerConfig,
61+
Effect.gen(function* () {
62+
const config = yield* ServerConfig.ServerConfig;
63+
return {
64+
...config,
65+
mode: "desktop" as const,
66+
host: "0.0.0.0",
67+
port: 3773,
68+
desktopBootstrapToken: DESKTOP_BOOTSTRAP_TOKEN,
69+
};
70+
}),
71+
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-link-proof-test-" })));
72+
73+
// Real credential issuance, verification, origin checks and signing; no relay or tunnel is started.
74+
const layerLinkProofRoutes = HttpApiBuilder.layer(LinkProofTestApi).pipe(
75+
Layer.provide([AuthHttp.layer, ConnectHttp.layer]),
76+
Layer.provide(AuthHttp.layerAuthenticatedAuth),
77+
Layer.provide(CloudLink.layer),
78+
Layer.provide(EnvironmentAuth.layer),
79+
Layer.provide([
80+
SqlitePersistence.layerMemory,
81+
ServerEnvironment.layerIdentity,
82+
Layer.mock(ServerEnvironment.ServerEnvironment)({
83+
getDescriptor: Effect.succeed({
84+
environmentId: EnvironmentId.make("wsl-environment"),
85+
label: "WSL",
86+
platform: { os: "linux", arch: "x64" },
87+
serverVersion: "0.0.0-test",
88+
capabilities: { repositoryIdentity: false },
89+
}),
90+
}),
91+
Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({}),
92+
Layer.mock(CliTokenManager.CloudCliTokenManager)({}),
93+
Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({}),
94+
Layer.mock(HttpServer.HttpServer)({
95+
address: NetAddress.inetAddressFromIpStringUnsafe("0.0.0.0", 3773),
96+
}),
97+
Layer.succeed(
98+
HttpClient.HttpClient,
99+
HttpClient.make(() => Effect.die("Unexpected relay call")),
100+
),
101+
]),
102+
Layer.provideMerge(ServerSecretStore.layer),
103+
Layer.provide(layerConfig),
104+
Layer.provideMerge(HttpPlatform.layer.pipe(Layer.provideMerge(Etag.layerWeak))),
105+
Layer.provideMerge(NodeServices.layer),
106+
);
107+
108+
const withLinkProofClient = async (
109+
body: (client: {
110+
post: (
111+
path: string,
112+
payload: unknown,
113+
headers?: Record<string, string>,
114+
origin?: string,
115+
) => Promise<Response>;
116+
exchange: (credential: string, scope?: string) => Promise<string>;
117+
}) => Promise<void>,
118+
) => {
119+
const { handler, dispose } = HttpRouter.toWebHandler(layerLinkProofRoutes, {
120+
disableLogger: true,
121+
});
122+
const post = (
123+
path: string,
124+
payload: unknown,
125+
headers?: Record<string, string>,
126+
origin = NON_LOOPBACK_ORIGIN,
127+
) =>
128+
handler(
129+
new Request(`${origin}${path}`, {
130+
method: "POST",
131+
headers: { "content-type": "application/json", ...headers },
132+
body: JSON.stringify(payload),
133+
}),
134+
);
135+
const exchange = async (credential: string, scope?: string) => {
136+
const response = await handler(
137+
new Request(`${NON_LOOPBACK_ORIGIN}/oauth/token`, {
138+
method: "POST",
139+
body: new URLSearchParams({
140+
grant_type: AuthTokenExchangeGrantType,
141+
subject_token: credential,
142+
subject_token_type: AuthEnvironmentBootstrapTokenType,
143+
requested_token_type: AuthAccessTokenType,
144+
...(scope ? { scope } : {}),
145+
}),
146+
}),
147+
);
148+
expect(response.status).toBe(200);
149+
const payload = (await response.json()) as { access_token: string };
150+
return payload.access_token;
151+
};
152+
try {
153+
await body({ post, exchange });
154+
} finally {
155+
await dispose();
156+
}
157+
};
158+
159+
describe("link proof over a non-loopback desktop transport", () => {
160+
it("signs a loopback tunnel origin after desktop bootstrap token exchange over a non-loopback host", () =>
161+
withLinkProofClient(async ({ post, exchange }) => {
162+
const token = await exchange(DESKTOP_BOOTSTRAP_TOKEN);
163+
const response = await post("/api/connect/link-proof", proofRequest, {
164+
authorization: `Bearer ${token}`,
165+
});
166+
expect(response.status).toBe(200);
167+
const proof = (await response.json()) as string;
168+
const payload = decodeLinkProofPayload(decodeJwt(proof));
169+
const verified = await jwtVerify(
170+
proof,
171+
await importSPKI(payload.environmentPublicKey, "EdDSA"),
172+
{
173+
algorithms: ["EdDSA"],
174+
typ: RELAY_LINK_PROOF_TYP,
175+
issuer: "t3-env:wsl-environment",
176+
audience: proofRequest.relayIssuer,
177+
},
178+
);
179+
expect(verified.payload).toMatchObject({
180+
origin: proofRequest.origin,
181+
endpoint: proofRequest.endpoint,
182+
challenge: proofRequest.challenge,
183+
});
184+
expect(response.headers.get("cache-control")).toBe("no-store");
185+
}));
186+
187+
it("does not pass desktop trust to a paired client or accept client-supplied provenance", () =>
188+
withLinkProofClient(async ({ post, exchange }) => {
189+
const desktop = await exchange(DESKTOP_BOOTSTRAP_TOKEN);
190+
const paired = await post(
191+
"/api/auth/pairing-token",
192+
{
193+
scopes: ["relay:write"],
194+
label: "desktop-bootstrap",
195+
subject: "desktop-bootstrap",
196+
},
197+
{ authorization: `Bearer ${desktop}` },
198+
);
199+
expect(paired.status).toBe(200);
200+
const { credential } = (await paired.json()) as { credential: string };
201+
const token = await exchange(credential);
202+
const headers = { authorization: `Bearer ${token}`, "x-desktop-bootstrap": "true" };
203+
const payload = {
204+
...proofRequest,
205+
allowNonLoopbackRequestHost: true,
206+
subject: "desktop-bootstrap",
207+
};
208+
const rejected = await post("/api/connect/link-proof", payload, headers);
209+
expect(rejected.status).toBe(400);
210+
expect(await rejected.json()).toMatchObject({ message: "Invalid managed endpoint origin." });
211+
const loopback = await post(
212+
"/api/connect/link-proof",
213+
proofRequest,
214+
headers,
215+
LOOPBACK_ORIGIN,
216+
);
217+
expect(loopback.status).toBe(200);
218+
}));
219+
220+
it.each([
221+
{
222+
name: "non-loopback tunnel origin",
223+
origin: { localHttpHost: "172.24.162.233", localHttpPort: 3773 },
224+
headers: {},
225+
},
226+
{
227+
name: "mismatched port",
228+
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3774 },
229+
headers: {},
230+
},
231+
{
232+
name: "forwarded host",
233+
origin: proofRequest.origin,
234+
headers: { "x-forwarded-host": "127.0.0.1:3773" },
235+
},
236+
{
237+
name: "forwarded protocol",
238+
origin: proofRequest.origin,
239+
headers: { "x-forwarded-proto": "http" },
240+
},
241+
])("rejects $name even for desktop bootstrap", ({ origin, headers }) =>
242+
withLinkProofClient(async ({ post, exchange }) => {
243+
const token = await exchange(DESKTOP_BOOTSTRAP_TOKEN);
244+
const response = await post(
245+
"/api/connect/link-proof",
246+
{ ...proofRequest, origin },
247+
{ ...headers, authorization: `Bearer ${token}` },
248+
);
249+
expect(response.status).toBe(400);
250+
expect(await response.json()).toMatchObject({ message: "Invalid managed endpoint origin." });
251+
}),
252+
);
253+
254+
it("requires relay-write scope even for desktop bootstrap", () =>
255+
withLinkProofClient(async ({ post, exchange }) => {
256+
const token = await exchange(DESKTOP_BOOTSTRAP_TOKEN, "relay:read");
257+
const response = await post("/api/connect/link-proof", proofRequest, {
258+
authorization: `Bearer ${token}`,
259+
});
260+
expect(response.status).toBe(403);
261+
expect(await response.json()).toMatchObject({
262+
code: "insufficient_scope",
263+
requiredScope: "relay:write",
264+
});
265+
}));
266+
267+
it("rejects unauthenticated requests", () =>
268+
withLinkProofClient(async ({ post }) => {
269+
const response = await post("/api/connect/link-proof", proofRequest);
270+
expect(response.status).toBe(401);
271+
}));
272+
});
16273

17274
class ConnectTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.connect) {}
18275

‎apps/server/src/cloud/linkChecks.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,14 @@ export function parseManagedEndpointLocalOrigin(localOrigin: string) {
8282
export function isAllowedEndpointOrigin(input: {
8383
readonly origin: RelayManagedEndpointOrigin;
8484
readonly requestUrl: string;
85+
readonly allowNonLoopbackRequestHost?: boolean;
8586
}): boolean {
8687
if (!isLoopbackHostname(input.origin.localHttpHost)) {
8788
return false;
8889
}
8990

9091
const url = new URL(input.requestUrl);
91-
if (!isLoopbackHostname(url.hostname)) {
92+
if (!input.allowNonLoopbackRequestHost && !isLoopbackHostname(url.hostname)) {
9293
return false;
9394
}
9495

0 commit comments

Comments
 (0)