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