diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 568dc3739c00..9a9c62dd2d7b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -72,7 +72,7 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects workspace files outside the authorized root", () => + it.effect("rejects relative workspace paths outside the authorized root", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -80,16 +80,18 @@ describe("AssetAccess", () => { prefix: "t3-asset-root-", }); const outside = yield* fileSystem.makeTempDirectoryScoped({ + directory: process.cwd(), prefix: "t3-asset-outside-", }); const htmlPath = path.join(outside, "report.html"); yield* fileSystem.writeFileString(htmlPath, "

outside

"); + const relativePath = path.relative(root, htmlPath); const error = yield* issueAssetUrl({ resource: { _tag: "workspace-file", threadId: ThreadId.make("thread-1"), - path: htmlPath, + path: relativePath, }, workspaceRoot: root, }).pipe(Effect.flip); @@ -99,13 +101,50 @@ describe("AssetAccess", () => { resource: { _tag: "workspace-file", threadId: "thread-1", - path: htmlPath, + path: relativePath, }, }); expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); }).pipe(Effect.provide(testLayer)), ); + it.effect("issues exact URLs for absolute files outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-root-", + }); + const delivery = yield* fileSystem.makeTempDirectoryScoped({ + directory: process.cwd(), + prefix: "t3-asset-delivery-", + }); + const htmlPath = path.join(delivery, "report.html"); + const siblingPath = path.join(delivery, "other.html"); + yield* fileSystem.writeFileString(htmlPath, "

report

"); + yield* fileSystem.writeFileString(siblingPath, "

other

"); + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: htmlPath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: canonicalHtmlPath, + }); + expect(yield* resolveAsset(token, "other.html")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c00f7f1a5e3c..2d9168a8498a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -97,6 +97,16 @@ const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +function isWithinRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + function decodeClaims(encodedPayload: string): AssetClaims | null { try { return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload))); @@ -193,11 +203,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + const isExternalFile = + path.isAbsolute(input.resource.path) && + !isWithinRoot(path, workspaceRoot, input.resource.path); + const assetRoot = isExternalFile ? path.dirname(input.resource.path) : workspaceRoot; const relativePath = path.isAbsolute(input.resource.path) - ? path.relative(workspaceRoot, input.resource.path) + ? path.relative(assetRoot, input.resource.path) : input.resource.path; const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) + .resolveRelativePathWithinRoot({ workspaceRoot: assetRoot, relativePath }) .pipe( Effect.mapError( (cause) => @@ -213,7 +227,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }); } const canonicalFile = yield* resolveCanonicalWorkspaceFile({ - workspaceRoot, + workspaceRoot: assetRoot, relativePath: resolved.relativePath, }).pipe( Effect.mapError( @@ -229,7 +243,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } - const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + const canonicalWorkspaceRoot = yield* fileSystem.realPath(assetRoot).pipe( Effect.mapError( (cause) => new AssetWorkspaceResolutionError({ @@ -238,7 +252,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - claims = isWorkspaceImagePreviewPath(resolved.relativePath) + const isExactAsset = isExternalFile || isWorkspaceImagePreviewPath(resolved.relativePath); + claims = isExactAsset ? { version: 1, kind: "workspace-file-exact",