security: close audit findings #19–#23 and harden Docker setup (#24)

#19 MFA QR code: render locally via qrcode package, remove external qrserver.com request
#20 Webhook SSRF: add ssrf-guard.ts with DNS-verified IP blocklist; enforce on create/update/test/dispatch
#21 /api/perf: fail-closed when CRON_SECRET missing; remove query-string token auth
#22 CSP: remove unsafe-eval and unsafe-inline from script-src in production builds
#23 Active session registry: forward jti into session object; validate against ActiveSession on every tRPC request

#24 Docker: add missing packages/application to Dockerfile.dev; fix pnpm-lock.yaml glob;
    run db:migrate:deploy on container start so a fresh checkout boots without manual steps

Also: fix pre-existing TS error in e2e/allocations.spec.ts (args.length literal type overlap)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-01 18:19:21 +02:00
parent fd75628e9d
commit 0e119cfe73
17 changed files with 675 additions and 44 deletions
+85 -16
View File
@@ -1,25 +1,80 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
const FIXED_BROWSER_NOW = "2026-04-01T12:00:00.000Z";
async function freezeBrowserTime(page: Page) {
await page.addInitScript((fixedIso) => {
const fixedTime = new Date(fixedIso).valueOf();
const RealDate = Date;
class MockDate extends RealDate {
constructor(...args: ConstructorParameters<DateConstructor>) {
if ((args as unknown[]).length === 0) {
super(fixedTime);
return;
}
super(...args);
}
static now() {
return fixedTime;
}
}
MockDate.UTC = RealDate.UTC;
MockDate.parse = RealDate.parse;
MockDate.prototype = RealDate.prototype;
// @ts-expect-error override Date in the browser for deterministic client-side filtering
window.Date = MockDate;
}, FIXED_BROWSER_NOW);
}
async function signIn(page: Page, email: string, password: string) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
}
test.describe("Allocations", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
await freezeBrowserTime(page);
await signIn(page, "admin@capakraken.dev", "admin123");
await page.goto("/allocations");
});
test("allocation list loads with table", async ({ page }) => {
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
await page.waitForLoadState("networkidle");
// The page title should be visible
await expect(
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
).toBeVisible({ timeout: 10000 });
// Table or empty state should be present
await expect(
page.locator("table").or(page.locator("text=No allocations")),
).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
});
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
await page.waitForLoadState("networkidle");
const projectFilter = page.getByPlaceholder("Filter by project…");
await projectFilter.click();
await projectFilter.fill("PAG25G");
await page.getByRole("button", { name: /PAG25G/i }).click();
await expect(page.getByTestId("allocations-empty-state")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/hidden by the active project filters/i)).toBeVisible();
const resetButton = page.getByRole("button", { name: "Show all assignments" });
await expect(resetButton).toBeVisible();
await resetButton.click();
await expect(projectFilter).toHaveValue("");
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
});
test("new planning entry modal opens", async ({ page }) => {
@@ -27,10 +82,8 @@ test.describe("Allocations", () => {
const newBtn = page.locator("button", { hasText: /New Planning Entry/i });
await expect(newBtn).toBeVisible({ timeout: 10000 });
await newBtn.click();
// Modal should appear with form fields
await expect(
page.locator("[role='dialog']").or(page.locator("text=Create").or(page.locator("text=Project"))),
).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
await page.keyboard.press("Escape");
});
@@ -61,4 +114,20 @@ test.describe("Allocations", () => {
await page.keyboard.press("Escape");
}
});
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
const page = await browser.newPage();
await freezeBrowserTime(page);
await signIn(page, "viewer@capakraken.dev", "viewer123");
await page.goto("/allocations");
await page.waitForLoadState("networkidle");
const accessError = page.getByTestId("allocations-access-error");
await expect(accessError).toBeVisible({ timeout: 10000 });
await expect(accessError).toContainText(/do not have permission to view allocations/i);
await expect(page.getByTestId("allocation-row")).toHaveCount(0);
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
await page.close();
});
});
+8 -1
View File
@@ -28,7 +28,14 @@ const nextConfig: NextConfig = {
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" },
{
key: "Content-Security-Policy",
value: process.env.NODE_ENV === "production"
// Production: no unsafe-eval, no unsafe-inline in script-src
? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// Development: allow unsafe-eval and unsafe-inline for HMR / dev tooling
: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
},
{ key: "X-XSS-Protection", value: "0" },
],
},
+3 -1
View File
@@ -29,6 +29,7 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/qrcode": "^1.5.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"exceljs": "^4.4.0",
@@ -36,6 +37,7 @@
"next": "^15.1.7",
"next-auth": "^5.0.0-beta.25",
"otpauth": "^9.5.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.1",
@@ -49,13 +51,13 @@
"devDependencies": {
"@capakraken/tsconfig": "workspace:*",
"@playwright/test": "^1.49.1",
"@vitest/coverage-v8": "^2.1.9",
"@types/dompurify": "^3.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
"@types/react-grid-layout": "^2.1.0",
"@types/three": "^0.183.1",
"@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
+6 -7
View File
@@ -13,14 +13,13 @@ export const runtime = "nodejs";
export function GET(request: Request) {
const cronSecret = process.env["CRON_SECRET"];
if (cronSecret) {
const url = new URL(request.url);
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
const queryToken = url.searchParams.get("token");
if (!cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (headerToken !== cronSecret && queryToken !== cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
if (headerToken !== cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const mem = process.memoryUsage();
+15
View File
@@ -23,6 +23,21 @@ function trackActivity(userId: string) {
const handler = async (req: NextRequest) => {
const session = await auth();
// Validate active session registry on every authenticated request.
// Sessions kicked by concurrent-session limits or manual logout are rejected immediately.
if (session?.user) {
const jti = (session.user as typeof session.user & { jti?: string }).jti;
if (jti) {
const activeSession = await prisma.activeSession.findUnique({ where: { jti } });
if (!activeSession) {
return new Response(JSON.stringify({ error: "Session revoked" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
}
}
const dbUser = session?.user?.email
? await prisma.user.findUnique({
where: { email: session.user.email },
@@ -267,6 +267,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
data-testid="allocation-modal"
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
@@ -60,6 +60,45 @@ type DemandRow = AllocationWithDetails & {
unfilledHeadcount?: number;
};
type AllocationQueryError = {
data?: {
code?: string;
};
message?: string;
} | null;
function getAllocationQueryFailure(error: AllocationQueryError) {
const code = error?.data?.code;
if (code === "FORBIDDEN") {
return {
testId: "allocations-access-error",
title: "You do not have permission to view allocations.",
detail: "Your account is missing planning read access for this page.",
actionLabel: null,
actionHref: null,
};
}
if (code === "UNAUTHORIZED") {
return {
testId: "allocations-session-error",
title: "Your session expired.",
detail: "Sign in again to load allocations.",
actionLabel: "Sign in again",
actionHref: "/auth/signin",
};
}
return {
testId: "allocations-query-error",
title: "Allocations could not be loaded.",
detail: error?.message ?? "Refresh the page or try again in a moment.",
actionLabel: null,
actionHref: null,
};
}
export function AllocationsClient() {
const [modalOpen, setModalOpen] = useState(false);
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
@@ -86,7 +125,7 @@ export function AllocationsClient() {
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
const allocationQuery = trpc.allocation.listView.useQuery(
{
projectId: filterProjectId || undefined,
resourceId: filterResourceId || undefined,
@@ -94,7 +133,14 @@ export function AllocationsClient() {
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
) as {
data: AllocationAssignmentsView | undefined;
isLoading: boolean;
isError: boolean;
error: AllocationQueryError;
};
const { data: allocationView, isLoading, isError, error } = allocationQuery;
const allocationQueryFailure = isError ? getAllocationQueryFailure(error) : null;
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
onSuccess: async () => {
@@ -420,7 +466,12 @@ export function AllocationsClient() {
const isSelected = selection.selectedIds.has(alloc.id);
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
return (
<tr key={alloc.id} className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
<tr
key={alloc.id}
data-testid="allocation-row"
className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}
>
<td className="px-4 py-3">
<input
type="checkbox"
@@ -491,6 +542,8 @@ export function AllocationsClient() {
<p className="app-page-subtitle mt-1">
{isLoading
? "Loading…"
: allocationQueryFailure
? allocationQueryFailure.title
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
</p>
</div>
@@ -627,7 +680,7 @@ export function AllocationsClient() {
)}
<div className="app-data-table">
<table className="w-full">
<table data-testid="allocations-table" className="w-full">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 w-10">
@@ -676,10 +729,29 @@ export function AllocationsClient() {
</tr>
)}
{!isLoading && sorted.length === 0 && (
{!isLoading && allocationQueryFailure && (
<tr>
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center gap-2">
<div data-testid={allocationQueryFailure.testId} className="flex flex-col items-center gap-2">
<p className="font-medium text-gray-700 dark:text-gray-200">{allocationQueryFailure.title}</p>
<p>{allocationQueryFailure.detail}</p>
{allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && (
<a
href={allocationQueryFailure.actionHref}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
>
{allocationQueryFailure.actionLabel}
</a>
)}
</div>
</td>
</tr>
)}
{!isLoading && !allocationQueryFailure && sorted.length === 0 && (
<tr>
<td colSpan={totalColSpan} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
<div data-testid="allocations-empty-state" className="flex flex-col items-center gap-2">
<p className="font-medium text-gray-700 dark:text-gray-200">{emptyState.title}</p>
<p>{emptyState.detail}</p>
{emptyState.showResetAction && (
@@ -696,10 +768,10 @@ export function AllocationsClient() {
</tr>
)}
{!isLoading && viewMode === "flat" &&
{!isLoading && !allocationQueryFailure && viewMode === "flat" &&
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
{!isLoading && viewMode === "grouped" &&
{!isLoading && !allocationQueryFailure && viewMode === "grouped" &&
groups.map((group) => {
const isCollapsed = collapsedGroups === "all" || collapsedGroups.has(group.resourceId);
const groupAllocIds = group.allocations.map((a) => a.id);
@@ -709,6 +781,7 @@ export function AllocationsClient() {
<GroupRows key={group.resourceId}>
{/* Group header */}
<tr
data-testid="allocation-group-header"
className="bg-gray-50 dark:bg-gray-800/50 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-800/80 transition-colors"
onClick={() => toggleGroup(group.resourceId)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }}
@@ -763,6 +836,7 @@ export function AllocationsClient() {
return (
<GroupRows key={subKey}>
<tr
data-testid="allocation-subgroup-header"
className="bg-gray-25 dark:bg-gray-850/30 cursor-pointer select-none hover:bg-gray-100/60 dark:hover:bg-gray-800/40 transition-colors"
onClick={() => toggleSubGroup(group.resourceId, subGroup.projectId)}
tabIndex={0}
+21 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import QRCode from "qrcode";
import { trpc } from "~/lib/trpc/client.js";
type SetupStep = "idle" | "show-secret" | "verify" | "done";
@@ -9,10 +10,20 @@ export function MfaSetup() {
const [step, setStep] = useState<SetupStep>("idle");
const [secret, setSecret] = useState("");
const [uri, setUri] = useState("");
const [qrDataUrl, setQrDataUrl] = useState("");
const [token, setToken] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (!uri) return;
let cancelled = false;
QRCode.toDataURL(uri, { width: 200, margin: 2 }).then((dataUrl) => {
if (!cancelled) setQrDataUrl(dataUrl);
}).catch(() => {/* ignore — manual key is shown as fallback */});
return () => { cancelled = true; };
}, [uri]);
const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
const generateMutation = trpc.user.generateTotpSecret.useMutation();
const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
@@ -110,17 +121,17 @@ export function MfaSetup() {
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
</p>
{/* QR Code via public Google Charts API (otpauth URI) */}
{/* QR Code — rendered locally, no external service */}
<div className="flex justify-center">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(uri)}`}
alt="TOTP QR Code"
width={200}
height={200}
className="rounded"
/>
{qrDataUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={qrDataUrl} alt="TOTP QR Code" width={200} height={200} className="rounded" />
) : (
<div className="h-[200px] w-[200px] flex items-center justify-center text-xs text-gray-400">
Generating
</div>
)}
</div>
</div>
+3
View File
@@ -154,6 +154,9 @@ const authConfig = {
if (token.role) {
(session.user as typeof session.user & { role: string }).role = token.role as string;
}
if (token.jti) {
(session.user as typeof session.user & { jti: string }).jti = token.jti as string;
}
return session;
},
async jwt({ token, user }) {