Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1113,8 +1113,14 @@ DELETE /v1/safelist/{prefix}
Authorization: Bearer <token>
```

`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
Expand Down
12 changes: 12 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 15 additions & 9 deletions docs/detectors/fastnetmon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions frontend/__tests__/auth-lib.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
126 changes: 126 additions & 0 deletions frontend/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
9 changes: 7 additions & 2 deletions frontend/app/(dashboard)/mitigations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
19 changes: 11 additions & 8 deletions frontend/app/api/prefixd/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`

Expand Down Expand Up @@ -53,33 +60,29 @@ 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(
request: NextRequest,
{ 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(
request: NextRequest,
{ 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(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
const fullPath = "/" + path.join("/")
return proxyRequest(request, fullPath)
return proxyRequest(request, upstreamPath(path))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -269,10 +270,12 @@ function AdapterRow({
<button
type="button"
aria-label="Copy endpoint"
onClick={() => {
navigator.clipboard.writeText(endpoint).then(() => {
onClick={async () => {
if (await copyText(endpoint)) {
toast.success("Endpoint copied")
})
} else {
toast.error("Copy failed — clipboard unavailable")
}
}}
className="p-1 hover:bg-muted rounded"
>
Expand Down
9 changes: 7 additions & 2 deletions frontend/components/dashboard/event-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { SourceBadge } from "./source-badge"
import { ConfidenceBar } from "./confidence-bar"
import type { Event } from "@/lib/api"
import { copyText } from "@/lib/clipboard"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"

Expand Down Expand Up @@ -84,8 +86,11 @@ export function EventDetailPanel({ event, onClose }: EventDetailPanelProps) {

if (!event) return null

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)
}
Expand Down
Loading
Loading