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
41 changes: 38 additions & 3 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts";
import {
AssetGitHubAttachmentUrlValidationError,
AssetPreviewTypeValidationError,
ThreadId,
} from "@t3tools/contracts";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import { describe, expect, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -31,6 +35,31 @@ const testLayer = Layer.mergeAll(
).pipe(Layer.provideMerge(NodeServices.layer));

describe("AssetAccess", () => {
it.effect("signs only GitHub user attachment URLs", () =>
Effect.gen(function* () {
const url = "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/user-attachments/assets/dec6b30a-7724-4590-802a-af5586a2724d";
const result = yield* issueAssetUrl({
resource: { _tag: "github-user-attachment", url },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
expect(
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
).toEqual({
kind: "github-user-attachment",
url,
});

const error = yield* issueAssetUrl({
resource: {
_tag: "github-user-attachment",
url: "https://example.com/user-attachments/assets/dec6b30a-7724-4590-802a-af5586a2724d",
},
}).pipe(Effect.flip);
expect(error).toBeInstanceOf(AssetGitHubAttachmentUrlValidationError);
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -191,7 +220,9 @@ describe("AssetAccess", () => {
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.makeDirectory(config.attachmentsDir, {
recursive: true,
});
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
Expand Down Expand Up @@ -299,7 +330,11 @@ describe("AssetAccess", () => {
yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "<svg>saved</svg>");

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root, path: "brand/hint.svg" },
resource: {
_tag: "project-favicon",
cwd: root,
path: "brand/hint.svg",
},
projectFaviconPath: "brand/saved.svg",
});

Expand Down
62 changes: 59 additions & 3 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AssetResource } from "@t3tools/contracts";
import {
AssetAttachmentNotFoundError,
AssetPreviewTypeValidationError,
AssetGitHubAttachmentUrlValidationError,
AssetProjectFaviconInspectionError,
AssetProjectFaviconNotFoundError,
AssetProjectFaviconResolutionError,
Expand Down Expand Up @@ -88,14 +89,40 @@ const AssetClaimsSchema = Schema.Union([
relativePath: Schema.NullOr(Schema.String),
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("github-user-attachment"),
url: 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);

export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
export type ResolvedAsset =
| { readonly kind: "file"; readonly path: string }
| { readonly kind: "github-user-attachment"; readonly url: string };

export function isGitHubUserAttachmentUrl(value: string): boolean {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === "github.com" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
url.search === "" &&
url.hash === "" &&
/^\/user-attachments\/assets\/[0-9a-f-]+$/i.test(url.pathname)
);
} catch {
return false;
}
}

function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
Expand Down Expand Up @@ -302,11 +329,16 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
);
const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) {
return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
return yield* new AssetPreviewTypeValidationError({
resource: input.resource,
});
}
sourcePath = relativePath ?? undefined;
const canonicalFaviconPath = relativePath
? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
? yield* resolveCanonicalWorkspaceFile({
workspaceRoot,
relativePath,
}).pipe(
Effect.mapError(
(cause) =>
new AssetProjectFaviconInspectionError({
Expand Down Expand Up @@ -363,6 +395,21 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}
break;
}
case "github-user-attachment": {
if (!isGitHubUserAttachmentUrl(input.resource.url)) {
return yield* new AssetGitHubAttachmentUrlValidationError({
resource: input.resource,
});
Comment thread
artieeg marked this conversation as resolved.
}
claims = {
version: 1,
kind: "github-user-attachment",
url: input.resource.url,
expiresAt,
};
fileName = path.basename(new URL(input.resource.url).pathname);
break;
}
}

const secretStore = yield* ServerSecretStore.ServerSecretStore;
Expand Down Expand Up @@ -409,6 +456,15 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
const claims = decodeClaims(encodedPayload);
if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;

if (claims.kind === "github-user-attachment") {
return isGitHubUserAttachmentUrl(claims.url)
? ({
kind: "github-user-attachment",
url: claims.url,
} satisfies ResolvedAsset)
: null;
}

if (claims.kind === "attachment") {
const config = yield* ServerConfig.ServerConfig;
const attachmentPath = resolveAttachmentPathById({
Expand Down
76 changes: 75 additions & 1 deletion apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { describe } from "vite-plus/test";

import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
import * as ProcessRunner from "./processRunner.ts";
import {
assetResponseHeaders,
isLoopbackHostname,
proxyGitHubUserAttachment,
resolveDevRedirectUrl,
} from "./http.ts";

const processResult = (stdout: string): ProcessRunner.ProcessRunOutput => ({
stdout,
stderr: "",
code: 0 as ProcessRunner.ProcessRunOutput["code"],
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
});

describe("http dev routing", () => {
it("treats localhost and loopback addresses as local", () => {
Expand Down Expand Up @@ -36,6 +56,12 @@ describe("assetResponseHeaders", () => {
expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty(
"Content-Security-Policy",
);
expect(
assetResponseHeaders(
"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/user-attachments/assets/00000000-0000-0000-0000-000000000000",
"image/svg+xml",
),
).toHaveProperty("Content-Security-Policy");
});

it("does not apply document policy to raster images", () => {
Expand All @@ -45,3 +71,51 @@ describe("assetResponseHeaders", () => {
});
});
});

describe("GitHub attachment proxy", () => {
it.effect("returns 404 without a GitHub token", () =>
proxyGitHubUserAttachment("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/user-attachments/assets/id").pipe(
Effect.provide(
Layer.merge(
Layer.succeed(ProcessRunner.ProcessRunner, {
run: () => Effect.succeed(processResult("")),
}),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("unexpected fetch")),
),
),
),
Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(404))),
),
);

it.effect("streams authenticated images with safe headers", () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("<svg/>", { headers: { "content-type": "image/svg+xml" } }),
),
),
);
return proxyGitHubUserAttachment("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/user-attachments/assets/id").pipe(
Effect.provide(
Layer.merge(
Layer.succeed(ProcessRunner.ProcessRunner, {
run: () => Effect.succeed(processResult("token\n")),
}),
Layer.succeed(HttpClient.HttpClient, httpClient),
),
),
Effect.tap((response) =>
Effect.sync(() => {
expect(response.status).toBe(200);
expect(response.headers["content-type"]).toBe("image/svg+xml");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.body._tag).toBe("Stream");
}),
),
);
});
});
66 changes: 61 additions & 5 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { cast } from "effect/Function";
import {
HttpBody,
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpMiddleware,
HttpRouter,
Expand All @@ -38,23 +39,69 @@ import {
failEnvironmentInternal,
} from "./auth/http.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as ProcessRunner from "./processRunner.ts";
import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";

const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";

export function assetResponseHeaders(filePath: string): Record<string, string> {
export function assetResponseHeaders(
filePath: string,
contentType?: string,
): Record<string, string> {
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
...(filePath.toLowerCase().endsWith(".svg")
...(contentType === "image/svg+xml" || filePath.toLowerCase().endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
};
}

export const proxyGitHubUserAttachment = Effect.fn("proxyGitHubUserAttachment")(function* (
url: string,
) {
const processRunner = yield* ProcessRunner.ProcessRunner;
const token = yield* processRunner
.run({
command: "gh",
args: ["auth", "token", "--hostname", "github.com"],
timeout: "10 seconds",
maxOutputBytes: 4096,
})
.pipe(
Effect.map((result) => (result.code === 0 ? result.stdout.trim() : "")),
Effect.orElseSucceed(() => ""),
);
if (token.length === 0) return HttpServerResponse.text("Not Found", { status: 404 });

const httpClient = yield* HttpClient.HttpClient;
const response = yield* httpClient
.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.setHeader("accept", "image/*"),
HttpClientRequest.bearerToken(token),
),
)
.pipe(Effect.orElseSucceed(() => null));
if (!response || response.status < 200 || response.status >= 300) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const contentType = response.headers["content-type"]?.split(";", 1)[0] ?? "";
if (!contentType.startsWith("image/")) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
return HttpServerResponse.stream(response.stream, {
status: 200,
headers: {
...assetResponseHeaders(url, contentType),
"Content-Type": contentType,
},
});
});

export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
global: true,
});
Expand Down Expand Up @@ -217,6 +264,9 @@ export const assetRouteLayer = HttpRouter.add(
if (!asset) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
if (asset.kind === "github-user-attachment") {
Comment thread
artieeg marked this conversation as resolved.
Outdated
return yield* proxyGitHubUserAttachment(asset.url).pipe(Effect.provide(ProcessRunner.layer));
}
return yield* HttpServerResponse.file(asset.path, {
status: 200,
headers: assetResponseHeaders(asset.path),
Expand Down Expand Up @@ -270,7 +320,9 @@ export const staticAndDevRouteLayer = HttpRouter.add(
hasPathTraversalSegment ||
staticRelativePath.includes("\0")
) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
return HttpServerResponse.text("Invalid static file path", {
status: 400,
});
}

const isWithinStaticRoot = (candidate: string) =>
Expand All @@ -279,14 +331,18 @@ export const staticAndDevRouteLayer = HttpRouter.add(

let filePath = path.resolve(staticRoot, staticRelativePath);
if (!isWithinStaticRoot(filePath)) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
return HttpServerResponse.text("Invalid static file path", {
status: 400,
});
}

const ext = path.extname(filePath);
if (!ext) {
filePath = path.resolve(filePath, "index.html");
if (!isWithinStaticRoot(filePath)) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
return HttpServerResponse.text("Invalid static file path", {
status: 400,
});
}
}

Expand Down
Loading
Loading