From f12adaa10df9202cd5108320f1b9009f0fbd00f7 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:44:51 +0100 Subject: [PATCH 1/3] feat: add session-bound asset capability base --- apps/mobile/src/components/ProjectFavicon.tsx | 25 ++- .../features/files/ThreadFilesRouteScreen.tsx | 30 ++- .../files/WorkspaceFileImagePreview.tsx | 26 ++- .../files/WorkspaceFileWebPreview.tsx | 23 +- .../features/files/workspaceFileAssetUrl.ts | 4 +- .../src/features/threads/ThreadFeed.tsx | 17 +- apps/mobile/src/state/assets.ts | 35 ++- apps/server/src/assets/AssetAccess.test.ts | 76 ++++++- apps/server/src/assets/AssetAccess.ts | 209 ++++++++++++++---- apps/server/src/http.ts | 147 +++++++++++- apps/server/src/server.test.ts | 105 ++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 14 +- .../client-runtime/src/state/assets.test.ts | 14 +- packages/client-runtime/src/state/assets.ts | 27 ++- packages/contracts/src/assetClaims.test.ts | 42 ++++ packages/contracts/src/assetClaims.ts | 87 ++++++++ packages/contracts/src/assets.test.ts | 73 ++++++ packages/contracts/src/assets.ts | 30 +++ packages/contracts/src/index.ts | 1 + 20 files changed, 874 insertions(+), 113 deletions(-) create mode 100644 packages/contracts/src/assetClaims.test.ts create mode 100644 packages/contracts/src/assetClaims.ts create mode 100644 packages/contracts/src/assets.test.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14..9b830344d75 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,7 +5,7 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; -import { useAssetUrl } from "../state/assets"; +import { type AssetRequestSource, useAssetRequestSource } from "../state/assets"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ const loadedFaviconUrls = new Set(); @@ -19,18 +19,20 @@ export function ProjectFavicon(props: { readonly workspaceRoot?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( + const faviconSource = useAssetRequestSource( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); - const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const renderableFaviconSource = isProjectFaviconFallbackUrl(faviconSource?.uri ?? null) + ? null + : faviconSource; return ( (() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + props.faviconSource && loadedFaviconUrls.has(props.faviconSource.uri) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const showImage = props.faviconSource !== null && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (props.faviconSource) loadedFaviconUrls.add(props.faviconSource.uri); setStatus("loaded"); }} onError={() => setStatus("error")} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 012f99536d2..13172b4d286 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -40,6 +40,7 @@ import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import type { AssetRequestSource } from "../../state/assets"; import { basename, isBrowserPreviewFile, @@ -83,7 +84,7 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; - readonly previewUri: string | null; + readonly previewSource: AssetRequestSource | null; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -97,18 +98,18 @@ function FileContent(props: { if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { - return ; + return ; } return ( ); } if (props.activeMode === "preview" && isBrowserFile) { - return ; + return ; } if (props.fileError && props.fileContents === null) { @@ -482,16 +483,19 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { : defaultViewMode(relativePath); const resolvedActiveMode = canPreview ? activeMode : "source"; const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const assetPreviewSource = useWorkspaceFileAssetUrl({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); - const previewUri = - assetPreviewUri === null || previewRevision === 0 - ? assetPreviewUri - : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; + const previewSource = + assetPreviewSource === null || previewRevision === 0 + ? assetPreviewSource + : { + ...assetPreviewSource, + uri: `${assetPreviewSource.uri}${assetPreviewSource.uri.includes("?") ? "&" : "?"}revision=${previewRevision}`, + }; const needsFileContents = relativePath !== null && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); @@ -629,11 +633,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { > Copy path - {isBrowserFile && typeof assetPreviewUri === "string" ? ( + {isBrowserFile && + assetPreviewSource !== null && + assetPreviewSource.headers === undefined ? ( { - void tryOpenExternalUrl(assetPreviewUri, "file-preview"); + void tryOpenExternalUrl(assetPreviewSource.uri, "file-preview"); }} > Open in Safari @@ -653,7 +659,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (null); const [fullScreenVisible, setFullScreenVisible] = useState(false); const imageSource = useMemo( - () => ({ uri: props.uri, cache: "force-cache" as const }), - [props.uri], + () => ({ + uri: props.source.uri, + headers: props.source.headers, + cache: "force-cache" as const, + }), + [props.source], ); const fullScreenImages = useMemo(() => [imageSource], [imageSource]); @@ -89,16 +94,16 @@ function CachedWorkspaceFileImagePreview(props: { return ( ); } export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; - readonly uri: string | null; + readonly source: AssetRequestSource | null; }) { - if (props.uri === null) { + if (props.source === null) { return ( @@ -109,10 +114,15 @@ export function WorkspaceFileImagePreview(props: { ); } - return ( + return props.source.headers === undefined ? ( + ) : ( + ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index efa7ee88cba..6b088afe596 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -1,15 +1,30 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, View } from "react-native"; import { WebView } from "react-native-webview"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import type { AssetRequestSource } from "../../state/assets"; -export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { +export function WorkspaceFileWebPreview(props: { readonly source: AssetRequestSource | null }) { const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); + const webViewSource = useMemo(() => { + if (props.source === null) return null; + if (props.source.surfaceBinding === undefined) return { uri: props.source.uri }; + const assetUrl = new URL(props.source.uri); + return { + uri: props.source.surfaceBinding.uri, + method: "POST" as const, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + credential: props.source.surfaceBinding.credential, + redirect: `${assetUrl.pathname}${assetUrl.search}`, + }), + }; + }, [props.source]); - if (props.uri === null) { + if (webViewSource === null) { return ( @@ -28,7 +43,7 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) ) : null} ) => void; }) { - const uri = useAssetUrl(props.environmentId, { + const source = useAssetRequestSource(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, }); - if (uri === null) { + if (source === null) { return ( @@ -161,8 +161,15 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - + props.onPressImage(source.uri, source.headers)} + > + ); } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea..94d29cc2400 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -7,15 +7,25 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; + +export interface AssetRequestSource { + readonly uri: string; + readonly headers?: Record; + readonly surfaceBinding?: { + readonly uri: string; + readonly credential: string; + }; +} const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-url:empty"), ); -export function useAssetUrl( +export function useAssetRequestSource( environmentId: EnvironmentId | null, resource: AssetResource | null, -): string | null { +): AssetRequestSource | null { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( environmentId === null || resource === null @@ -25,5 +35,24 @@ export function useAssetUrl( if (preparedConnection._tag === "None" || result._tag !== "Success") { return null; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const uri = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + if (uri === null) return null; + const surfaceCredential = result.value.surfaceCredential; + return { + uri, + ...(surfaceCredential === null || surfaceCredential === undefined + ? {} + : { + headers: { + [ASSET_SURFACE_CREDENTIAL_HEADER]: surfaceCredential, + }, + surfaceBinding: { + uri: new URL( + "/api/assets/relay/surface", + preparedConnection.value.httpBaseUrl, + ).toString(), + credential: surfaceCredential, + }, + }), + }; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 06a22754e55..db35445d29f 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ThreadId } from "@t3tools/contracts"; +import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AuthSessionId, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -9,10 +9,39 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + ASSET_SURFACE_RELAY_PREFIX, + issueAssetUrl as issueAssetUrlImpl, + resolveAsset as resolveAssetImpl, +} from "./AssetAccess.ts"; + +const TEST_SURFACE_SESSION_ID = AuthSessionId.make("asset-access-test-surface"); +const OTHER_SURFACE_SESSION_ID = AuthSessionId.make("asset-access-other-surface"); + +const assetRouteSuffix = (relativeUrl: string) => { + const prefix = relativeUrl.startsWith(`${ASSET_SURFACE_RELAY_PREFIX}/`) + ? ASSET_SURFACE_RELAY_PREFIX + : ASSET_ROUTE_PREFIX; + return relativeUrl.slice(`${prefix}/`.length); +}; + +const issueAssetUrl = (input: Parameters[0]) => + issueAssetUrlImpl({ + ...input, + clientCapabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + surfaceSessionId: TEST_SURFACE_SESSION_ID, + }); + +const resolveAsset = ( + token: string, + relativePath: string, + sessionId: AuthSessionId = TEST_SURFACE_SESSION_ID, +) => resolveAssetImpl(token, relativePath, { sessionId }); const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", @@ -22,6 +51,10 @@ const testLayer = Layer.mergeAll( WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), ServerSecretStore.layer.pipe(Layer.provide(configLayer)), + Layer.mock(SessionStore.SessionStore)({ + cookieName: "t3_asset_access_test", + isActive: () => Effect.succeed(true), + }), ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { @@ -48,7 +81,7 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); @@ -165,7 +198,7 @@ describe("AssetAccess", () => { }, workspaceRoot: root, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); @@ -191,7 +224,7 @@ describe("AssetAccess", () => { const result = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); @@ -199,6 +232,35 @@ describe("AssetAccess", () => { kind: "file", path: attachmentPath, }); + expect(yield* resolveAsset(token, "ignored.png", OTHER_SURFACE_SESSION_ID)).toBeNull(); + expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); + expect(result.relativeUrl).not.toContain(TEST_SURFACE_SESSION_ID); + expect(result.relativeUrl).not.toContain(result.surfaceCredential); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("requires the negotiated capability before returning a private asset URL", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-old-client-00000000-0000-4000-8000-000000000001"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile( + path.join(config.attachmentsDir, `${attachmentId}.png`), + new Uint8Array([1, 2, 3]), + ); + + const error = yield* issueAssetUrlImpl({ + resource: { _tag: "attachment", attachmentId }, + surfaceSessionId: TEST_SURFACE_SESSION_ID, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AssetClientUpgradeRequiredError", + requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + }); + expect(error).not.toHaveProperty("relativeUrl"); }).pipe(Effect.provide(testLayer)), ); @@ -216,7 +278,7 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); - const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const faviconSuffix = assetRouteSuffix(faviconResult.relativeUrl); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( yield* resolveAsset( @@ -230,7 +292,7 @@ describe("AssetAccess", () => { resource: { _tag: "project-favicon", cwd: root }, }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); - const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const fallbackSuffix = assetRouteSuffix(fallbackResult.relativeUrl); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( yield* resolveAsset( diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..03aa21e594d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,5 +1,7 @@ -import type { AssetResource } from "@t3tools/contracts"; import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetClaimJson, + AssetClientUpgradeRequiredError, AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, @@ -12,6 +14,11 @@ import { AssetWorkspacePathValidationError, AssetWorkspaceResolutionError, AssetWorkspaceRootNormalizationError, + AssetSurfaceCredentialClaimJson, + type AssetClaim, + type AssetClientCapability, + type AssetResource, + type AuthSessionId, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, @@ -21,6 +28,7 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -35,12 +43,27 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; +export const ASSET_SURFACE_RELAY_PREFIX = `${ASSET_ROUTE_PREFIX}/relay`; +export const ASSET_SURFACE_BIND_PATH = `${ASSET_SURFACE_RELAY_PREFIX}/surface`; +export const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; + +export function assetSurfaceCookiePrefix(sessionCookieName: string): string { + return `${sessionCookieName}_asset_surface_`; +} + +export function assetSurfaceCookieName( + sessionCookieName: string, + surfaceBindingId: string, +): string { + return `${assetSurfaceCookiePrefix(sessionCookieName)}${surfaceBindingId}`; +} const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -56,44 +79,16 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ".woff2", ]); -const AssetClaimsSchema = Schema.Union([ - Schema.Struct({ - version: Schema.Literal(1), - kind: Schema.Literal("workspace-file"), - workspaceRoot: Schema.String, - baseRelativePath: Schema.String, - expiresAt: Schema.Number, - }), - Schema.Struct({ - version: Schema.Literal(1), - kind: Schema.Literal("workspace-file-exact"), - workspaceRoot: Schema.String, - relativePath: Schema.String, - expiresAt: Schema.Number, - }), - Schema.Struct({ - version: Schema.Literal(1), - kind: Schema.Literal("attachment"), - attachmentId: Schema.String, - expiresAt: Schema.Number, - }), - Schema.Struct({ - version: Schema.Literal(1), - kind: Schema.Literal("project-favicon"), - workspaceRoot: Schema.String, - relativePath: Schema.NullOr(Schema.String), - expiresAt: Schema.Number, - }), -]); -type AssetClaims = typeof AssetClaimsSchema.Type; - -const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); -const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); -const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); +const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimJson); +const encodeAssetClaims = Schema.encodeSync(AssetClaimJson); +const decodeAssetSurfaceCredentialClaims = Schema.decodeUnknownOption( + AssetSurfaceCredentialClaimJson, +); +const encodeAssetSurfaceCredentialClaims = Schema.encodeSync(AssetSurfaceCredentialClaimJson); export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; -function decodeClaims(encodedPayload: string): AssetClaims | null { +function decodeClaims(encodedPayload: string): AssetClaim | null { try { return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); } catch { @@ -101,6 +96,29 @@ function decodeClaims(encodedPayload: string): AssetClaims | null { } } +function decodeSurfaceCredentialClaims(encodedPayload: string) { + try { + return Option.getOrNull( + decodeAssetSurfaceCredentialClaims(base64UrlDecodeUtf8(encodedPayload)), + ); + } catch { + return null; + } +} + +function signToken(encodedPayload: string, signingSecret: Uint8Array): string { + return `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; +} + +function splitSignedToken(token: string): readonly [string, string] | null { + const parts = token.split("."); + return parts.length === 2 && parts[0] && parts[1] ? [parts[0], parts[1]] : null; +} + +function surfaceBindingIdForSession(sessionId: AuthSessionId, signingSecret: Uint8Array): string { + return signPayload(`asset-surface:${sessionId}`, signingSecret); +} + function decodeRelativePath(value: string): string | null { try { return decodeURIComponent(value); @@ -165,12 +183,16 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly clientCapabilities?: ReadonlyArray; + readonly surfaceSessionId?: AuthSessionId; + readonly surfaceSessionExpiresAt?: DateTime.DateTime; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; - let claims: AssetClaims; + const now = yield* Clock.currentTimeMillis; + let expiresAt = now + ASSET_TOKEN_TTL_MS; + let claims: AssetClaim; let fileName: string; switch (input.resource._tag) { @@ -241,6 +263,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, + dataAudience: "private", + surfaceBindingId: null, } : { version: 1, @@ -248,6 +272,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, baseRelativePath: path.dirname(resolved.relativePath), expiresAt, + dataAudience: "private", + surfaceBindingId: null, }; fileName = path.basename(resolved.relativePath); break; @@ -268,6 +294,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, + dataAudience: "private", + surfaceBindingId: null, }; fileName = path.basename(attachmentPath); break; @@ -323,12 +351,32 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), relativePath, expiresAt, + dataAudience: "private", + surfaceBindingId: null, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } + if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { + yield* Effect.logWarning( + "Private asset URL issuance requires a client upgrade for same-origin relay support.", + { + "asset.outcome": "upgrade_required", + "asset.required_capability": ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + "asset.resource_kind": input.resource._tag, + }, + ); + return yield* new AssetClientUpgradeRequiredError({ + resource: input.resource, + requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + }); + } + if (input.surfaceSessionId === undefined) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( @@ -339,20 +387,82 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + const surfaceBindingId = surfaceBindingIdForSession(input.surfaceSessionId, signingSecret); + const surfaceCredentialExpiresAt = Math.min( + expiresAt, + input.surfaceSessionExpiresAt?.epochMilliseconds ?? expiresAt, + ); + expiresAt = surfaceCredentialExpiresAt; + if (expiresAt <= now) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + claims = { ...claims, surfaceBindingId, expiresAt }; const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); - const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; + const token = signToken(encodedPayload, signingSecret); + const surfaceCredential = signToken( + base64UrlEncode( + encodeAssetSurfaceCredentialClaims({ + version: 1, + kind: "asset-surface", + surfaceSessionId: input.surfaceSessionId, + surfaceBindingId, + expiresAt: surfaceCredentialExpiresAt, + }), + ), + signingSecret, + ); return { - relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, + relativeUrl: `${ASSET_SURFACE_RELAY_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, + surfaceCredential, }; }); +export const verifyAssetSurfaceCredential = Effect.fn("AssetAccess.verifyAssetSurfaceCredential")( + function* (credential: string) { + const tokenParts = splitSignedToken(credential); + if (tokenParts === null) return null; + const [encodedPayload, signature] = tokenParts; + + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the asset surface signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) { + return null; + } + + const claims = decodeSurfaceCredentialClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + const sessions = yield* SessionStore.SessionStore; + const active = yield* sessions.isActive(claims.surfaceSessionId).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to verify the asset surface session.", { + sessionId: claims.surfaceSessionId, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); + return active ? claims : null; + }, +); + export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, + requestProof: { + readonly sessionId?: AuthSessionId; + readonly surfaceCredentials?: ReadonlyArray; + } = {}, ) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) return null; + const tokenParts = splitSignedToken(token); + if (tokenParts === null) return null; + const [encodedPayload, signature] = tokenParts; const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( @@ -365,6 +475,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + if (claims.surfaceBindingId === null) return null; + let matchedSurface = + requestProof.sessionId !== undefined && + surfaceBindingIdForSession(requestProof.sessionId, signingSecret) === claims.surfaceBindingId; + if (!matchedSurface) { + for (const credential of requestProof.surfaceCredentials ?? []) { + const surfaceCredential = yield* verifyAssetSurfaceCredential(credential); + if (surfaceCredential?.surfaceBindingId === claims.surfaceBindingId) { + matchedSurface = true; + break; + } + } + } + if (!matchedSurface) return null; + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 90222521591..c164507d1a9 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -17,6 +18,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { cast } from "effect/Function"; +import * as Cookies from "effect/unstable/http/Cookies"; import { HttpBody, HttpClient, @@ -30,9 +32,19 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { + ASSET_ROUTE_PREFIX, + ASSET_SURFACE_BIND_PATH, + ASSET_SURFACE_CREDENTIAL_HEADER, + ASSET_SURFACE_RELAY_PREFIX, + assetSurfaceCookieName, + assetSurfaceCookiePrefix, + resolveAsset, + verifyAssetSurfaceCredential, +} from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as SessionStore from "./auth/SessionStore.ts"; import * as DeviceNotifications from "./notifications/DeviceNotifications.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -531,23 +543,60 @@ export const assetRouteLayer = HttpRouter.add( return HttpServerResponse.text("Bad Request", { status: 400 }); } - const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const isSurfaceRelay = url.value.pathname.startsWith(`${ASSET_SURFACE_RELAY_PREFIX}/`); + const routePrefix = isSurfaceRelay ? ASSET_SURFACE_RELAY_PREFIX : ASSET_ROUTE_PREFIX; + const suffix = url.value.pathname.slice(`${routePrefix}/`.length); const separatorIndex = suffix.indexOf("/"); if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } + const explicitSurfaceCredential = request.headers[ASSET_SURFACE_CREDENTIAL_HEADER]; + const requestSurfaceCredentials = + explicitSurfaceCredential === undefined ? [] : [explicitSurfaceCredential]; + if (isSurfaceRelay) { + const sessions = yield* SessionStore.SessionStore; + const surfaceCookiePrefix = assetSurfaceCookiePrefix(sessions.cookieName); + requestSurfaceCredentials.push( + ...Object.entries(request.cookies) + .filter(([name]) => name.startsWith(surfaceCookiePrefix)) + .map(([, credential]) => credential), + ); + } + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const authenticatedSession = yield* serverAuth + .authenticateHttpRequest(request) + .pipe( + Effect.option, + Effect.map(Option.filter((session) => session.scopes.includes(AuthOrchestrationReadScope))), + ); + const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), + { + ...(Option.isSome(authenticatedSession) + ? { sessionId: authenticatedSession.value.sessionId } + : {}), + surfaceCredentials: requestSurfaceCredentials, + }, ); if (!asset) { + if (isSurfaceRelay) { + yield* Effect.logWarning("Asset surface request was masked as not found.", { + "asset.outcome": "masked_not_found", + "asset.session_proof_present": + Option.isSome(authenticatedSession) || requestSurfaceCredentials.length > 0, + }); + } return HttpServerResponse.text("Not Found", { status: 404 }); } return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { - "Cache-Control": "private, max-age=3600", + // Session-bound assets must be revalidated on every fetch. A private browser cache can + // outlive or switch sessions and would otherwise bypass the masking-404 proof check. + "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }, }).pipe( @@ -556,6 +605,98 @@ export const assetRouteLayer = HttpRouter.add( }), ); +const AssetSurfaceBindingInput = Schema.Struct({ + credential: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096)), + redirect: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(4096))), +}); +const decodeAssetSurfaceBindingInput = Schema.decodeUnknownOption(AssetSurfaceBindingInput); + +function validateAssetSurfaceRedirect(value: string): string | null { + if (!value.startsWith("/") || value.startsWith("//")) return null; + let url: URL; + try { + url = new URL(value, "http://asset.invalid"); + } catch { + return null; + } + const prefix = `${ASSET_SURFACE_RELAY_PREFIX}/`; + if (!url.pathname.startsWith(prefix) || url.pathname === ASSET_SURFACE_BIND_PATH) return null; + const suffix = url.pathname.slice(prefix.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === suffix.length - 1) return null; + return `${url.pathname}${url.search}`; +} + +export const assetSurfaceBindingRouteLayer = HttpRouter.add( + "POST", + ASSET_SURFACE_BIND_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const input = yield* request.json.pipe( + Effect.map(decodeAssetSurfaceBindingInput), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(input)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const redirect = + input.value.redirect === undefined + ? null + : validateAssetSurfaceRedirect(input.value.redirect); + if (input.value.redirect !== undefined && redirect === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const verified = yield* verifyAssetSurfaceCredential(input.value.credential); + if (verified === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const sessions = yield* SessionStore.SessionStore; + const requestOrigin = normalizeCorsOrigin(request.headers.origin); + const forwardedProtocol = request.headers["x-forwarded-proto"] + ?.split(",", 1)[0] + ?.trim() + .toLowerCase(); + const secure = + (requestOrigin !== null && new URL(requestOrigin).protocol === "https:") || + forwardedProtocol === "https:" || + forwardedProtocol === "https" || + (() => { + try { + return new URL(request.originalUrl).protocol === "https:"; + } catch { + return false; + } + })(); + const cookies = yield* Effect.fromResult( + Cookies.set( + Cookies.empty, + assetSurfaceCookieName(sessions.cookieName, verified.surfaceBindingId), + input.value.credential, + { + expires: DateTime.toDate(DateTime.makeUnsafe(verified.expiresAt)), + httpOnly: true, + path: ASSET_SURFACE_RELAY_PREFIX, + sameSite: "lax", + secure, + }, + ), + ).pipe(Effect.orDie); + const response = + redirect === null + ? HttpServerResponse.empty({ + status: 204, + headers: { "Cache-Control": "no-store" }, + }) + : HttpServerResponse.redirect(redirect, { + status: 303, + headers: { "Cache-Control": "no-store" }, + }); + return HttpServerResponse.mergeCookies(response, cookies); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 37c473f65d5..54e1de775e4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,6 +7,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AuthAccessTokenType, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -128,7 +129,6 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; -import * as AssetAccess from "./assets/AssetAccess.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; @@ -1912,16 +1912,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path.join(config.attachmentsDir, `${attachmentId}.bin`), sentinel("assets"), ); - const configLayer = ServerConfig.layer(config); - const assetSetupLayer = Layer.mergeAll( - configLayer, - ServerSecretStore.layer.pipe(Layer.provide(configLayer)), - WorkspacePaths.layer, - ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), - ).pipe(Layer.provideMerge(NodeServices.layer)); - const issuedAssetUrl = yield* AssetAccess.issueAssetUrl({ - resource: { _tag: "attachment", attachmentId }, - }).pipe(Effect.provide(assetSetupLayer)); const issuedMcpPeerToken = yield* McpSessionRegistry.issueActiveMcpPeerCredential({ sourceEnvironmentId: EnvironmentId.make("environment-security-source"), }); @@ -1931,7 +1921,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { } const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const wsUrl = yield* getWsServerUrl("/ws"); + const wsUrl = appendSessionCookieToWsUrl(yield* getWsServerUrl("/ws"), ownerCookie); const authenticatedGet = (pathname: string) => Effect.gen(function* () { const response = yield* fetchEffect(yield* getHttpServerUrl(pathname), { @@ -1945,6 +1935,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const rpc = (f: (client: WsRpcClient) => Effect.Effect) => Effect.scoped(withWsRpcClient(wsUrl, f)); + const issuedAssetUrl = yield* rpc((client) => + client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ); const processProbe = Effect.gen(function* () { const isolation = yield* WorkerProcessIsolation.WorkerProcessIsolation; @@ -2176,6 +2172,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { rpc((client) => client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], }).pipe(Effect.map(encodeSecurityObservation)), ), attachmentId, @@ -2185,6 +2182,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const response = yield* fetchEffect( yield* getHttpServerUrl(issuedAssetUrl.relativeUrl), + { headers: { cookie: ownerCookie } }, ); if (response.status === 401 || response.status === 403) { return yield* new SecurityProbeDenied({ source: "http" }); @@ -2380,6 +2378,89 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("binds private asset URLs to the issuing same-surface session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* buildAppUnderTest(); + const attachmentId = "asset-session-binding-integration"; + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.bin`), + "same-surface-private-asset", + ); + + const issuerCookie = yield* getAuthenticatedSessionCookieHeader(); + const otherCookie = yield* getAuthenticatedSessionCookieHeader(); + assert.notEqual(issuerCookie, otherCookie); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + issuerCookie, + ); + const rpc = (f: (client: WsRpcClient) => Effect.Effect) => + Effect.scoped(withWsRpcClient(wsUrl, f)); + + const oldClientError = yield* rpc((client) => + client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + }), + ).pipe(Effect.flip); + assert.equal(oldClientError._tag, "AssetClientUpgradeRequiredError"); + assert.notProperty(oldClientError, "relativeUrl"); + + const issued = yield* rpc((client) => + client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ); + assert.include(issued.relativeUrl, "/api/assets/relay/"); + assert.isString(issued.surfaceCredential); + assert.notInclude(issued.relativeUrl, issued.surfaceCredential ?? "surface-credential"); + + const assetUrl = yield* getHttpServerUrl(issued.relativeUrl); + const unboundResponse = yield* fetchEffect(assetUrl); + assert.equal(unboundResponse.status, 404); + const crossSurfaceResponse = yield* fetchEffect(assetUrl, { + headers: { cookie: otherCookie }, + }); + assert.equal(crossSurfaceResponse.status, 404); + const sameSurfaceResponse = yield* fetchEffect(assetUrl, { + headers: { cookie: issuerCookie }, + }); + assert.equal(sameSurfaceResponse.status, 200); + assert.equal(sameSurfaceResponse.headers["cache-control"], "no-store"); + assert.equal(yield* sameSurfaceResponse.text, "same-surface-private-asset"); + + const nativeResponse = yield* fetchEffect(assetUrl, { + headers: { "x-t3-asset-surface": issued.surfaceCredential ?? "" }, + }); + assert.equal(nativeResponse.status, 200); + assert.equal(yield* nativeResponse.text, "same-surface-private-asset"); + + const bindingResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/assets/relay/surface"), + { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json" }, + body: jsonRequestBody({ + credential: issued.surfaceCredential, + redirect: issued.relativeUrl, + }), + }, + ); + assert.equal(bindingResponse.status, 303); + assert.equal(bindingResponse.headers.location, issued.relativeUrl); + const surfaceCookie = bindingResponse.headers["set-cookie"]; + assert.isString(surfaceCookie); + assert.include(surfaceCookie ?? "", "HttpOnly"); + assert.include(surfaceCookie ?? "", "SameSite=Lax"); + assert.include(surfaceCookie ?? "", "Path=/api/assets/relay"); + assert.notInclude((surfaceCookie ?? "").toLowerCase(), "domain="); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("serves static index content for GET / when staticDir is configured", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b7a9f2b834e..2dc84544347 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import { notificationRecoveryRouteLayer, mcpPeerTokenRouteLayer, assetRouteLayer, + assetSurfaceBindingRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, @@ -427,6 +428,7 @@ export const makeRoutesLayer = Layer.mergeAll( notificationAckRouteLayer, notificationRecoveryRouteLayer, assetRouteLayer, + assetSurfaceBindingRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index adddaaca82b..c031edfbff1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1762,7 +1762,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => WS_METHODS.assetsCreateUrl, Effect.gen(function* () { if (input.resource._tag !== "workspace-file") { - return yield* issueAssetUrl({ resource: input.resource }); + return yield* issueAssetUrl({ + resource: input.resource, + ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), + surfaceSessionId: currentSession.sessionId, + ...(currentSession.expiresAt + ? { surfaceSessionExpiresAt: currentSession.expiresAt } + : {}), + }); } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.resource.threadId) @@ -1799,6 +1806,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => return yield* issueAssetUrl({ resource: input.resource, workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), + surfaceSessionId: currentSession.sessionId, + ...(currentSession.expiresAt + ? { surfaceSessionExpiresAt: currentSession.expiresAt } + : {}), }); }), { "rpc.aggregate": "workspace" }, diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 58add31d6bb..19d0ef3f700 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, EnvironmentId } from "@t3tools/contracts"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; @@ -8,8 +8,20 @@ import { createAssetEnvironmentAtoms, InvalidAssetCollectionKeyError, parseAssetCollectionKey, + withAssetClientCapabilities, } from "./assets.ts"; +describe("asset client capabilities", () => { + it("advertises same-origin relay support on every asset URL request", () => { + const resource = { _tag: "attachment" as const, attachmentId: "attachment-1" }; + + expect(withAssetClientCapabilities({ resource })).toEqual({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }); + }); +}); + describe("asset collection keys", () => { it("preserves malformed JSON and its native cause", () => { const key = "not-json"; diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..894262c51c4 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,4 +1,10 @@ -import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetResource, + EnvironmentId, + WS_METHODS, + type AssetCreateUrlInput, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; @@ -43,16 +49,33 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin } } +export function withAssetClientCapabilities(input: { + readonly resource: AssetResource; +}): AssetCreateUrlInput { + return { + resource: input.resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }; +} + export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { - const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, { + const createUrlQuery = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", tag: WS_METHODS.assetsCreateUrl, staleTimeMs: ASSET_URL_STALE_TIME_MS, idleTtlMs: ASSET_URL_IDLE_TTL_MS, refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, }); + const createUrl = (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => + createUrlQuery({ + environmentId: target.environmentId, + input: withAssetClientCapabilities(target.input), + }); const createUrlsFamily = Atom.family((key: string) => { const [environmentId, resources] = parseAssetCollectionKey(key); return Atom.make((get) => diff --git a/packages/contracts/src/assetClaims.test.ts b/packages/contracts/src/assetClaims.test.ts new file mode 100644 index 00000000000..ad648f0ea0b --- /dev/null +++ b/packages/contracts/src/assetClaims.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { AssetClaimJson } from "./assetClaims.ts"; + +const decodeClaim = Schema.decodeUnknownSync(AssetClaimJson); +const encodeClaim = Schema.encodeSync(AssetClaimJson); + +describe("asset claim codec", () => { + it("round-trips an attachment claim with its audience", () => { + const claim = { + version: 1 as const, + kind: "attachment" as const, + attachmentId: "attachment-1", + expiresAt: 123, + dataAudience: "factory" as const, + surfaceBindingId: "surface-binding-1", + }; + + expect(decodeClaim(encodeClaim(claim))).toEqual(claim); + }); + + it("decodes legacy claims fail-closed as private and unbound", () => { + expect( + decodeClaim( + JSON.stringify({ + version: 1, + kind: "attachment", + attachmentId: "attachment-legacy", + expiresAt: 123, + }), + ), + ).toEqual({ + version: 1, + kind: "attachment", + attachmentId: "attachment-legacy", + expiresAt: 123, + dataAudience: "private", + surfaceBindingId: null, + }); + }); +}); diff --git a/packages/contracts/src/assetClaims.ts b/packages/contracts/src/assetClaims.ts new file mode 100644 index 00000000000..0bf2d6d24bb --- /dev/null +++ b/packages/contracts/src/assetClaims.ts @@ -0,0 +1,87 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; + +import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { DataAudience, type DataAudience as DataAudienceType } from "./orchestration.ts"; + +const ASSET_SESSION_BINDING_ID_MAX_LENGTH = 256; + +export const AssetSessionBindingId = TrimmedNonEmptyString.check( + Schema.isMaxLength(ASSET_SESSION_BINDING_ID_MAX_LENGTH), +); +export type AssetSessionBindingId = typeof AssetSessionBindingId.Type; + +// Claims issued before audience binding landed had no dataAudience. Decode those +// as private so compatibility can never widen access; every newly encoded claim +// is required to carry the field explicitly. +const AssetClaimDataAudience = Schema.optionalKey(DataAudience).pipe( + Schema.decodeTo(Schema.toType(DataAudience), { + decode: SchemaGetter.withDefault(Effect.succeed("private")), + encode: SchemaGetter.required(), + }), +); + +const NullableAssetSessionBindingId = Schema.NullOr(AssetSessionBindingId); +const AssetClaimSessionBindingId = Schema.optionalKey(NullableAssetSessionBindingId).pipe( + Schema.decodeTo(Schema.toType(NullableAssetSessionBindingId), { + decode: SchemaGetter.withDefault(Effect.succeed(null)), + encode: SchemaGetter.required(), + }), +); + +const AudienceBoundAssetClaimFields = { + dataAudience: AssetClaimDataAudience, + surfaceBindingId: AssetClaimSessionBindingId, +}; + +export const AssetAttachmentClaim = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment"), + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, +}); +export type AssetAttachmentClaim = typeof AssetAttachmentClaim.Type; + +export const AssetClaim = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file"), + workspaceRoot: Schema.String, + baseRelativePath: Schema.String, + expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, + }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-file-exact"), + workspaceRoot: Schema.String, + relativePath: Schema.String, + expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, + }), + AssetAttachmentClaim, + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon"), + workspaceRoot: Schema.String, + relativePath: Schema.NullOr(Schema.String), + expiresAt: Schema.Number, + ...AudienceBoundAssetClaimFields, + }), +]); +export type AssetClaim = typeof AssetClaim.Type; + +export const AssetClaimJson = Schema.fromJsonString(AssetClaim); + +export const AssetSurfaceCredentialClaim = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("asset-surface"), + surfaceSessionId: AuthSessionId, + surfaceBindingId: AssetSessionBindingId, + expiresAt: Schema.Number, +}); +export type AssetSurfaceCredentialClaim = typeof AssetSurfaceCredentialClaim.Type; + +export const AssetSurfaceCredentialClaimJson = Schema.fromJsonString(AssetSurfaceCredentialClaim); diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts new file mode 100644 index 00000000000..dadbae2868c --- /dev/null +++ b/packages/contracts/src/assets.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetResource, +} from "./assets.ts"; + +const LegacyAssetCreateUrlInput = Schema.Struct({ resource: AssetResource }); +const LegacyAssetCreateUrlResult = Schema.Struct({ + relativeUrl: Schema.String, + expiresAt: Schema.Number, +}); +const decodeAssetCreateUrlInput = Schema.decodeUnknownSync(AssetCreateUrlInput); +const decodeAssetCreateUrlResult = Schema.decodeUnknownSync(AssetCreateUrlResult); +const decodeLegacyAssetCreateUrlInput = Schema.decodeUnknownSync(LegacyAssetCreateUrlInput); +const decodeLegacyAssetCreateUrlResult = Schema.decodeUnknownSync(LegacyAssetCreateUrlResult); + +describe("asset protocol compatibility", () => { + const resource = { + _tag: "attachment" as const, + attachmentId: "attachment-1", + }; + + it("decodes old client requests without advertised capabilities", () => { + expect(decodeAssetCreateUrlInput({ resource })).toEqual({ resource }); + }); + + it("decodes the same-origin relay capability advertised by new clients", () => { + expect( + decodeAssetCreateUrlInput({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ).toEqual({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }); + }); + + it("lets an old server decode a new client request", () => { + expect( + decodeLegacyAssetCreateUrlInput({ + resource, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ).toEqual({ resource }); + }); + + it("decodes old server results without a surface credential", () => { + const oldServerResult = { + relativeUrl: "/api/assets/signed/attachment.png", + expiresAt: 123, + }; + + expect(decodeAssetCreateUrlResult(oldServerResult)).toEqual(oldServerResult); + }); + + it("lets an old client decode a new server result", () => { + expect( + decodeLegacyAssetCreateUrlResult({ + relativeUrl: "/api/assets/relay/signed/attachment.png", + expiresAt: 123, + surfaceCredential: "surface.credential", + }), + ).toEqual({ + relativeUrl: "/api/assets/relay/signed/attachment.png", + expiresAt: 123, + }); + }); +}); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 0dbe7d9fa25..d587805ba73 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -3,6 +3,15 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const ASSET_PATH_MAX_LENGTH = 1024; +const ASSET_CLIENT_CAPABILITY_MAX_LENGTH = 64; +const ASSET_CLIENT_CAPABILITIES_MAX_LENGTH = 16; + +export const ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY = "same-origin-relay-v1" as const; + +export const AssetClientCapability = TrimmedNonEmptyString.check( + Schema.isMaxLength(ASSET_CLIENT_CAPABILITY_MAX_LENGTH), +); +export type AssetClientCapability = typeof AssetClientCapability.Type; export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -20,15 +29,35 @@ export type AssetResource = typeof AssetResource.Type; export const AssetCreateUrlInput = Schema.Struct({ resource: AssetResource, + capabilities: Schema.optionalKey( + Schema.Array(AssetClientCapability).check( + Schema.isMaxLength(ASSET_CLIENT_CAPABILITIES_MAX_LENGTH), + ), + ), }); export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, + surfaceCredential: Schema.optionalKey( + Schema.NullOr(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), + ), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export class AssetClientUpgradeRequiredError extends Schema.TaggedErrorClass()( + "AssetClientUpgradeRequiredError", + { + resource: AssetResource, + requiredCapability: Schema.Literal(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY), + }, +) { + override get message(): string { + return "Upgrade the client to load private assets."; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { @@ -181,6 +210,7 @@ export class AssetSigningKeyLoadError extends Schema.TaggedErrorClass Date: Wed, 22 Jul 2026 02:55:39 +0100 Subject: [PATCH 2/3] fix: address asset relay review findings --- .../files/WorkspaceFileImagePreview.tsx | 2 +- apps/mobile/src/state/assets.ts | 4 +- apps/server/src/assets/AssetAccess.test.ts | 22 +++++--- apps/server/src/assets/AssetAccess.ts | 55 +++++++++---------- apps/server/src/http.ts | 15 ++++- apps/server/src/server.test.ts | 29 ++++++++-- .../client-runtime/src/state/assets.test.ts | 12 +++- packages/client-runtime/src/state/assets.ts | 11 +++- 8 files changed, 102 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 90ddcd02b6f..493f8d96029 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -19,7 +19,7 @@ function ResolvedWorkspaceFileImagePreview(props: { () => ({ uri: props.source.uri, headers: props.source.headers, - cache: "force-cache" as const, + ...(props.source.headers === undefined ? { cache: "force-cache" as const } : {}), }), [props.source], ); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 94d29cc2400..ce74e9c50a8 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,7 +6,9 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; -export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime, { + supportsSurfaceCredentials: true, +}); const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; export interface AssetRequestSource { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index db35445d29f..7ec72b78023 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -239,7 +239,7 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("requires the negotiated capability before returning a private asset URL", () => + it.effect("preserves signed direct URLs for clients without surface credentials", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -251,16 +251,24 @@ describe("AssetAccess", () => { new Uint8Array([1, 2, 3]), ); - const error = yield* issueAssetUrlImpl({ + const result = yield* issueAssetUrlImpl({ resource: { _tag: "attachment", attachmentId }, surfaceSessionId: TEST_SURFACE_SESSION_ID, - }).pipe(Effect.flip); + }); + const suffix = assetRouteSuffix(result.relativeUrl); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); - expect(error).toMatchObject({ - _tag: "AssetClientUpgradeRequiredError", - requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + expect(result).toEqual({ + relativeUrl: expect.stringMatching(/^\/api\/assets\/(?!relay\/)/), + expiresAt: expect.any(Number), }); - expect(error).not.toHaveProperty("relativeUrl"); + expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); + expect( + yield* resolveAssetImpl(token, "ignored.png", { + allowUnbound: true, + }), + ).toEqual({ kind: "file", path: path.join(config.attachmentsDir, `${attachmentId}.png`) }); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 03aa21e594d..6d11fdc54b6 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,7 +1,6 @@ import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AssetClaimJson, - AssetClientUpgradeRequiredError, AssetAttachmentNotFoundError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, @@ -359,24 +358,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { - yield* Effect.logWarning( - "Private asset URL issuance requires a client upgrade for same-origin relay support.", - { - "asset.outcome": "upgrade_required", - "asset.required_capability": ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, - "asset.resource_kind": input.resource._tag, - }, - ); - return yield* new AssetClientUpgradeRequiredError({ - resource: input.resource, - requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, - }); - } - if (input.surfaceSessionId === undefined) { - return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); - } - const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( @@ -387,6 +368,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { + const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); + return { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${signToken(encodedPayload, signingSecret)}/${encodeURIComponent(fileName)}`, + expiresAt, + }; + } + if (input.surfaceSessionId === undefined) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } const surfaceBindingId = surfaceBindingIdForSession(input.surfaceSessionId, signingSecret); const surfaceCredentialExpiresAt = Math.min( expiresAt, @@ -458,6 +449,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( requestProof: { readonly sessionId?: AuthSessionId; readonly surfaceCredentials?: ReadonlyArray; + readonly allowUnbound?: boolean; } = {}, ) { const tokenParts = splitSignedToken(token); @@ -475,20 +467,23 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; - if (claims.surfaceBindingId === null) return null; - let matchedSurface = - requestProof.sessionId !== undefined && - surfaceBindingIdForSession(requestProof.sessionId, signingSecret) === claims.surfaceBindingId; - if (!matchedSurface) { - for (const credential of requestProof.surfaceCredentials ?? []) { - const surfaceCredential = yield* verifyAssetSurfaceCredential(credential); - if (surfaceCredential?.surfaceBindingId === claims.surfaceBindingId) { - matchedSurface = true; - break; + if (claims.surfaceBindingId === null) { + if (requestProof.allowUnbound !== true) return null; + } else { + let matchedSurface = + requestProof.sessionId !== undefined && + surfaceBindingIdForSession(requestProof.sessionId, signingSecret) === claims.surfaceBindingId; + if (!matchedSurface) { + for (const credential of requestProof.surfaceCredentials ?? []) { + const surfaceCredential = yield* verifyAssetSurfaceCredential(credential); + if (surfaceCredential?.surfaceBindingId === claims.surfaceBindingId) { + matchedSurface = true; + break; + } } } + if (!matchedSurface) return null; } - if (!matchedSurface) return null; if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c164507d1a9..93bc59e736d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -579,6 +579,7 @@ export const assetRouteLayer = HttpRouter.add( ? { sessionId: authenticatedSession.value.sessionId } : {}), surfaceCredentials: requestSurfaceCredentials, + allowUnbound: !isSurfaceRelay, }, ); if (!asset) { @@ -611,6 +612,14 @@ const AssetSurfaceBindingInput = Schema.Struct({ }); const decodeAssetSurfaceBindingInput = Schema.decodeUnknownOption(AssetSurfaceBindingInput); +function parseAbsoluteUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + function validateAssetSurfaceRedirect(value: string): string | null { if (!value.startsWith("/") || value.startsWith("//")) return null; let url: URL; @@ -654,12 +663,16 @@ export const assetSurfaceBindingRouteLayer = HttpRouter.add( const sessions = yield* SessionStore.SessionStore; const requestOrigin = normalizeCorsOrigin(request.headers.origin); + const requestOriginUrl = requestOrigin === null ? null : parseAbsoluteUrl(requestOrigin); + if (requestOrigin !== null && requestOriginUrl === null) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } const forwardedProtocol = request.headers["x-forwarded-proto"] ?.split(",", 1)[0] ?.trim() .toLowerCase(); const secure = - (requestOrigin !== null && new URL(requestOrigin).protocol === "https:") || + requestOriginUrl?.protocol === "https:" || forwardedProtocol === "https:" || forwardedProtocol === "https" || (() => { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 54e1de775e4..3df7b287c38 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2400,13 +2400,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const rpc = (f: (client: WsRpcClient) => Effect.Effect) => Effect.scoped(withWsRpcClient(wsUrl, f)); - const oldClientError = yield* rpc((client) => + const legacyIssued = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId }, }), - ).pipe(Effect.flip); - assert.equal(oldClientError._tag, "AssetClientUpgradeRequiredError"); - assert.notProperty(oldClientError, "relativeUrl"); + ); + assert.include(legacyIssued.relativeUrl, "/api/assets/"); + assert.notInclude(legacyIssued.relativeUrl, "/api/assets/relay/"); + assert.notProperty(legacyIssued, "surfaceCredential"); + const legacyResponse = yield* fetchEffect(yield* getHttpServerUrl(legacyIssued.relativeUrl)); + assert.equal(legacyResponse.status, 200); + assert.equal(yield* legacyResponse.text, "same-surface-private-asset"); const issued = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ @@ -2438,6 +2442,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(nativeResponse.status, 200); assert.equal(yield* nativeResponse.text, "same-surface-private-asset"); + for (const origin of ["null", "not an origin"]) { + const rejectedBindingResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/assets/relay/surface"), + { + method: "POST", + redirect: "manual", + headers: { "content-type": "application/json", origin }, + body: jsonRequestBody({ + credential: issued.surfaceCredential, + redirect: issued.relativeUrl, + }), + }, + ); + assert.equal(rejectedBindingResponse.status, 404); + assert.isUndefined(rejectedBindingResponse.headers["set-cookie"]); + } + const bindingResponse = yield* fetchEffect( yield* getHttpServerUrl("/api/assets/relay/surface"), { diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 19d0ef3f700..8de09b511d8 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -12,10 +12,18 @@ import { } from "./assets.ts"; describe("asset client capabilities", () => { - it("advertises same-origin relay support on every asset URL request", () => { + it("does not advertise same-origin relay support for bearer/DPoP web clients", () => { const resource = { _tag: "attachment" as const, attachmentId: "attachment-1" }; - expect(withAssetClientCapabilities({ resource })).toEqual({ + expect(withAssetClientCapabilities({ resource, supportsSurfaceCredentials: false })).toEqual({ + resource, + }); + }); + + it("advertises same-origin relay support for clients that present surface credentials", () => { + const resource = { _tag: "attachment" as const, attachmentId: "attachment-1" }; + + expect(withAssetClientCapabilities({ resource, supportsSurfaceCredentials: true })).toEqual({ resource, capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], }); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 894262c51c4..736e8d3053c 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -51,15 +51,19 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin export function withAssetClientCapabilities(input: { readonly resource: AssetResource; + readonly supportsSurfaceCredentials?: boolean; }): AssetCreateUrlInput { return { resource: input.resource, - capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + ...(input.supportsSurfaceCredentials === true + ? { capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY] } + : {}), }; } export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, + options: { readonly supportsSurfaceCredentials?: boolean } = {}, ) { const createUrlQuery = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", @@ -74,7 +78,10 @@ export function createAssetEnvironmentAtoms( }) => createUrlQuery({ environmentId: target.environmentId, - input: withAssetClientCapabilities(target.input), + input: withAssetClientCapabilities({ + ...target.input, + supportsSurfaceCredentials: options.supportsSurfaceCredentials === true, + }), }); const createUrlsFamily = Atom.family((key: string) => { const [environmentId, resources] = parseAssetCollectionKey(key); From 226cc90a8d85c012dff6855d592a9d1d56080473 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:49:54 +0100 Subject: [PATCH 3/3] fix: harden asset relay client boundaries --- .../files/WorkspaceFileImagePreview.tsx | 10 ++----- .../files/workspaceFileImageSource.test.ts | 26 +++++++++++++++++++ .../files/workspaceFileImageSource.ts | 11 ++++++++ apps/mobile/src/state/assets.ts | 3 ++- apps/server/src/http.ts | 3 --- apps/server/src/server.test.ts | 9 ++++--- apps/web/src/state/assets.ts | 6 ++++- .../client-runtime/src/state/assets.test.ts | 17 +++++++----- packages/client-runtime/src/state/assets.ts | 10 ++++--- 9 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 apps/mobile/src/features/files/workspaceFileImageSource.test.ts create mode 100644 apps/mobile/src/features/files/workspaceFileImageSource.ts diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 493f8d96029..f31517bb1e0 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -8,6 +8,7 @@ import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; import type { AssetRequestSource } from "../../state/assets"; +import { workspaceFileImageSource } from "./workspaceFileImageSource"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; @@ -15,14 +16,7 @@ function ResolvedWorkspaceFileImagePreview(props: { }) { const [loadError, setLoadError] = useState(null); const [fullScreenVisible, setFullScreenVisible] = useState(false); - const imageSource = useMemo( - () => ({ - uri: props.source.uri, - headers: props.source.headers, - ...(props.source.headers === undefined ? { cache: "force-cache" as const } : {}), - }), - [props.source], - ); + const imageSource = useMemo(() => workspaceFileImageSource(props.source), [props.source]); const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( diff --git a/apps/mobile/src/features/files/workspaceFileImageSource.test.ts b/apps/mobile/src/features/files/workspaceFileImageSource.test.ts new file mode 100644 index 00000000000..5518f2a2335 --- /dev/null +++ b/apps/mobile/src/features/files/workspaceFileImageSource.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { workspaceFileImageSource } from "./workspaceFileImageSource"; + +describe("workspaceFileImageSource", () => { + it("force-caches only local cached-file sources", () => { + expect(workspaceFileImageSource({ uri: "file:///cache/image.png" })).toEqual({ + uri: "file:///cache/image.png", + headers: undefined, + cache: "force-cache", + }); + }); + + it("reloads session-bound relay sources instead of force-caching them", () => { + expect( + workspaceFileImageSource({ + uri: "https://environment.example/api/assets/relay/token/image.png", + headers: { "x-t3-asset-surface": "surface.credential" }, + }), + ).toEqual({ + uri: "https://environment.example/api/assets/relay/token/image.png", + headers: { "x-t3-asset-surface": "surface.credential" }, + cache: "reload", + }); + }); +}); diff --git a/apps/mobile/src/features/files/workspaceFileImageSource.ts b/apps/mobile/src/features/files/workspaceFileImageSource.ts new file mode 100644 index 00000000000..0a0034ddabb --- /dev/null +++ b/apps/mobile/src/features/files/workspaceFileImageSource.ts @@ -0,0 +1,11 @@ +import type { AssetRequestSource } from "../../state/assets"; + +export function workspaceFileImageSource(source: AssetRequestSource) { + return { + uri: source.uri, + headers: source.headers, + // Relay-backed images are session-bound and served with no-store. Revalidate them instead of + // allowing React Native to reuse a surface credential after it has expired or been revoked. + cache: source.headers === undefined ? ("force-cache" as const) : ("reload" as const), + }; +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index ce74e9c50a8..46747060bbf 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -7,7 +7,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime, { - supportsSurfaceCredentials: true, + // Native images attach the credential header and native WebViews bind the relay cookie. + surfaceCredentialBinding: "native-header-or-cookie", }); const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 93bc59e736d..437ba791ae1 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -664,9 +664,6 @@ export const assetSurfaceBindingRouteLayer = HttpRouter.add( const sessions = yield* SessionStore.SessionStore; const requestOrigin = normalizeCorsOrigin(request.headers.origin); const requestOriginUrl = requestOrigin === null ? null : parseAbsoluteUrl(requestOrigin); - if (requestOrigin !== null && requestOriginUrl === null) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } const forwardedProtocol = request.headers["x-forwarded-proto"] ?.split(",", 1)[0] ?.trim() diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3df7b287c38..7de0ede0333 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2443,7 +2443,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(yield* nativeResponse.text, "same-surface-private-asset"); for (const origin of ["null", "not an origin"]) { - const rejectedBindingResponse = yield* fetchEffect( + const nonHttpsBindingResponse = yield* fetchEffect( yield* getHttpServerUrl("/api/assets/relay/surface"), { method: "POST", @@ -2455,8 +2455,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), }, ); - assert.equal(rejectedBindingResponse.status, 404); - assert.isUndefined(rejectedBindingResponse.headers["set-cookie"]); + assert.equal(nonHttpsBindingResponse.status, 303); + assert.equal(nonHttpsBindingResponse.headers.location, issued.relativeUrl); + const nonHttpsSurfaceCookie = nonHttpsBindingResponse.headers["set-cookie"]; + assert.isString(nonHttpsSurfaceCookie); + assert.notInclude(nonHttpsSurfaceCookie ?? "", "Secure"); } const bindingResponse = yield* fetchEffect( diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b..fcf5f4cd19d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -2,4 +2,8 @@ import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/asset import { connectionAtomRuntime } from "../connection/runtime"; -export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +// Browser requests cannot attach the relay's surface credential. Keep web on the +// pre-relay signed URL path until a browser-specific credential binding is implemented. +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime, { + surfaceCredentialBinding: "none", +}); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 8de09b511d8..1e34eda2be2 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -12,18 +12,23 @@ import { } from "./assets.ts"; describe("asset client capabilities", () => { - it("does not advertise same-origin relay support for bearer/DPoP web clients", () => { + it("keeps plain-URL web image consumers on legacy asset URLs", () => { const resource = { _tag: "attachment" as const, attachmentId: "attachment-1" }; - expect(withAssetClientCapabilities({ resource, supportsSurfaceCredentials: false })).toEqual({ + expect(withAssetClientCapabilities({ resource, surfaceCredentialBinding: "none" })).toEqual({ resource, }); }); - it("advertises same-origin relay support for clients that present surface credentials", () => { + it("advertises same-origin relay support for native header/cookie binding", () => { const resource = { _tag: "attachment" as const, attachmentId: "attachment-1" }; - expect(withAssetClientCapabilities({ resource, supportsSurfaceCredentials: true })).toEqual({ + expect( + withAssetClientCapabilities({ + resource, + surfaceCredentialBinding: "native-header-or-cookie", + }), + ).toEqual({ resource, capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], }); @@ -58,7 +63,7 @@ describe("createAssetEnvironmentAtoms", () => { EnvironmentRegistry, never >; - const assets = createAssetEnvironmentAtoms(runtime); + const assets = createAssetEnvironmentAtoms(runtime, { surfaceCredentialBinding: "none" }); const environmentId = EnvironmentId.make("environment-1"); const originalTarget = { environmentId, @@ -105,7 +110,7 @@ describe("createAssetEnvironmentAtoms", () => { EnvironmentRegistry, never >; - const assets = createAssetEnvironmentAtoms(runtime); + const assets = createAssetEnvironmentAtoms(runtime, { surfaceCredentialBinding: "none" }); const environmentId = EnvironmentId.make("environment-1"); const resources = [ { _tag: "attachment" as const, attachmentId: "attachment-1" }, diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 736e8d3053c..dc765645cd6 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -49,13 +49,15 @@ export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): strin } } +export type AssetSurfaceCredentialBinding = "none" | "native-header-or-cookie"; + export function withAssetClientCapabilities(input: { readonly resource: AssetResource; - readonly supportsSurfaceCredentials?: boolean; + readonly surfaceCredentialBinding: AssetSurfaceCredentialBinding; }): AssetCreateUrlInput { return { resource: input.resource, - ...(input.supportsSurfaceCredentials === true + ...(input.surfaceCredentialBinding === "native-header-or-cookie" ? { capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY] } : {}), }; @@ -63,7 +65,7 @@ export function withAssetClientCapabilities(input: { export function createAssetEnvironmentAtoms( runtime: Atom.AtomRuntime, - options: { readonly supportsSurfaceCredentials?: boolean } = {}, + options: { readonly surfaceCredentialBinding: AssetSurfaceCredentialBinding }, ) { const createUrlQuery = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", @@ -80,7 +82,7 @@ export function createAssetEnvironmentAtoms( environmentId: target.environmentId, input: withAssetClientCapabilities({ ...target.input, - supportsSurfaceCredentials: options.supportsSurfaceCredentials === true, + surfaceCredentialBinding: options.surfaceCredentialBinding, }), }); const createUrlsFamily = Atom.family((key: string) => {