From 147611a8a183224368ffcaa1fac745d14d8c2d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:56:47 +0000 Subject: [PATCH] fix(api): refuse artifact uploads from untrusted origins Uploads with a text/plain, text/csv or text/markdown body are CORS "simple" requests that a browser sends cross-site without a preflight. The session cookie's SameSite default already keeps it off such requests, but a deployment that relaxes it would accept uploads from any page. The route now applies the same origin rule as CORS before reading the session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/app.ts | 1 + apps/api/src/artifact-http.test.ts | 14 ++++++++++++++ apps/api/src/artifact-http.ts | 6 +++++- packages/testkit/src/artifact-routes.test.ts | 9 +++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c05a71f..8893a50 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -589,6 +589,7 @@ export async function createApp( artifacts, sessionUserId: async (request) => (await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null, + trustedOrigin: (origin) => isTrustedOrigin(origin, env), }); mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs }); // Shared with stop so a shutdown during retry delays does not restart polling. diff --git a/apps/api/src/artifact-http.test.ts b/apps/api/src/artifact-http.test.ts index a1d6d44..0a2a6fc 100644 --- a/apps/api/src/artifact-http.test.ts +++ b/apps/api/src/artifact-http.test.ts @@ -77,6 +77,7 @@ describe("artifact HTTP routes", () => { prisma: prisma as unknown as PrismaClient, artifacts: artifacts as unknown as ArtifactStore, sessionUserId, + trustedOrigin: (origin) => origin === "http://127.0.0.1:5173", }); return { hono, prisma, artifacts }; } @@ -95,6 +96,19 @@ describe("artifact HTTP routes", () => { expect(artifacts.putStream).not.toHaveBeenCalled(); }); + it("refuses uploads from untrusted origins before reading the session", async () => { + const sessionUserId = vi.fn(async () => "user-1"); + const { hono, artifacts } = app(sessionUserId); + const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", { + method: "POST", + headers: { "content-type": "text/plain", origin: "https://evil.example" }, + body: "data", + }); + expect(response.status).toBe(403); + expect(sessionUserId).not.toHaveBeenCalled(); + expect(artifacts.putStream).not.toHaveBeenCalled(); + }); + it("answers ids that are not artifact ids with 404 without a lookup", async () => { const { hono, prisma } = app(async () => "user-1"); const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`); diff --git a/apps/api/src/artifact-http.ts b/apps/api/src/artifact-http.ts index b0c048c..d8b9df5 100644 --- a/apps/api/src/artifact-http.ts +++ b/apps/api/src/artifact-http.ts @@ -20,6 +20,8 @@ export type ArtifactHttpDeps = { artifacts: ArtifactStore; /** The signed-in user (cookie or bearer session), or null. */ sessionUserId: (request: Request) => Promise; + /** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */ + trustedOrigin: (origin: string) => boolean; }; /** Generated ids only; anything else is not an artifact. */ @@ -208,10 +210,12 @@ export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) { app.post(ARTIFACT_HTTP_PATH, async (c) => { const request = c.req.raw; - const reject = (status: 400 | 401 | 404 | 413 | 415, error: string) => { + const reject = (status: 400 | 401 | 403 | 404 | 413 | 415, error: string) => { cancelBody(request.body); return c.json({ error }, status); }; + const origin = c.req.header("origin"); + if (origin && !deps.trustedOrigin(origin)) return reject(403, "Forbidden"); const userId = await deps.sessionUserId(request); if (!userId) return reject(401, "Unauthorized"); const actor = await requireMembership( diff --git a/packages/testkit/src/artifact-routes.test.ts b/packages/testkit/src/artifact-routes.test.ts index 3b6a3e6..6e55fdc 100644 --- a/packages/testkit/src/artifact-routes.test.ts +++ b/packages/testkit/src/artifact-routes.test.ts @@ -148,6 +148,15 @@ describeRoutes("artifact HTTP routes", () => { (await upload(app, intruder, { groupId: group.id }, "x.mp4", "video/mp4", video)).status, ).toBe(404); + // A cross-site page cannot upload with the owner's cookie. + expect( + ( + await upload(app, owner, { botId: bot.id }, "x.txt", "text/plain", video, { + origin: "https://evil.example", + }) + ).status, + ).toBe(403); + // Signed out. expect((await app.request(`/api/artifacts/${botArtifact.id}`)).status).toBe(401); expect(