From 149f332f2e98ad99ec85e3037bfe5a53acd106c0 Mon Sep 17 00:00:00 2001 From: Teddy Lee Date: Thu, 16 Apr 2026 16:22:42 +0900 Subject: [PATCH] fix(api): add admin auth guard to upload file-serve route The GET handler at frontend/src/app/api/admin/upload/[filename]/route.ts served admin-uploaded files to any caller without an authentication check, while the sibling POST handler already required auth() + isAdmin(). This asymmetry let unauthenticated clients read admin-private branding and logo assets once a filename leaked (browser history, server logs, CDN cache). Add the session + role check at the start of the GET handler: - no session -> 401 Unauthorized - authenticated non-admin -> 403 Forbidden - admin/super_admin -> existing file-serve path (unchanged) Keep the existing path-traversal defense and MIME handling intact. Add a Playwright e2e regression test covering the unauthenticated 401 case (matches the existing e2e infra under frontend/e2e/). --- frontend/e2e/admin-upload-auth.spec.ts | 28 +++++++++++++++++++ .../app/api/admin/upload/[filename]/route.ts | 11 ++++++++ 2 files changed, 39 insertions(+) create mode 100644 frontend/e2e/admin-upload-auth.spec.ts diff --git a/frontend/e2e/admin-upload-auth.spec.ts b/frontend/e2e/admin-upload-auth.spec.ts new file mode 100644 index 0000000..29e7575 --- /dev/null +++ b/frontend/e2e/admin-upload-auth.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test"; + +/** + * Admin upload file-serve auth guard — regression test for GitHub issue #65. + * + * Verifies that `GET /api/admin/upload/{filename}` rejects unauthenticated + * requests with 401 Unauthorized. Before this fix the route returned uploaded + * admin assets to any caller that knew the filename. + * + * Only the unauthenticated boundary is asserted here. The non-admin (403) and + * admin (200) cases depend on seeded NextAuth session state that isn't + * available in the standalone e2e environment used by this suite. + * + * Requires: + * - Next.js dev/preview server on the baseURL defined in playwright.config + * (any AUTH_MODE — the check runs at route entry before mode branching) + */ + +test.describe("Admin upload file-serve — auth guard", () => { + test("unauthenticated GET returns 401 Unauthorized", async ({ request }) => { + const response = await request.get( + "/api/admin/upload/does-not-matter.png", + ); + + expect(response.status()).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "Unauthorized" }); + }); +}); diff --git a/frontend/src/app/api/admin/upload/[filename]/route.ts b/frontend/src/app/api/admin/upload/[filename]/route.ts index 1244ffc..9e6ad78 100644 --- a/frontend/src/app/api/admin/upload/[filename]/route.ts +++ b/frontend/src/app/api/admin/upload/[filename]/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { readFile, access, constants } from "fs/promises"; import path from "path"; +import { auth } from "@/lib/auth"; +import { isAdmin } from "@/types/auth-mode"; +import type { UserRole } from "@/types/auth-mode"; // Get upload directory (same as upload route) function getUploadDir(): string { @@ -26,6 +29,14 @@ export async function GET( { params }: { params: Promise<{ filename: string }> }, ) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isAdmin(session.user.role as UserRole)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { filename } = await params; // Security: prevent directory traversal