fix(security+db): close the real audit findings (SEC-04/05/01, DB-01)
Deploy to VPS / deploy (push) Has been cancelled

Acts on the verified findings from the 2026-06 audit (docs/AUDIT_2026-06_
VERIFIED.md). The audit's #1 "middleware never runs" was a false positive
(verified in prod: /hq-command redirects to login). These are the genuine
gaps:

- SEC-04 (HIGH): /api/assets (GET/POST/PUT/DELETE/PATCH) and
  /api/branding/favicon (POST) had NO auth. The middleware matcher excludes
  /api, so they were world-reachable — anyone could list/upload/rename/
  delete CMS files or regenerate the favicon. Added a new getAdminSession()
  helper (src/lib/session.ts) and a requireAdmin() guard on every handler.

- DB-01 (HIGH): the ClientUser table (B2B client portal) was defined in the
  schema but NEVER created by any migration, and OperationsSignal.clientId +
  its FK were missing too. B2B register/login failed at runtime; the
  dashboard silently showed 0 clients. New additive migration
  20260609120000_add_client_user creates the table, the unique email index,
  the clientId column (IF NOT EXISTS), and the FK (duplicate-object guarded).

- SEC-05 (MED-HIGH): operations.ts generateRichEmailHtml() interpolated
  item.title/sku/quantity, clientName/Company/Email/Phone and the free-text
  message straight into HTML — stored XSS into the team's internal inbox.
  Now escaped via escapeHtml/escapeAttr/safeMailto; file links validated to
  internal paths only.

- SEC-01 (MED): removed the hardcoded SESSION_SECRET fallback in src/proxy.ts;
  it now validates lazily and throws if the secret is missing (mirrors
  session.ts), so a runtime env failure can't fall back to a public key.

Verified: next build compiles with SESSION_SECRET unset (Docker parity),
TypeScript clean, prisma schema valid, golden tests 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:40:20 -05:00
parent b76c14b780
commit 18d5ed87c8
7 changed files with 562 additions and 17 deletions
+14
View File
@@ -32,6 +32,15 @@ import fs from "fs";
import path from "path";
import { revalidateContent, type RevalidateScope } from "@/lib/revalidate";
import { optimizeImage, isOptimizable } from "@/lib/imageOptimizer";
import { getAdminSession } from "@/lib/session";
// All asset operations are admin-only. The middleware (src/proxy.ts) does NOT
// cover /api, so each handler must verify the admin session itself.
async function requireAdmin(): Promise<NextResponse | null> {
const session = await getAdminSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return null;
}
const SCOPE_ROOTS: Record<string, string> = {
applications: path.join(process.cwd(), "public", "applications"),
@@ -123,6 +132,7 @@ function buildBreadcrumbs(subPath: string) {
// GET — List files and folders
export async function GET(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const { searchParams } = new URL(request.url);
const scope = searchParams.get("scope") || "applications";
@@ -198,6 +208,7 @@ export async function GET(request: NextRequest) {
// produces a different hash, so the browser cache invalidates instantly
// without any header trickery.
export async function POST(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const formData = await request.formData();
const scope = (formData.get("scope") as string) || "applications";
@@ -280,6 +291,7 @@ export async function POST(request: NextRequest) {
// PUT — Create a new folder
export async function PUT(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const body = await request.json();
const { scope = "applications", slug = "", folderName, parentPath = "" } = body;
@@ -315,6 +327,7 @@ export async function PUT(request: NextRequest) {
// { scope, slug, filePath: "..." } single delete
// { scope, slug, filePaths: ["a", "b", ...] } bulk delete
export async function DELETE(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const body = await request.json();
const { scope = "applications", slug = "", filePath, filePaths } = body;
@@ -371,6 +384,7 @@ export async function DELETE(request: NextRequest) {
// Cannot overwrite an existing file (returns 409). Sanitises target name
// the same way upload does, and creates intermediate folders if needed.
export async function PATCH(request: NextRequest) {
const unauth = await requireAdmin(); if (unauth) return unauth;
try {
const body = await request.json();
const { scope = "applications", slug = "", fromPath, toPath } = body;