Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,24 +72,26 @@ 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;
const root = yield* fileSystem.makeTempDirectoryScoped({
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, "<p>outside</p>");
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);
Expand All @@ -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, "<p>report</p>");
yield* fileSystem.writeFileString(siblingPath, "<p>other</p>");
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;
Expand Down
25 changes: 20 additions & 5 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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) =>
Expand All @@ -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(
Expand All @@ -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({
Expand All @@ -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",
Expand Down
Loading