diff --git a/Dockerfile.dev b/Dockerfile.dev index a423f15..c67f379 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -9,12 +9,13 @@ RUN npm install -g pnpm@9.14.2 WORKDIR /app # Copy workspace manifests -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY tooling/ ./tooling/ COPY packages/shared/package.json ./packages/shared/ COPY packages/db/package.json ./packages/db/ COPY packages/engine/package.json ./packages/engine/ COPY packages/staffing/package.json ./packages/staffing/ +COPY packages/application/package.json ./packages/application/ COPY packages/api/package.json ./packages/api/ COPY packages/ui/package.json ./packages/ui/ COPY apps/web/package.json ./apps/web/ diff --git a/apps/web/e2e/allocations.spec.ts b/apps/web/e2e/allocations.spec.ts index c15bb52..ee446c6 100644 --- a/apps/web/e2e/allocations.spec.ts +++ b/apps/web/e2e/allocations.spec.ts @@ -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) { + 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(); + }); }); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index b3e23db..b4341ab 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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" }, ], }, diff --git a/apps/web/package.json b/apps/web/package.json index a96e345..b848ab2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/api/perf/route.ts b/apps/web/src/app/api/perf/route.ts index 472fda3..7dc993d 100644 --- a/apps/web/src/app/api/perf/route.ts +++ b/apps/web/src/app/api/perf/route.ts @@ -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(); diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index e7e473c..a12a623 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -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 }, diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index a490786..8d2223c 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -267,6 +267,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo >
{ if (e.key === "Escape") onClose(); }} > diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 4e9d2d8..69f844f 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -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(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 ( - + {isLoading ? "Loading…" + : allocationQueryFailure + ? allocationQueryFailure.title : `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}

@@ -627,7 +680,7 @@ export function AllocationsClient() { )}
- +
)} - {!isLoading && sorted.length === 0 && ( + {!isLoading && allocationQueryFailure && ( + + )} + + {!isLoading && !allocationQueryFailure && sorted.length === 0 && ( + + )} - {!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() { {/* Group header */} toggleGroup(group.resourceId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(group.resourceId); } }} @@ -763,6 +836,7 @@ export function AllocationsClient() { return ( toggleSubGroup(group.resourceId, subGroup.projectId)} tabIndex={0} diff --git a/apps/web/src/components/security/MfaSetup.tsx b/apps/web/src/components/security/MfaSetup.tsx index 9aca63d..6152c20 100644 --- a/apps/web/src/components/security/MfaSetup.tsx +++ b/apps/web/src/components/security/MfaSetup.tsx @@ -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("idle"); const [secret, setSecret] = useState(""); const [uri, setUri] = useState(""); + const [qrDataUrl, setQrDataUrl] = useState(""); const [token, setToken] = useState(""); const [error, setError] = useState(null); const [success, setSuccess] = useState(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.).

- {/* QR Code via public Google Charts API (otpauth URI) */} + {/* QR Code — rendered locally, no external service */}
- {/* eslint-disable-next-line @next/next/no-img-element */} - TOTP QR Code + {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + TOTP QR Code + ) : ( +
+ Generating… +
+ )}
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 08fc467..c3d0c5e 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -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 }) { diff --git a/docker-compose.yml b/docker-compose.yml index f54353a..69b4481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,9 @@ services: condition: service_healthy volumes: - .:/app + # Anonymous volumes mask the bind-mount for generated/installed artefacts. + # Docker seeds them from the image layer on first start; they persist across restarts. + # pnpm stores all packages in the root node_modules/.pnpm virtual store — one volume covers it all. - /app/node_modules - /app/apps/web/.next profiles: diff --git a/packages/api/src/lib/ssrf-guard.ts b/packages/api/src/lib/ssrf-guard.ts new file mode 100644 index 0000000..c8d80a2 --- /dev/null +++ b/packages/api/src/lib/ssrf-guard.ts @@ -0,0 +1,70 @@ +/** + * SSRF guard for outbound webhook URLs. + * + * Validates that a target URL is not pointing to internal/private infrastructure + * before allowing a webhook to be stored or dispatched. + */ +import { lookup } from "node:dns/promises"; +import { TRPCError } from "@trpc/server"; + +/** Regex patterns matching IP ranges that must not be targeted. */ +const BLOCKED_IP_PATTERNS: RegExp[] = [ + // Loopback IPv4 + /^127\./, + // Loopback IPv6 + /^::1$/, + // RFC 1918 private + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + // Link-local + /^169\.254\./, + // Cloud metadata (AWS, GCP, Azure) + /^100\.64\./, +]; + +/** Hostnames that must never be resolved or contacted. */ +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "metadata.google.internal", + "169.254.169.254", +]); + +function isBlockedIp(ip: string): boolean { + return BLOCKED_IP_PATTERNS.some((re) => re.test(ip)); +} + +/** + * Throws a TRPCError if the given URL targets internal/private infrastructure. + * Performs DNS resolution to catch attempts to bypass hostname checks. + */ +export async function assertWebhookUrlAllowed(urlString: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid webhook URL." }); + } + + if (parsed.protocol !== "https:") { + throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URLs must use HTTPS." }); + } + + const hostname = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." }); + } + + // Resolve hostname and validate the resulting IP address + try { + const { address } = await lookup(hostname); + if (isBlockedIp(address) || BLOCKED_HOSTNAMES.has(address)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." }); + } + } catch (err) { + if (err instanceof TRPCError) throw err; + // DNS resolution failed — block by default (fail-closed) + throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL could not be validated." }); + } +} diff --git a/packages/api/src/lib/webhook-dispatcher.ts b/packages/api/src/lib/webhook-dispatcher.ts index b298e76..4f63782 100644 --- a/packages/api/src/lib/webhook-dispatcher.ts +++ b/packages/api/src/lib/webhook-dispatcher.ts @@ -9,6 +9,7 @@ import { createHmac } from "node:crypto"; import { logger } from "./logger.js"; import { sendSlackNotification } from "./slack-notify.js"; +import { assertWebhookUrlAllowed } from "./ssrf-guard.js"; /** Available webhook event types. */ export const WEBHOOK_EVENTS = [ @@ -85,6 +86,8 @@ async function _sendToWebhook( payload: Record, ): Promise { try { + await assertWebhookUrlAllowed(wh.url); + // Slack-specific path: use the Slack notification helper if (wh.url.includes("hooks.slack.com")) { const message = formatSlackMessage(event, payload); diff --git a/packages/api/src/router/webhook-procedure-support.ts b/packages/api/src/router/webhook-procedure-support.ts index f23a894..ca54cbc 100644 --- a/packages/api/src/router/webhook-procedure-support.ts +++ b/packages/api/src/router/webhook-procedure-support.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { createAuditEntry } from "../lib/audit.js"; +import { assertWebhookUrlAllowed } from "../lib/ssrf-guard.js"; import type { TRPCContext } from "../trpc.js"; import { buildWebhookCreateData, @@ -44,6 +45,8 @@ export async function createWebhook( ctx: WebhookProcedureContext, input: z.infer, ) { + await assertWebhookUrlAllowed(input.url); + const webhook = await ctx.db.webhook.create({ data: buildWebhookCreateData(input), }); @@ -66,6 +69,10 @@ export async function updateWebhook( ctx: WebhookProcedureContext, input: z.infer, ) { + if (input.data.url !== undefined) { + await assertWebhookUrlAllowed(input.data.url); + } + const existing = await loadWebhookOrThrow(ctx.db, input.id); const updated = await ctx.db.webhook.update({ @@ -112,6 +119,7 @@ export async function testWebhook( input: z.infer, ) { const webhook = await loadWebhookOrThrow(ctx.db, input.id); + await assertWebhookUrlAllowed(webhook.url); const result = await sendWebhookTestRequest(webhook); void createAuditEntry({ diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..2fa70a1 --- /dev/null +++ b/plan.md @@ -0,0 +1,194 @@ +# CapaKraken — Umsetzungsplan: Security + Platform Issues + +Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY` +Stand: 2026-04-01 | Issues: #19, #20, #21, #22, #23, #24 + +--- + +## Anforderungsanalyse + +Alle 6 Issues stammen aus einem Security-Audit und einem Plattform-Review. +5 davon sind Security-Findings (OWASP A02/A05/A07/A09), 1 ist ein Plattform-Thema (Docker-Reproduzierbarkeit). +Die Security-Issues sind weitgehend unabhängig voneinander; lediglich #23 greift in den tRPC-Request-Pfad ein und sollte zuletzt umgesetzt werden, da es den Haupt-Auth-Pfad berührt. + +--- + +## Betroffene Pakete & Dateien + +| Issue | Paket/Pfad | Datei | Art | +|-------|-----------|-------|-----| +| #21 | `apps/web` | `src/app/api/perf/route.ts` | edit | +| #19 | `apps/web` | `src/components/security/MfaSetup.tsx` | edit | +| #19 | `apps/web` | `package.json` | edit (neue Dep: `qrcode` + `@types/qrcode`) | +| #20 | `packages/api` | `src/lib/webhook-dispatcher.ts` | edit | +| #20 | `packages/api` | `src/router/webhook-support.ts` | edit | +| #20 | `packages/api` | `src/lib/ssrf-guard.ts` | create | +| #22 | `apps/web` | `next.config.ts` | edit | +| #23 | `apps/web` | `src/app/api/trpc/[trpc]/route.ts` | edit | +| #23 | `apps/web` | `src/server/auth.ts` | edit (Doku/Cleanup) | +| #24 | root | `Dockerfile.dev`, `Dockerfile.prod`, `docker-compose.yml`, `docker-compose.prod.yml`, `tooling/docker/app-dev-start.sh` | edit | + +--- + +## Task-Liste (in empfohlener Reihenfolge) + +### Issue #21 — /api/perf fail-closed + Query-Token entfernen + +- [ ] **Task 1:** `GET`-Handler in `apps/web/src/app/api/perf/route.ts` ändern: + - `if (cronSecret)` → `if (!cronSecret) return 401/403` (fail-closed) + - `queryToken`-Zweig vollständig entfernen + - Nur noch `Authorization: Bearer ` prüfen + - → Datei: `apps/web/src/app/api/perf/route.ts` + +- [ ] **Task 2:** Unit-Tests für `/api/perf`: + - Test: autorisiert per Header → 200 + - Test: kein Secret → 401 + - Test: Query-Param-Token → 401 (nicht mehr akzeptiert) + - Test: fehlende `CRON_SECRET`-Env → fail-closed (kein Metrics-Leak) + - → Datei: `apps/web/src/app/api/perf/route.test.ts` (neu) + +--- + +### Issue #19 — MFA QR lokal rendern + +- [ ] **Task 3:** `qrcode`-Paket und `@types/qrcode` zu `apps/web/package.json` hinzufügen, `pnpm install` ausführen. + +- [ ] **Task 4:** `MfaSetup.tsx` umschreiben: + - `` durch lokale QR-Generierung ersetzen + - `qrcode.toDataURL(uri)` im Client-Effekt aufrufen und als `` rendern + - Sicherstellen: der `otpauth://`-URI verlässt den Browser nicht mehr + - → Datei: `apps/web/src/components/security/MfaSetup.tsx` + +- [ ] **Task 5:** Test sicherstellen, dass kein Rendering-Request an externe QR-URL geht: + - Unit-Test oder Playwright-Test der prüft, dass kein `` mit `qrserver.com` oder `chart.googleapis.com` gerendert wird + - → Datei: `apps/web/src/components/security/MfaSetup.test.tsx` (neu oder bestehend ergänzen) + +--- + +### Issue #20 — Webhook SSRF-Schutz + +- [ ] **Task 6:** `ssrf-guard.ts` erstellen mit einer `assertWebhookUrlAllowed(url: string): void`-Funktion: + - Parst die URL, löst Hostname auf (DNS-Check via Node `dns.lookup`) + - Blockt: Loopback (`127.0.0.0/8`, `::1`), RFC1918 (`10.x`, `172.16–31.x`, `192.168.x`), Link-Local (`169.254.x`), Cloud-Metadata (`169.254.169.254`) + - Blockt: alle Schemes außer `https` (und `http` nur wenn expliziter Dev-Override gesetzt) + - Wirft `TRPCError({ code: "BAD_REQUEST" })` mit allgemeiner Fehlermeldung (ohne IP preiszugeben) + - → Datei: `packages/api/src/lib/ssrf-guard.ts` + +- [ ] **Task 7:** `ssrf-guard` in `webhook-support.ts` und `webhook-dispatcher.ts` integrieren: + - Vor Speicherung + vor Dispatch `assertWebhookUrlAllowed(url)` aufrufen + - → Dateien: `packages/api/src/router/webhook-support.ts`, `packages/api/src/lib/webhook-dispatcher.ts` + +- [ ] **Task 8:** Unit-Tests für `ssrf-guard.ts`: + - Erlaubt: `https://example.com/hook` + - Blockt: `http://localhost/…`, `http://127.0.0.1/…`, `http://10.0.0.1/…`, `http://192.168.1.1/…`, `http://169.254.169.254/…`, `ftp://…` + - → Datei: `packages/api/src/__tests__/ssrf-guard.test.ts` (neu) + +--- + +### Issue #22 — CSP härten + +- [ ] **Task 9:** CSP in `apps/web/next.config.ts` überarbeiten: + - `unsafe-eval` entfernen oder nur für `NODE_ENV === "development"` erlauben + - `unsafe-inline` aus `script-src` entfernen + - Nonce-basierte Inline-Scripts prüfen: Next.js 15 unterstützt CSP-Nonces via `nonce`-Prop auf `
@@ -676,10 +729,29 @@ export function AllocationsClient() {
-
+
+

{allocationQueryFailure.title}

+

{allocationQueryFailure.detail}

+ {allocationQueryFailure.actionLabel && allocationQueryFailure.actionHref && ( + + {allocationQueryFailure.actionLabel} + + )} +
+
+

{emptyState.title}

{emptyState.detail}

{emptyState.showResetAction && ( @@ -696,10 +768,10 @@ export function AllocationsClient() {