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:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user