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
31 relayUrl: "https://relay.example.test",31 relayUrl: "https://relay.example.test",
32 });32 });
33 });33 });
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 });
34});47});
apps/mobile/src/features/cloud/publicConfig.ts+2−1
@@ -1,5 +1,6 @@
1import Constants from "expo-constants";1import Constants from "expo-constants";
2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
3import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3 4
4type ExpoExtra = Readonly<Record<string, unknown>> | undefined;5type ExpoExtra = Readonly<Record<string, unknown>> | undefined;
5 6
@@ -22,7 +23,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig · 16 unmodified lines
22 return {23 return {
23 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),24 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),
24 clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate),25 clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate),
25 relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null,26 relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""),
26 } satisfies CloudPublicConfig;27 } satisfies CloudPublicConfig;
27}28}
28 29
apps/server/src/cloud/http.ts+1−14
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
37 signRelayJwt,37 signRelayJwt,
38 verifyRelayJwt,38 verifyRelayJwt,
39} from "@t3tools/shared/relayJwt";39} from "@t3tools/shared/relayJwt";
40import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
40import * as DateTime from "effect/DateTime";41import * as DateTime from "effect/DateTime";
41import * as Crypto from "effect/Crypto";42import * as Crypto from "effect/Crypto";
42import * as Duration from "effect/Duration";43import * as Duration from "effect/Duration";
@@ -155,20 +156,6 @@ function validateCloudMintPublicKey( · 112 unmodified lines
155 );156 );
156}157}
157 158
158function 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
172function validateRelayConfigPayload(159function validateRelayConfigPayload(
173 payload: RelayEnvironmentConfigRequest,160 payload: RelayEnvironmentConfigRequest,
174): Effect.Effect<void, EnvironmentHttpBadRequestError> {161): 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
2127 relayIssuer: "http://relay.example.test",2127 relayIssuer: "http://relay.example.test",
2128 environmentCredential: "t3env_test_credential",2128 environmentCredential: "t3env_test_credential",
2129 });2129 });
2130 const nonOriginRelayUrl = yield* postRelayConfig({
2131 relayUrl: "https://relay.example.test/path",
2132 cloudUserId: "user_123",
2133 environmentCredential: "t3env_test_credential",
2134 });
2130 const emptyCredential = yield* postRelayConfig({2135 const emptyCredential = yield* postRelayConfig({
2131 relayUrl: "https://relay.example.test",2136 relayUrl: "https://relay.example.test",
2132 cloudUserId: "user_123",2137 cloudUserId: "user_123",
@@ -2138,6 +2143,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
2138 const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>(2143 const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>(
2139 insecureRelayIssuer,2144 insecureRelayIssuer,
2140 );2145 );
2146 const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>(
2147 nonOriginRelayUrl,
2148 );
2141 const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>(2149 const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>(
2142 emptyCredential,2150 emptyCredential,
2143 );2151 );
@@ -2149,6 +2157,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
2149 insecureRelayIssuerBody.message,2157 insecureRelayIssuerBody.message,
2150 "Relay issuer must be a secure absolute HTTPS URL.",2158 "Relay issuer must be a secure absolute HTTPS URL.",
2151 );2159 );
2160 assert.equal(nonOriginRelayUrl.status, 400);
2161 assert.equal(nonOriginRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL.");
2152 assert.equal(emptyCredential.status, 400);2162 assert.equal(emptyCredential.status, 400);
2153 assert.equal(emptyCredentialBody.message, "Relay environment credential is required.");2163 assert.equal(emptyCredentialBody.message, "Relay environment credential is required.");
2154 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2164 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
apps/web/src/cloud/publicConfig.test.ts+8−0
@@ -22,4 +22,12 @@ describe("hasCloudPublicConfig", () => { · 21 unmodified lines
22 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");22 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
23 expect(hasCloudPublicConfig()).toBe(true);23 expect(hasCloudPublicConfig()).toBe(true);
24 });24 });
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 });
25});33});
apps/web/src/cloud/publicConfig.ts+4−5
@@ -1,4 +1,5 @@
1import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";1import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
2import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
2 3
3export interface CloudPublicConfig {4export interface CloudPublicConfig {
4 readonly clerkPublishableKey: string | null;5 readonly clerkPublishableKey: string | null;
@@ -16,11 +17,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { · 11 unmodified lines
16 import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined,17 import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined,
17 ),18 ),
18 clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined),19 clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined),
19 relayUrl:20 relayUrl: normalizeSecureRelayUrl(
20 trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace(21 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
21 /\/+$/u,22 ),
22 "",
23 ) ?? null,
24 };23 };
25}24}
26 25
packages/client-runtime/src/managedRelay.test.ts+26−2
@@ -16,7 +16,10 @@ import { · 15 unmodified lines
16} from "./managedRelay.ts";16} from "./managedRelay.ts";
17import { remoteHttpClientLayer } from "./remote.ts";17import { remoteHttpClientLayer } from "./remote.ts";
18 18
19function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) {19function managedRelayTestLayer(
20 fetchFn: typeof globalThis.fetch,
21 relayUrl = "https://relay.example.test",
22) {
20 const httpClientLayer = remoteHttpClientLayer(fetchFn);23 const httpClientLayer = remoteHttpClientLayer(fetchFn);
21 const signerLayer = Layer.succeed(24 const signerLayer = Layer.succeed(
22 ManagedRelayDpopSigner,25 ManagedRelayDpopSigner,
@@ -26,12 +29,33 @@ function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { · 3 unmodified lines
26 }),29 }),
27 );30 );
28 return managedRelayClientLayer({31 return managedRelayClientLayer({
29 relayUrl: "https://relay.example.test",32 relayUrl,
30 clientId: "t3-mobile",33 clientId: "t3-mobile",
31 }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer));34 }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer));
32}35}
33 36
34describe("ManagedRelayClient", () => {37describe("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
35 it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => {59 it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => {
36 let tokenExchangeCount = 0;60 let tokenExchangeCount = 0;
37 const fetchFn = ((input) => {61 const fetchFn = ((input) => {
packages/client-runtime/src/managedRelay.ts+28−4
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26 RelayUnregisterDeviceEndpoint,26 RelayUnregisterDeviceEndpoint,
27} from "@t3tools/contracts/relay";27} from "@t3tools/contracts/relay";
28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
29import * as Clock from "effect/Clock";30import * as Clock from "effect/Clock";
30import * as Context from "effect/Context";31import * as Context from "effect/Context";
31import * as Data from "effect/Data";32import * as Data from "effect/Data";
@@ -183,14 +184,37 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { · 151 unmodified lines
183 };184 };
184}185}
185 186
187function 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
186export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) {206export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) {
187 return Layer.effect(207 return Layer.effect(
188 ManagedRelayClient,208 ManagedRelayClient,
189 Effect.gen(function* () {209 Effect.gen(function* () {
210 const relayUrl = normalizeSecureRelayUrl(options.relayUrl);
211 if (relayUrl === null) {
212 return disabledManagedRelayClient(options.relayUrl);
213 }
190 const signer = yield* ManagedRelayDpopSigner;214 const signer = yield* ManagedRelayDpopSigner;
191 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: options.relayUrl });215 const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl });
192 const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]);216 const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]);
193 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: options.relayUrl });217 const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl });
194 218
195 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;219 type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">;
196 const dpopProofTargets = {220 const dpopProofTargets = {
@@ -257,7 +281,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 60 unmodified lines
257 subject_token: input.clerkToken,281 subject_token: input.clerkToken,
258 subject_token_type: RelayJwtSubjectTokenType,282 subject_token_type: RelayJwtSubjectTokenType,
259 requested_token_type: RelayAccessTokenType,283 requested_token_type: RelayAccessTokenType,
260 resource: options.relayUrl,284 resource: relayUrl,
261 scope: encodeOAuthScope(input.scopes),285 scope: encodeOAuthScope(input.scopes),
262 client_id: options.clientId,286 client_id: options.clientId,
263 },287 },
@@ -325,7 +349,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) · 61 unmodified lines
325 });349 });
326 350
327 return ManagedRelayClient.of({351 return ManagedRelayClient.of({
328 relayUrl: options.relayUrl,352 relayUrl,
329 listEnvironments: (input) =>353 listEnvironments: (input) =>
330 client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe(354 client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe(
331 Effect.map((response) => response.environments),355 Effect.map((response) => response.environments),
packages/shared/package.json+4−0
@@ -103,6 +103,10 @@ · 102 unmodified lines
103 "types": "./src/relayAuth.ts",103 "types": "./src/relayAuth.ts",
104 "import": "./src/relayAuth.ts"104 "import": "./src/relayAuth.ts"
105 },105 },
106 "./relayUrl": {
107 "types": "./src/relayUrl.ts",
108 "import": "./src/relayUrl.ts"
109 },
106 "./relayJwt": {110 "./relayJwt": {
107 "types": "./src/relayJwt.ts",111 "types": "./src/relayJwt.ts",
108 "import": "./src/relayJwt.ts"112 "import": "./src/relayJwt.ts"
packages/shared/src/relayUrl.test.ts+26−0
@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vitest";
2
3import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts";
4
5describe("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 @@
1export 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
20export function isSecureRelayUrl(value: string): boolean {
21 return normalizeSecureRelayUrl(value) !== null;
22}