diff --git a/app/api/posts/edit/[token]/route.test.ts b/app/api/posts/edit/[token]/route.test.ts index feb95cb..c870331 100644 --- a/app/api/posts/edit/[token]/route.test.ts +++ b/app/api/posts/edit/[token]/route.test.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const from = vi.fn(); @@ -16,9 +16,52 @@ function createPatchRequest(body: unknown) { } describe("PATCH /api/posts/edit/[token]", () => { + const originalEnv = { ...process.env }; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + process.env.R2_PUBLIC_URL = "https://cdn.example.com"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("rejects a screenshot URL that isn't on our own R2 bucket", async () => { + from.mockImplementation((table: string) => { + if (table === "posts") { + return { + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: { id: "post-1" }, + error: null, + }), + }), + }), + }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { PATCH } = await import("./route"); + const response = await PATCH( + createPatchRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + screenshotUrls: ["https://evil.example.com/pwned.png"], + }), + { params: { token: "token-123" } }, + ); + + expect(response.status).toBe(400); }); it("returns 404 for an invalid edit token", async () => { @@ -120,6 +163,9 @@ describe("PATCH /api/posts/edit/[token]", () => { tags: ["hallucination"], isAnonymous: false, authorHandle: "ops-team", + screenshotUrls: [ + "https://cdn.example.com/screenshots/8f14e45f-ceea-467e-b7d1-3cfa78f5c15e.png", + ], }), { params: { token: "token-123" } }, ); @@ -131,4 +177,40 @@ describe("PATCH /api/posts/edit/[token]", () => { { post_id: "post-1", tag_id: "tag-1" }, ]); }); + + it("rejects a well-formed R2 URL with a malformed object key", async () => { + from.mockImplementation((table: string) => { + if (table === "posts") { + return { + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: { id: "post-1" }, + error: null, + }), + }), + }), + }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { PATCH } = await import("./route"); + const response = await PATCH( + createPatchRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + screenshotUrls: ["https://cdn.example.com/screenshots/not-a-uuid.png"], + }), + { params: { token: "token-123" } }, + ); + + expect(response.status).toBe(400); + }); }); diff --git a/app/api/posts/edit/[token]/route.ts b/app/api/posts/edit/[token]/route.ts index 91476d0..1a96b07 100644 --- a/app/api/posts/edit/[token]/route.ts +++ b/app/api/posts/edit/[token]/route.ts @@ -1,12 +1,11 @@ import { createHash } from "crypto"; import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; -import { submitSchema } from "@/lib/schemas/submit"; +import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit"; import { redactPii } from "@/lib/utils/pii"; const editSchema = submitSchema.extend({ - screenshotUrls: z.array(z.string().url()).max(5).optional(), + screenshotUrls: screenshotUrlsSchema, }); interface EditablePostRow { diff --git a/app/api/posts/route.test.ts b/app/api/posts/route.test.ts index 84fffa8..e21d98d 100644 --- a/app/api/posts/route.test.ts +++ b/app/api/posts/route.test.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendEditTokenEmail = vi.fn(); const hashIp = vi.fn(() => "hashed-ip"); @@ -42,9 +42,224 @@ function createRequest(body: unknown) { } describe("POST /api/posts", () => { + const originalEnv = { ...process.env }; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + process.env.R2_PUBLIC_URL = "https://cdn.example.com"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("rejects a screenshot URL that isn't on our own R2 bucket", async () => { + from.mockImplementation((table: string) => { + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + screenshotUrls: ["https://evil.example.com/pwned.png"], + }), + ); + + expect(response.status).toBe(400); + }); + + it("rejects a well-formed R2 URL with a malformed object key", async () => { + from.mockImplementation((table: string) => { + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + screenshotUrls: ["https://cdn.example.com/screenshots/not-a-uuid.png"], + }), + ); + + expect(response.status).toBe(400); + }); + + it("accepts a submission with a valid R2 screenshot URL", async () => { + const insertSpy = vi.fn().mockReturnValue({ + select: () => ({ + single: async () => ({ data: { id: "post-1" }, error: null }), + }), + }); + + from.mockImplementation((table: string) => { + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + if (table === "posts") { + return { insert: insertSpy }; + } + + if (table === "post_tags") { + return { insert: vi.fn().mockResolvedValue({ error: null }) }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + screenshotUrls: [ + "https://cdn.example.com/screenshots/8f14e45f-ceea-467e-b7d1-3cfa78f5c15e.png", + ], + }), + ); + + expect(response.status).toBe(201); + expect(insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ + screenshot_urls: [ + "https://cdn.example.com/screenshots/8f14e45f-ceea-467e-b7d1-3cfa78f5c15e.png", + ], + }), + ); + }); + + it("accepts a submission with no screenshots at all", async () => { + const insertSpy = vi.fn().mockReturnValue({ + select: () => ({ + single: async () => ({ data: { id: "post-1" }, error: null }), + }), + }); + + from.mockImplementation((table: string) => { + if (table === "agents") { + return { + select: () => ({ + eq: () => ({ + single: async () => ({ data: { id: "agent-1" }, error: null }), + }), + }), + }; + } + + if (table === "tags") { + return { + select: () => ({ + in: async () => ({ + data: [{ id: "tag-1", slug: "hallucination" }], + error: null, + }), + }), + }; + } + + if (table === "posts") { + return { insert: insertSpy }; + } + + if (table === "post_tags") { + return { insert: vi.fn().mockResolvedValue({ error: null }) }; + } + + throw new Error(`Unexpected table ${table}`); + }); + + const { POST } = await import("./route"); + const response = await POST( + createRequest({ + agentSlug: "claude", + title: "Agent deleted a customer record during a routine sync", + outcome: + "The assistant misunderstood the task, deleted a live customer record, and forced the team into a manual restore that took several hours to unwind safely.", + damageLevel: 3, + tags: ["hallucination"], + isAnonymous: true, + }), + ); + + expect(response.status).toBe(201); }); it("rejects unknown tags instead of silently dropping them", async () => { diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 64b70a1..e859d24 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { submitSchema } from "@/lib/schemas/submit"; +import { submitSchema, screenshotUrlsSchema } from "@/lib/schemas/submit"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { sendEditTokenEmail } from "@/lib/resend/send"; import { hashIp, getClientIp } from "@/lib/utils/hash"; @@ -13,7 +12,7 @@ const WINDOW_SECONDS = 60 * 60; const MAX_SUBMISSIONS = 3; const bodySchema = submitSchema.extend({ - screenshotUrls: z.array(z.string().url()).max(5).optional(), + screenshotUrls: screenshotUrlsSchema, }); export async function POST(req: NextRequest) { diff --git a/lib/schemas/submit.ts b/lib/schemas/submit.ts index 270a6bf..371b130 100644 --- a/lib/schemas/submit.ts +++ b/lib/schemas/submit.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isOwnedScreenshotUrl } from "@/lib/utils/urls"; export const submitSchema = z.object({ /** Slug of the agent involved (from AGENTS constant) */ @@ -47,11 +48,27 @@ export const submitSchema = z.object({ .optional() .or(z.literal("")), - /** R2 object keys or public URLs for screenshot evidence */ + /** Public URLs for screenshot evidence — must point at our own R2 bucket */ screenshots: z - .array(z.string().url()) + .array( + z + .string() + .url() + .refine(isOwnedScreenshotUrl, "Screenshot URL is not allowed."), + ) .max(5, "Maximum 5 screenshots per submission.") .optional(), }); export type SubmitFormValues = z.infer; + +/** Shared validator for the `screenshotUrls` field the submit/edit API routes accept. */ +export const screenshotUrlsSchema = z + .array( + z + .string() + .url() + .refine(isOwnedScreenshotUrl, "Screenshot URL is not allowed."), + ) + .max(5) + .optional(); diff --git a/lib/utils/urls.test.ts b/lib/utils/urls.test.ts index f167be6..4b83d46 100644 --- a/lib/utils/urls.test.ts +++ b/lib/utils/urls.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getR2PublicBaseUrl, getSiteUrl } from "./urls"; +import { getR2PublicBaseUrl, getSiteUrl, isOwnedScreenshotUrl } from "./urls"; describe("url helpers", () => { const originalEnv = { ...process.env }; @@ -31,3 +31,49 @@ describe("url helpers", () => { expect(getR2PublicBaseUrl()).toBe("https://public.example.com"); }); }); + +describe("isOwnedScreenshotUrl", () => { + const originalEnv = { ...process.env }; + const uuid = "8f14e45f-ceea-467e-b7d1-3cfa78f5c15e"; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("accepts a well-formed key under our own R2 base URL", () => { + process.env.R2_PUBLIC_URL = "https://cdn.example.com"; + expect( + isOwnedScreenshotUrl(`https://cdn.example.com/screenshots/${uuid}.png`), + ).toBe(true); + }); + + it("rejects a URL on a different host", () => { + process.env.R2_PUBLIC_URL = "https://cdn.example.com"; + expect( + isOwnedScreenshotUrl(`https://evil.example.com/screenshots/${uuid}.png`), + ).toBe(false); + }); + + it("rejects a malformed key on our own host", () => { + process.env.R2_PUBLIC_URL = "https://cdn.example.com"; + expect( + isOwnedScreenshotUrl( + "https://cdn.example.com/screenshots/../../etc/passwd", + ), + ).toBe(false); + expect( + isOwnedScreenshotUrl( + "https://cdn.example.com/uploads/not-a-screenshot.png", + ), + ).toBe(false); + }); + + it("rejects everything when the R2 public URL isn't configured", () => { + delete (process.env as Record).R2_PUBLIC_URL; + delete (process.env as Record) + .NEXT_PUBLIC_R2_PUBLIC_URL; + expect( + isOwnedScreenshotUrl(`https://cdn.example.com/screenshots/${uuid}.png`), + ).toBe(false); + }); +}); diff --git a/lib/utils/urls.ts b/lib/utils/urls.ts index 303c47a..05da74d 100644 --- a/lib/utils/urls.ts +++ b/lib/utils/urls.ts @@ -21,3 +21,21 @@ export function getR2PublicBaseUrl(): string { return configured ? stripTrailingSlash(configured) : ""; } + +const SCREENSHOT_KEY_PATTERN = + /^screenshots\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|jpeg|png|webp|gif)$/i; + +/** + * True only for URLs pointing at an object we generated ourselves through + * the presigned upload flow — same R2 public base URL, key shaped like + * `screenshots/.`. Anything else (a third party host, or a + * same-host URL with a made-up key) is rejected. + */ +export function isOwnedScreenshotUrl(url: string): boolean { + const base = getR2PublicBaseUrl(); + if (!base) return false; + if (!url.startsWith(`${base}/`)) return false; + + const key = url.slice(base.length + 1); + return SCREENSHOT_KEY_PATTERN.test(key); +}