History

fix(cloud): validate relay URL origins

Julius Marminge committed 5 months ago

Commit 7b3bc331 parent 26f36ca11 files · +144 −26Browse files

Unified · Split

11 files
  1. apps/mobile/src/features/cloud/publicConfig.test.ts +13 −0
  2. apps/mobile/src/features/cloud/publicConfig.ts +2 −1
  3. apps/server/src/cloud/http.ts +1 −14
  4. apps/server/src/server.test.ts +10 −0
  5. apps/web/src/cloud/publicConfig.test.ts +8 −0
  6. apps/web/src/cloud/publicConfig.ts +4 −5
  7. packages/client-runtime/src/managedRelay.test.ts +26 −2
  8. packages/client-runtime/src/managedRelay.ts +28 −4
  9. packages/shared/package.json +4 −0
  10. packages/shared/src/relayUrl.test.ts +26 −0
  11. packages/shared/src/relayUrl.ts +22 −0
apps/mobile/src/features/cloud/publicConfig.test.ts+13−0
@@ -31,4 +31,17 @@ describe("resolveCloudPublicConfig", () => { · 30 unmodified lines
3131 relayUrl: "https://relay.example.test",
3232 });
3333 });
34+
35+ it("rejects an insecure relay URL", () => {
36+ expect(
37+ resolveCloudPublicConfig({
38+ clerk: { publishableKey: "pk_test_example", jwtTemplate: "t3-relay" },
39+ relay: { url: "http://relay.example.test" },
40+ }),
41+ ).toEqual({
42+ clerkPublishableKey: "pk_test_example",
43+ clerkJwtTemplate: "t3-relay",
44+ relayUrl: null,
45+ });
46+ });
3447 });
apps/mobile/src/features/cloud/publicConfig.ts+2−1
@@ -1,5 +1,6 @@
11 import Constants from "expo-constants";
22 import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
3+import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34
45 type ExpoExtra = Readonly<Record<string, unknown>> | undefined;
56
@@ -22,7 +23,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig · 16 unmodified lines
2223 return {
2324 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),
2425 clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate),
25− relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null,
26+ relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""),
2627 } satisfies CloudPublicConfig;
2728 }
2829
apps/server/src/cloud/http.ts+1−14
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
3737 signRelayJwt,
3838 verifyRelayJwt,
3939 } from "@t3tools/shared/relayJwt";
40+import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
4041 import * as DateTime from "effect/DateTime";
4142 import * as Crypto from "effect/Crypto";
4243 import * as Duration from "effect/Duration";
@@ -155,20 +156,6 @@ function validateCloudMintPublicKey( · 112 unmodified lines
155156 );
156157 }
157158
158−function isSecureRelayUrl(value: string): boolean {
159− try {
160− const url = new URL(value);
161− return (
162− url.protocol === "https:" &&
163− url.username.length === 0 &&
164− url.password.length === 0 &&
165− url.hash.length === 0
166− );
167− } catch {
168− return false;
169− }
170−}
171−
172159 function validateRelayConfigPayload(
173160 payload: RelayEnvironmentConfigRequest,
174161 ): Effect.Effect<void, EnvironmentHttpBadRequestError> {
apps/server/src/server.test.ts+10−0
@@ -2127,6 +2127,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2126 unmodified lines
21272127 relayIssuer: "http://relay.example.test",
21282128 environmentCredential: "t3env_test_credential",
21292129 });
2130+ const nonOriginRelayUrl = yield* postRelayConfig({
2131+ relayUrl: "https://relay.example.test/path",
2132+ cloudUserId: "user_123",
2133+ environmentCredential: "t3env_test_credential",
2134+ });
21302135 const emptyCredential = yield* postRelayConfig({
21312136 relayUrl: "https://relay.example.test",
21322137 cloudUserId: "user_123",
@@ -2138,6 +2143,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
21382143 const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>(
21392144 insecureRelayIssuer,
21402145 );
2146+ const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>(
2147+ nonOriginRelayUrl,
2148+ );
21412149 const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>(
21422150 emptyCredential,
21432151 );
@@ -2149,6 +2157,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
21492157 insecureRelayIssuerBody.message,
21502158 "Relay issuer must be a secure absolute HTTPS URL.",
21512159 );
2160+ assert.equal(nonOriginRelayUrl.status, 400);
2161+ assert.equal(nonOriginRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL.");
21522162 assert.equal(emptyCredential.status, 400);
21532163 assert.equal(emptyCredentialBody.message, "Relay environment credential is required.");
21542164 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
apps/web/src/cloud/publicConfig.test.ts+8−0
@@ -22,4 +22,12 @@ describe("hasCloudPublicConfig", () => { · 21 unmodified lines
2222 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
2323 expect(hasCloudPublicConfig()).toBe(true);
2424 });
25+
26+ it("rejects an insecure relay URL", () => {
27+ vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example");
28+ vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay");
29+ vi.stubEnv("VITE_T3CODE_RELAY_URL", "http://relay.example.test");
30+
31+ expect(hasCloudPublicConfig()).toBe(false);
32+ });
2533 });
apps/web/src/cloud/publicConfig.ts+4−5
@@ -1,4 +1,5 @@
11 import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
2+import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
23
34 export interface CloudPublicConfig {
45 readonly clerkPublishableKey: string | null;
@@ -16,11 +17,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { · 11 unmodified lines
1617 import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined,
1718 ),
1819 clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined),
19− relayUrl:
20− trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace(
21− /\/+$/u,
22− "",
23− ) ?? null,
20+ relayUrl: normalizeSecureRelayUrl(
21+ (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
22+ ),
2423 };
2524 }
2625
packages/client-runtime/src/managedRelay.test.ts+26−2
@@ -16,7 +16,10 @@ import { · 15 unmodified lines
1616 } from "./managedRelay.ts";
1717 import { remoteHttpClientLayer } from "./remote.ts";
1818
19−function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) {
19+function managedRelayTestLayer(
20+ fetchFn: typeof globalThis.fetch,
21+ relayUrl = "https://relay.example.test",
22+) {
2023 const httpClientLayer = remoteHttpClientLayer(fetchFn);
2124 const signerLayer = Layer.succeed(
2225 ManagedRelayDpopSigner,
@@ -26,12 +29,33 @@ function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { · 3 unmodified lines
2629 }),
2730 );
2831 return managedRelayClientLayer({
29− relayUrl: "https://relay.example.test",
32+ relayUrl,
3033 clientId: "t3-mobile",
3134 }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer));
3235 }
3336
3437 describe("ManagedRelayClient", () => {
38+ it.effect("rejects unsafe relay URLs before sending credentials", () => {
39+ let requestCount = 0;
40+ const fetchFn = (() => {
41+ requestCount += 1;
42+ return Promise.resolve(Response.json({}));
43+ }) satisfies typeof globalThis.fetch;
44+
45+ return Effect.gen(function* () {
46+ const relayClient = yield* ManagedRelayClient;
47+ const error = yield* relayClient
48+ .listEnvironments({ clerkToken: "clerk-token" })
49+ .pipe(Effect.flip);
50+
51+ expect(error).toMatchObject({
52+ _tag: "ManagedRelayClientError",
53+ message: "Relay URL must be a secure absolute HTTPS origin.",
54+ });
55+ expect(requestCount).toBe(0);
56+ }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test")));
57+ });
58+
3559 it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => {
3660 let tokenExchangeCount = 0;
3761 const fetchFn = ((input) => {
packages/client-runtime/src/managedRelay.ts+28−4
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
2626 RelayUnregisterDeviceEndpoint,
2727 } from "@t3tools/contracts/relay";
2828 import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29+import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
2930 import * as Clock from "effect/Clock";
3031 import * as Context from "effect/Context";
3132 import * as Data from "effect/Data";
@@ -183,14 +184,37 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { · 151 unmodified lines
183184 };
184185 }
185186
187+function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape {
188+ const unavailable = () =>
189+ Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin."));
190+ return ManagedRelayClient.of({
191+ relayUrl,
192+ listEnvironments: unavailable,
193+ listDevices: unavailable,
194+ createEnvironmentLinkChallenge: unavailable,
195+ linkEnvironment: unavailable,
196+ unlinkEnvironment: unavailable,
197+ getEnvironmentStatus: unavailable,
198+ connectEnvironment: unavailable,
199+ registerDevice: unavailable,
200+ unregisterDevice: unavailable,
201+ registerLiveActivity: unavailable,
202+ resetTokenCache: Effect.void,
203+ });
204+}
205+
186206 export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) {
187207 return Layer.effect(
188208 ManagedRelayClient,
189209 Effect.gen(function* () {
210+ const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
211+ if (relayUrl === null) {
212+ return disabledManagedRelayClient(options.relayUrl);
213+ }
190214 const signer = yield* ManagedRelayDpopSigner;
191− const client = yield* HttpApiClient.make(RelayApi, { baseUrl: options.relayUrl });
215+ const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
192216 const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]);
193− const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: options.relayUrl });
217+ const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
194218
195219 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
196220 const dpopProofTargets = {
@@ -257,7 +281,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 60 unmodified lines
257281 subject_token: input.clerkToken,
258282 subject_token_type: RelayJwtSubjectTokenType,
259283 requested_token_type: RelayAccessTokenType,
260− resource: options.relayUrl,
284+ resource: relayUrl,
261285 scope: encodeOAuthScope(input.scopes),
262286 client_id: options.clientId,
263287 },
@@ -325,7 +349,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 61 unmodified lines
325349 });
326350
327351 return ManagedRelayClient.of({
328− relayUrl: options.relayUrl,
352+ relayUrl,
329353 listEnvironments: (input) =>
330354 client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe(
331355 Effect.map((response) => response.environments),
packages/shared/package.json+4−0
@@ -103,6 +103,10 @@ · 102 unmodified lines
103103 "types": "./src/relayAuth.ts",
104104 "import": "./src/relayAuth.ts"
105105 },
106+ "./relayUrl": {
107+ "types": "./src/relayUrl.ts",
108+ "import": "./src/relayUrl.ts"
109+ },
106110 "./relayJwt": {
107111 "types": "./src/relayJwt.ts",
108112 "import": "./src/relayJwt.ts"
packages/shared/src/relayUrl.test.ts+26−0
@@ -0,0 +1,26 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts";
4+
5+describe("normalizeSecureRelayUrl", () => {
6+ it("normalizes secure relay origins", () => {
7+ expect(normalizeSecureRelayUrl(" https://relay.example.test/// ")).toBe(
8+ "https://relay.example.test",
9+ );
10+ expect(normalizeSecureRelayUrl("https://relay.example.test:8443/")).toBe(
11+ "https://relay.example.test:8443",
12+ );
13+ });
14+
15+ it.each([
16+ "http://relay.example.test",
17+ "https://user:password@relay.example.test",
18+ "https://relay.example.test/path",
19+ "https://relay.example.test?query=value",
20+ "https://relay.example.test#fragment",
21+ "not a url",
22+ ])("rejects unsafe relay URL %s", (value) => {
23+ expect(normalizeSecureRelayUrl(value)).toBeNull();
24+ expect(isSecureRelayUrl(value)).toBe(false);
25+ });
26+});
packages/shared/src/relayUrl.ts+22−0
@@ -0,0 +1,22 @@
1+export function normalizeSecureRelayUrl(value: string): string | null {
2+ try {
3+ const url = new URL(value.trim());
4+ if (
5+ url.protocol !== "https:" ||
6+ url.username.length > 0 ||
7+ url.password.length > 0 ||
8+ url.search.length > 0 ||
9+ url.hash.length > 0 ||
10+ !/^\/+$/u.test(url.pathname)
11+ ) {
12+ return null;
13+ }
14+ return url.origin;
15+ } catch {
16+ return null;
17+ }
18+}
19+
20+export function isSecureRelayUrl(value: string): boolean {
21+ return normalizeSecureRelayUrl(value) !== null;
22+}