Skip to content
Merged
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
13 changes: 9 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 22 additions & 5 deletions cloudflare/artifact-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { CanvasHttpRequest } from "../src/httpTypes";
import { PluginError, PLUGIN_JSON_LIMIT, type PluginInvocationContext } from "./plugins";
import type { PluginRequest, PluginUser } from "../src/plugins/types";
import galleryBridge from "../dist/cloudflare/gallery-request.json";
import navigationHost from "../dist/cloudflare/navigation-host.json";
import { canvasApiRequest, canvasApiResponse } from "./canvas-api";

export async function identify(request: Request, env: Env): Promise<{ privateKey: string; user: PluginUser } | Response> {
if (env.AUTH_MODE && !["access", "better-auth"].includes(env.AUTH_MODE)) return Response.json({ error: "Invalid AUTH_MODE" }, { status: 503 });
Expand All @@ -20,8 +22,8 @@ export async function identify(request: Request, env: Env): Promise<{ privateKey
return identity instanceof Response ? identity : { privateKey: JSON.stringify(["private", env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]), user: identity };
}
const scriptJson = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c");
function html(body: string, policy: string): Response {
return new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": policy, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } });
function html(body: string, policy: string, head: boolean): Response {
return new Response(head ? null : body, { headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": policy, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } });
}

/** Root slug dispatch is separate from management authentication. */
Expand Down Expand Up @@ -66,6 +68,19 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
}
if (!link.version_id) return new Response("Canvas has no valid version", { status: 409 });
const service = new CloudCanvasService(env.LIBRARIES.getByName(link.libraryKey), link.workspace, env.BACKENDS, link.libraryKey, undefined, plugins);
if (url.pathname === `/${slug}/api` || url.pathname.startsWith(`/${slug}/api/`)) {
const origin = request.headers.get("Origin");
if (origin && origin !== url.origin) return new Response("Origin is not allowed", { status: 403 });
const selection = { name: link.name, version_id: link.version_id };
if ((await service.snapshot({ version_id: link.version_id })).server_source === null) return Response.json({ error: "Canvas has no server" }, { status: 404 });
let input: CanvasHttpRequest;
try { input = await canvasApiRequest(request, `/${slug}`, link.access === "private"); }
catch (error) {
if (error instanceof RangeError) return Response.json({ error: error.message }, { status: 413 });
throw error;
}
return canvasApiResponse(await service.request(selection, input), request.method);
}
if (url.pathname === `/${slug}/_canvas/plugins`) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405, headers: { Allow: "POST" } });
let input: PluginRequest;
Expand All @@ -88,15 +103,17 @@ export async function artifactRoute(request: Request, env: Env): Promise<Respons
if (args.version_id !== link.version_id) return Response.json({ error: "Canvas updated; reload the page" }, { status: 409 });
return Response.json({ response: await service.request({ name: link.name, version_id: link.version_id }, args.request) }, { headers: { "cache-control": "no-store" } });
}
if (url.pathname !== `/${slug}` && url.pathname !== `/${slug}/`) return new Response("Not found", { status: 404 });
if (url.pathname === `/${slug}/_canvas` || url.pathname.startsWith(`/${slug}/_canvas/`)) return new Response("Not found", { status: 404 });
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
const snapshot = await service.snapshot({ version_id: link.version_id });
const preview = await service.preview(snapshot);
if (!preview.ok || !preview._meta) return new Response(preview.check, { status: 400 });
const payload = preview._meta.canvas;
const frame = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'"><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}#root{padding:24px}</style></head><body><div id="root"></div><script>window.__herdrCanvas=${scriptJson({ canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, ...(payload.server ? { serverVersionId: payload.versionId } : {}) })};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
const frame = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'"><style>body{margin:0;background:#181818;color:#f0f0f0;font-family:system-ui,sans-serif}#root{padding:24px}</style></head><body><div id="root"></div><script>window.__herdrCanvas=${scriptJson({ canvasId: payload.name, state: payload.state, theme: { kind: "dark" }, plugins: payload.plugins, route: { path: (url.pathname.slice(slug.length + 1) || "/") + url.search, basePath: `/${slug}`, external: true }, ...(payload.server ? { serverVersionId: payload.versionId } : {}) })};${galleryBridge.replace(/<\/script/gi, "<\\/script")}</script><script type="module">${payload.js.replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
return html(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Canvas</title><style>html,body,iframe{width:100%;height:100%;margin:0;border:0;display:block;background:#181818}</style></head><body><iframe title="Canvas" sandbox="allow-scripts"></iframe><script>
const frame=document.querySelector('iframe');frame.srcdoc=${scriptJson(frame)};
window.__canvasNavigationHost={frame,basePath:${scriptJson(`/${slug}`)}};
${navigationHost.replace(/<\/script/gi, "<\\/script")}
const pending=new Set();
window.addEventListener('message',async event=>{
const data=event.data;
Expand All @@ -113,5 +130,5 @@ window.addEventListener('message',async event=>{
frame.contentWindow.postMessage({type:responseType,id:data.id,...(plugin?{result:result.result}:{response:result.response})},'*');
}catch(error){frame.contentWindow.postMessage({type:responseType,id:data.id,error:String(error.message||error)},'*');}
finally{pending.delete(data.id);}
});</script></body></html>`, "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' about:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
});</script></body></html>`, "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' about:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", request.method === "HEAD");
}
51 changes: 51 additions & 0 deletions cloudflare/canvas-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from "bun:test";
import { canvasApiRequest, canvasApiResponse } from "./canvas-api";

test("direct API envelopes preserve binary bodies, query strings and public application credentials", async () => {
const bytes = new Uint8Array([0, 255, 128, 10, 13]);
const input = await canvasApiRequest(new Request("https://canvas.example/demo/api/items?a=1&a=%FF", {
method: "PATCH", body: bytes,
headers: { cookie: "session=private", "cf-access-jwt-assertion": "jwt", "cf-access-client-id": "id", "cf-access-client-secret": "secret", authorization: "Bearer app-token", "x-input": "kept" },
}), "/demo", false);
expect(input.path).toBe("/api/items?a=1&a=%FF");
expect(input.method).toBe("PATCH");
expect(Uint8Array.from(atob(input.body!), char => char.charCodeAt(0))).toEqual(bytes);
const headers = new Headers(input.headers);
for (const name of ["cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) expect(headers.has(name)).toBe(false);
expect(headers.get("authorization")).toBe("Bearer app-token");
expect(headers.get("x-input")).toBe("kept");
const response = canvasApiResponse({ status: 201, statusText: "Created", headers: [["x-output", "kept"], ["set-cookie", "session=forged"], ["content-type", "application/octet-stream"]], body: input.body }, "PATCH");
expect(response.status).toBe(201);
expect(response.statusText).toBe("Created");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
expect(response.headers.get("x-output")).toBe("kept");
expect(response.headers.has("set-cookie")).toBe(false);
expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts allow-forms");
});

test("private API requests remove authorization and GET/HEAD requests have no body", async () => {
for (const method of ["GET", "HEAD"]) {
const input = await canvasApiRequest(new Request("https://canvas.example/demo/api", { method, headers: { authorization: "Bearer management" } }), "/demo", true);
expect(input.path).toBe("/api");
expect(input.body).toBeUndefined();
expect(new Headers(input.headers).has("authorization")).toBe(false);
}
});

test("direct API requests enforce the 256 KiB byte boundary", async () => {
const atLimit = await canvasApiRequest(new Request("https://canvas.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024) }), "/demo", false);
expect(atob(atLimit.body!).length).toBe(256 * 1024);
await expect(canvasApiRequest(new Request("https://canvas.example/demo/api", { method: "POST", body: new Uint8Array(256 * 1024 + 1) }), "/demo", false)).rejects.toThrow("256 KiB");
});

test("API responses preserve not-found status, sandbox HTML and suppress bodyless payloads", async () => {
const response = canvasApiResponse({ status: 404, statusText: "Not Found", headers: [["content-type", "text/html"], ["content-security-policy", "default-src 'self'"]], body: btoa("<h1>Missing</h1>") }, "GET");
expect(response.status).toBe(404);
expect(await response.text()).toBe("<h1>Missing</h1>");
expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts allow-forms");
for (const [method, status] of [["HEAD", 200], ["GET", 204], ["GET", 205], ["GET", 304]] as const) {
expect(canvasApiResponse({ status, statusText: "", headers: [], body: btoa("ignored") }, method).body).toBeNull();
}
expect(() => canvasApiResponse({ status: 200, statusText: "OK", headers: [], body: btoa("x".repeat(256 * 1024 + 1)) }, "GET")).toThrow("256 KiB");
});
42 changes: 42 additions & 0 deletions cloudflare/canvas-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { CanvasHttpRequest, CanvasHttpResponse } from "../src/httpTypes";

const MAX_BODY = 256 * 1024;

/** Adapt a standalone API request to the same bounded envelope used by the SDK. */
export async function canvasApiRequest(request: Request, basePath: string, privateLink: boolean): Promise<CanvasHttpRequest> {
const headers = new Headers(request.headers);
for (const name of ["cookie", "cf-access-jwt-assertion", "cf-access-client-id", "cf-access-client-secret"]) headers.delete(name);
if (privateLink) headers.delete("authorization");
const url = new URL(request.url);
const reader = request.body?.getReader();
let binary = "";
try {
if (reader) while (true) {
const chunk = await reader.read();
if (chunk.done) break;
if (binary.length + chunk.value.byteLength > MAX_BODY) {
await reader.cancel();
throw new RangeError("Canvas request body exceeds 256 KiB");
}
for (let i = 0; i < chunk.value.length; i += 8192) binary += String.fromCharCode(...chunk.value.subarray(i, i + 8192));
}
} finally { reader?.releaseLock(); }
return { path: url.pathname.slice(basePath.length) + url.search, method: request.method, headers: [...headers], ...(reader ? { body: btoa(binary) } : {}) };
}

export function canvasApiResponse(input: Omit<CanvasHttpResponse, "headers"> & { headers: string[][] }, method: string): Response {
// Durable Object RPC widens tuple arrays to string[][] on the caller side.
const headers = new Headers(input.headers as [string, string][]);
headers.delete("set-cookie");
headers.append("content-security-policy", "sandbox allow-scripts allow-forms");
headers.set("x-content-type-options", "nosniff");
headers.set("cache-control", "private, no-store");
let body: Uint8Array<ArrayBuffer> | null = null;
if (input.body !== undefined && method !== "HEAD" && ![204, 205, 304].includes(input.status)) {
if (input.body.length > Math.ceil(MAX_BODY / 3) * 4) throw new RangeError("Canvas response body exceeds 256 KiB");
const binary = atob(input.body);
if (binary.length > MAX_BODY) throw new RangeError("Canvas response body exceeds 256 KiB");
body = Uint8Array.from(binary, char => char.charCodeAt(0));
}
return new Response(body, { status: input.status, statusText: input.statusText, headers });
}
Loading
Loading