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
+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 }) {