fix: close TOCTOU race + missing checks in tier limits, add AI rate limits, patch CVEs (v0.60.0)

Security audit fixes (see SECURITY_AUDIT.md):
- lib/tiers.ts: checkAndIncrementTierLimit's recipe/storage branches did a
  live count/sum check with no lock tying it to the later write, so two
  concurrent requests near the cap could both pass and both write past the
  limit. Added checkTierLimitInTransaction — holds a per-user Postgres
  advisory lock for the duration of the caller's transaction, so the check
  and the write are atomic together. Applied to every recipe/storage write
  path (recipes create/update, fork, rate, avatar, and 7 AI recipe-creation
  routes).
- Adversarial re-verification of that fix caught a bigger gap in the same
  area: four AI routes (photo import, idea generation, batch-cook,
  translate-to-new-draft) had no recipe-limit check at all. Fixed.
- Added missing per-user rate limits to 5 AI endpoints (adapt, drinks,
  pairings, translate, variations) that had none, unlike their siblings.
- search page now checks for a session server-side, matching every other
  page under (app)/ — defense in depth; the underlying API was already
  public by design and proxy.ts already blocked unauthenticated requests.
- admin/settings route now uses the shared requireAdmin instead of a local
  duplicate.
- Documented two admin support-ticket endpoints missing from the OpenAPI
  spec; verified full route/OpenAPI parity otherwise.
- Bumped drizzle-orm to fix a SQL-identifier-escaping CVE, and overrode two
  transitive deps (esbuild, postcss) with known CVEs. pnpm audit is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-20 22:21:51 +02:00
parent 623e5bcd34
commit 7626f1b496
31 changed files with 561 additions and 509 deletions
@@ -1,39 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
}));
vi.mock("@/lib/auth/server", () => ({
auth: { api: { getSession: vi.fn() } },
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
vi.mock("@/lib/site-settings", () => ({
setSiteSetting: vi.fn().mockResolvedValue(undefined),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
const { mockInsertValues } = vi.hoisted(() => ({
mockInsertValues: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { auth } = await import("@/lib/auth/server");
const { requireAdmin } = await import("@/lib/api-auth");
const { setSiteSetting } = await import("@/lib/site-settings");
import { PUT } from "../route";
@@ -47,21 +36,26 @@ function makeRequest(body: unknown) {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never);
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
});
describe("PUT /api/v1/admin/settings", () => {
it("returns 403 when caller is not an admin", async () => {
mockSelectChain.where.mockResolvedValue([{ role: "user" }]);
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
} as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("returns 403 when there is no session", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
it("returns 401 when there is no session", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
} as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
expect(res.status).toBe(401);
});
it("updates allowed keys and writes an audit log", async () => {
+6 -15
View File
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, auditLogs, eq } from "@epicure/db";
import { db, auditLogs } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
import { randomUUID } from "crypto";
@@ -28,31 +27,23 @@ const ALLOWED_KEYS: SiteSettingKey[] = [
"GITEA_REPO",
];
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id));
if (dbUser?.role !== "admin") return null;
return session;
}
export async function PUT(req: NextRequest) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { session, response } = await requireAdmin();
if (response) return response;
const body = (await req.json()) as Record<string, string | null>;
const changed: string[] = [];
for (const [key, value] of Object.entries(body)) {
if (!ALLOWED_KEYS.includes(key as SiteSettingKey)) continue;
await setSiteSetting(key as SiteSettingKey, value, session.user.id);
await setSiteSetting(key as SiteSettingKey, value, session!.user.id);
changed.push(key);
}
if (changed.length > 0) {
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session.user.id,
userId: session!.user.id,
action: "admin.settings.update",
targetType: "site_settings",
metadata: JSON.stringify({ keys: changed }),