diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a0d8b..d17bc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Safelist removal from the dashboard returned 404.** The Next.js API proxy joins percent-decoded route segments, so `DELETE /v1/safelist/8.9.8.9%2F32` reached the backend as `/v1/safelist/8.9.8.9/32` and missed the single-segment route. The proxy now re-encodes each segment, and the route accepts a literal slash form (`{*prefix}`) for proxies that decode `%2F`. +- **Dashboard had no sign-out (and no role-based permissions).** `/v1/auth/login` and `/v1/auth/me` return a flat operator (`operator_id`), but the client read `data.operator`, leaving `operator` as `undefined`. The user menu (and therefore sign-out) never rendered, and role checks denied admin actions to authenticated admins. Auth responses are now mapped to the client `Operator` shape and `isAuthenticated` is a strict boolean. +- **FastNetMon SYN floods labelled `udp_flood`.** Vector detection matched the first protocol word anywhere in the attack details, so an idle line such as `outgoing udp traffic: 0 mbps` won over `syn_flood`. Zero-metric lines are ignored, TCP flag vectors are matched before `udp`, and matches are word-bounded (`ack` no longer matches inside `packets`). +- **Copy buttons failed silently on plain-HTTP deployments.** `navigator.clipboard` only exists in secure contexts, so the incident report, FlowSpec rule, and webhook endpoint copy actions threw and reported nothing when the dashboard was served over HTTP. The copy helper now falls back to a hidden textarea, checks `clipboard-write` permission, and surfaces a "Copy failed — clipboard unavailable" toast instead of claiming success. Chrome ignores clipboard writes entirely for plain-HTTP origins (other than `localhost`), so production dashboards need HTTPS for clipboard actions — documented in `docs/deployment.md`. + ## [0.19.1] - 2026-08-06 ### Changed diff --git a/docs/api.md b/docs/api.md index e0ef598..e1a764e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1113,8 +1113,14 @@ DELETE /v1/safelist/{prefix} Authorization: Bearer ``` +`prefix` is the stored CIDR, e.g. `8.9.8.9/32`. Both a percent-encoded separator +(`/v1/safelist/8.9.8.9%2F32`) and a literal slash (`/v1/safelist/8.9.8.9/32`) +are accepted, so proxies that decode `%2F` work too. + **Response (204 No Content)** +**Response (404 Not Found)** — prefix is not in the safelist. + --- ## Authentication Endpoints diff --git a/docs/deployment.md b/docs/deployment.md index e220f90..039bc11 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -137,6 +137,18 @@ When deploying to a remote server, ensure: open http://your-server ``` +### Clipboard on Plain HTTP + +Browsers only allow clipboard writes from a **secure context**. Chrome reports +`clipboard-write: denied` for plain-HTTP origins other than `localhost` and +ignores the copy request, so the dashboard's copy buttons (incident report, +FlowSpec rule, webhook endpoint) fail there — they show a "Copy failed — +clipboard unavailable" toast rather than pretending to succeed. + +Serve the dashboard over HTTPS (see `configs/nginx.conf` and `docs/adr/005-*.md`) +to enable clipboard actions in production. `http://localhost` also works for +local development. + ### Local Development (Outside Docker) For frontend development without Docker: diff --git a/docs/detectors/fastnetmon.md b/docs/detectors/fastnetmon.md index 7e92a89..d0d2096 100644 --- a/docs/detectors/fastnetmon.md +++ b/docs/detectors/fastnetmon.md @@ -85,15 +85,21 @@ Unban correlation is done by querying active mitigations for the victim IP and w ## Vector Detection -The script infers attack vectors from FastNetMon's stdin details: - -| FastNetMon Detail | prefixd Vector | -|------------------|----------------| -| Contains "udp" | `udp_flood` | -| Contains "syn" | `syn_flood` | -| Contains "ack" | `ack_flood` | -| Contains "icmp" | `icmp_flood` | -| Other | `unknown` | +The script infers the attack vector from FastNetMon's stdin details: + +| Detail token | prefixd Vector | +|------------------------------------|----------------| +| `syn` / `syn_` / `tcp_syn` | `syn_flood` | +| `ack` / `ack_` / `tcp_ack` | `ack_flood` | +| `icmp` / `icmp_` | `icmp_flood` | +| `udp` / `udp_` | `udp_flood` | +| Other / no signal | `unknown` | + +Rules that keep the mapping honest: + +- Lines reporting a **zero metric** (`outgoing udp traffic: 0 mbps`) are ignored — FastNetMon lists every protocol it tracks, and a SYN flood otherwise matches the idle `udp` line first. +- TCP flag tokens are matched before `udp` for mixed floods. +- Matches are word-bounded, so `ack` is not detected inside `packets`. ## Testing diff --git a/frontend/__tests__/auth-lib.test.ts b/frontend/__tests__/auth-lib.test.ts new file mode 100644 index 0000000..89cb436 --- /dev/null +++ b/frontend/__tests__/auth-lib.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { login, getCurrentUser } from "@/lib/auth" + +const originalFetch = globalThis.fetch + +function mockFetch(body: unknown, status: number) { + const fetchMock = vi.fn(async () => ({ + ok: status < 400, + status, + json: async () => body, + text: async () => JSON.stringify(body), + })) + globalThis.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe("auth API responses", () => { + // Backend sends a flat snake_case operator (/v1/auth/login, /v1/auth/me). + // Reading `data.operator` left the dashboard with `undefined`, which hid the + // user menu (no sign-out) and dropped the role for permission checks. + const wire = { operator_id: "abc-123", username: "admin", role: "admin" } + + it("maps the login response to an Operator", async () => { + mockFetch(wire, 200) + + await expect(login({ username: "admin", password: "pw" })).resolves.toEqual({ + id: "abc-123", + username: "admin", + role: "admin", + }) + }) + + it("maps the current-user response to an Operator", async () => { + mockFetch(wire, 200) + + await expect(getCurrentUser()).resolves.toEqual({ + id: "abc-123", + username: "admin", + role: "admin", + }) + }) + + it("returns null for an unauthenticated session", async () => { + mockFetch({ error: "unauthorized" }, 401) + + await expect(getCurrentUser()).resolves.toBeNull() + }) + + it("returns null when the operator payload is not the documented shape", async () => { + mockFetch({ username: "admin", role: "admin" }, 200) + + await expect(getCurrentUser()).resolves.toBeNull() + }) +}) diff --git a/frontend/__tests__/clipboard.test.ts b/frontend/__tests__/clipboard.test.ts new file mode 100644 index 0000000..1de708e --- /dev/null +++ b/frontend/__tests__/clipboard.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { copyText } from "@/lib/clipboard" + +const originalExecCommand = document.execCommand +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard") +const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext") +const originalPermissions = navigator.permissions + +function setClipboardWritePermission(state: PermissionState | "unsupported") { + Object.defineProperty(navigator, "permissions", { + configurable: true, + value: { + query: async () => { + if (state === "unsupported") { + throw new TypeError("clipboard-write is not a supported permission name") + } + return { state } as PermissionStatus + }, + }, + }) +} + +function setSecureContext(value: boolean) { + Object.defineProperty(window, "isSecureContext", { value, configurable: true }) +} + +function setClipboard(clipboard: unknown) { + Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true }) +} + +/** Captures the text handed to execCommand and returns its result. */ +function stubExecCommand(result: boolean) { + let copied = "" + document.execCommand = vi.fn(() => { + copied = document.querySelector("textarea")?.value ?? "" + return result + }) as unknown as typeof document.execCommand + return () => copied +} + +afterEach(() => { + document.execCommand = originalExecCommand + Object.defineProperty(navigator, "permissions", { configurable: true, value: originalPermissions }) + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard) + } else { + delete (navigator as { clipboard?: unknown }).clipboard + } + if (originalSecureContext) { + Object.defineProperty(window, "isSecureContext", originalSecureContext) + } +}) + +describe("copyText", () => { + // Plain-HTTP deployments have no navigator.clipboard at all; copy actions + // used to fail silently (incident report "not in clipboard"). + it("falls back to execCommand when the Clipboard API is unavailable", async () => { + setSecureContext(false) + setClipboard(undefined) + setClipboardWritePermission("granted") + const copiedValue = stubExecCommand(true) + + await expect(copyText("incident report body")).resolves.toBe(true) + + expect(copiedValue()).toBe("incident report body") + expect(document.execCommand).toHaveBeenCalledWith("copy") + expect(document.querySelectorAll("textarea")).toHaveLength(0) + }) + + it("reports failure when the fallback copy is rejected", async () => { + setSecureContext(false) + setClipboard(undefined) + setClipboardWritePermission("granted") + stubExecCommand(false) + + await expect(copyText("body")).resolves.toBe(false) + }) + + // Chrome denies clipboard writes on insecure origins but still returns true + // from the legacy copy command. + it("reports failure when the browser denies clipboard writes", async () => { + setSecureContext(false) + setClipboard(undefined) + setClipboardWritePermission("denied") + const execCommand = stubExecCommand(true) + + await expect(copyText("body")).resolves.toBe(false) + + expect(execCommand()).toBe("") + }) + + it("still attempts a copy when the permissions API cannot answer", async () => { + setSecureContext(false) + setClipboard(undefined) + setClipboardWritePermission("unsupported") + const copiedValue = stubExecCommand(true) + + await expect(copyText("body")).resolves.toBe(true) + + expect(copiedValue()).toBe("body") + }) + + it("uses the Clipboard API in a secure context", async () => { + setSecureContext(true) + const writeText = vi.fn(async () => {}) + setClipboard({ writeText }) + + await expect(copyText("body")).resolves.toBe(true) + + expect(writeText).toHaveBeenCalledWith("body") + }) + + it("falls back when the Clipboard API rejects", async () => { + setSecureContext(true) + setClipboard({ + writeText: vi.fn(async () => { + throw new Error("NotAllowedError") + }), + }) + const copiedValue = stubExecCommand(true) + + await expect(copyText("body")).resolves.toBe(true) + + expect(copiedValue()).toBe("body") + }) +}) diff --git a/frontend/app/(dashboard)/mitigations/[id]/page.tsx b/frontend/app/(dashboard)/mitigations/[id]/page.tsx index e0ef42c..ed305fe 100644 --- a/frontend/app/(dashboard)/mitigations/[id]/page.tsx +++ b/frontend/app/(dashboard)/mitigations/[id]/page.tsx @@ -15,6 +15,8 @@ import { ArrowLeft, Check, Clock, Copy, FileText, ShieldAlert, Activity, GitBran import { FlowSpecPreview, formatFlowSpecRule } from "@/components/dashboard/flowspec-preview" import { IncidentReportDialog } from "@/components/dashboard/incident-report-dialog" import { withdrawMitigation, getIncidentReport } from "@/lib/api" +import { copyText } from "@/lib/clipboard" +import { toast } from "sonner" import { useState } from "react" import { AlertDialog, @@ -82,8 +84,11 @@ export default function MitigationDetailPage({ params }: { params: Promise<{ id: const [showReportDialog, setShowReportDialog] = useState(false) const [reportLoading, setReportLoading] = useState(false) - const copyToClipboard = (text: string, field: string) => { - navigator.clipboard.writeText(text) + const copyToClipboard = async (text: string, field: string) => { + if (!(await copyText(text))) { + toast.error("Copy failed — clipboard unavailable") + return + } setCopied(field) setTimeout(() => setCopied(null), 2000) } diff --git a/frontend/app/api/prefixd/[...path]/route.ts b/frontend/app/api/prefixd/[...path]/route.ts index 6e27bb1..c8c23d8 100644 --- a/frontend/app/api/prefixd/[...path]/route.ts +++ b/frontend/app/api/prefixd/[...path]/route.ts @@ -5,6 +5,13 @@ export const dynamic = "force-dynamic" // Backend URL - only accessed server-side, so no NEXT_PUBLIC_ needed const PREFIXD_API = process.env.PREFIXD_API || "http://prefixd:8080" +// Next.js hands over already percent-decoded segments, so path separators that +// arrived encoded (e.g. the `%2F` in a CIDR: /v1/safelist/8.9.8.9%2F32) would +// otherwise be re-sent as real separators and 404 in the backend router. +function upstreamPath(path: string[], search = ""): string { + return "/" + path.map(encodeURIComponent).join("/") + search +} + async function proxyRequest(request: NextRequest, path: string) { const url = `${PREFIXD_API}${path}` @@ -53,8 +60,7 @@ export async function GET( { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params - const fullPath = "/" + path.join("/") + (request.nextUrl.search || "") - return proxyRequest(request, fullPath) + return proxyRequest(request, upstreamPath(path, request.nextUrl.search || "")) } export async function POST( @@ -62,8 +68,7 @@ export async function POST( { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params - const fullPath = "/" + path.join("/") - return proxyRequest(request, fullPath) + return proxyRequest(request, upstreamPath(path)) } export async function PUT( @@ -71,8 +76,7 @@ export async function PUT( { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params - const fullPath = "/" + path.join("/") - return proxyRequest(request, fullPath) + return proxyRequest(request, upstreamPath(path)) } export async function DELETE( @@ -80,6 +84,5 @@ export async function DELETE( { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params - const fullPath = "/" + path.join("/") - return proxyRequest(request, fullPath) + return proxyRequest(request, upstreamPath(path)) } diff --git a/frontend/components/dashboard/correlation/webhook-adapters.tsx b/frontend/components/dashboard/correlation/webhook-adapters.tsx index 91f930a..cd4cad9 100644 --- a/frontend/components/dashboard/correlation/webhook-adapters.tsx +++ b/frontend/components/dashboard/correlation/webhook-adapters.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from "react" import { toast } from "sonner" +import { copyText } from "@/lib/clipboard" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -269,10 +270,12 @@ function AdapterRow({