From 726402770125645911f78d29d9d3ef3f06b8f0df Mon Sep 17 00:00:00 2001 From: Lance Tuller Date: Sat, 26 Sep 2026 12:08:48 -0400 Subject: [PATCH 1/5] fix(dashboard): re-encode proxied path segments so safelist deletes work Next.js hands the catch-all proxy already percent-decoded segments, so `DELETE /v1/safelist/8.9.8.9%2F32` was re-sent as `/v1/safelist/8.9.8.9/32` and missed the single-segment route: the dashboard reported "API error 404" and the prefix stayed safelisted. Re-encode each segment before forwarding, and make the backend route a wildcard capture so clients and proxies that decode `%2F` (curl, other reverse proxies) work against it too. Verified: proxied `%2F` delete 404 -> 204, add/remove of 203.0.113.77/32 through the dashboard UI, plus regression tests for both slash forms. Fixes #146 --- docs/api.md | 6 ++ frontend/app/api/prefixd/[...path]/route.ts | 19 +++--- src/api/routes.rs | 4 +- tests/integration.rs | 67 +++++++++++++++++++++ 4 files changed, 87 insertions(+), 9 deletions(-) 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/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/src/api/routes.rs b/src/api/routes.rs index c65e852..eefe21f 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -68,8 +68,10 @@ fn api_routes() -> Router> { "/v1/safelist", get(handlers::list_safelist).post(handlers::add_safelist), ) + // Wildcard capture: prefixes contain a slash (8.9.8.9/32). Proxies differ + // in whether they hand over `%2F` or a raw slash, so accept both. .route( - "/v1/safelist/{prefix}", + "/v1/safelist/{*prefix}", axum::routing::delete(handlers::remove_safelist), ) .route("/v1/config/reload", post(handlers::reload_config)) diff --git a/tests/integration.rs b/tests/integration.rs index 9c49917..0f285b6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -6918,6 +6918,73 @@ async fn test_remove_safelist() { assert_eq!(response.status(), StatusCode::NO_CONTENT); } +/// Dashboard/CLI send the CIDR as a single percent-encoded path segment. +#[tokio::test] +async fn test_remove_safelist_percent_encoded_cidr() { + let app = setup_app().await; + + let add_body = serde_json::json!({ "prefix": "203.0.113.9/32" }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/safelist") + .header("content-type", "application/json") + .body(Body::from(add_body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/v1/safelist/203.0.113.9%2F32") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +/// Proxies that decode `%2F` (or hand-written curl calls) send the CIDR with a +/// literal slash; the route must accept that form too. +#[tokio::test] +async fn test_remove_safelist_literal_slash_cidr() { + let app = setup_app().await; + + let add_body = serde_json::json!({ "prefix": "203.0.113.10/32" }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/safelist") + .header("content-type", "application/json") + .body(Body::from(add_body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri("/v1/safelist/203.0.113.10/32") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + #[tokio::test] async fn test_remove_safelist_not_found() { let app = setup_app().await; From 081416ae5957a04a906ed4e332fb59af7e21c06e Mon Sep 17 00:00:00 2001 From: Lance Tuller Date: Sat, 26 Sep 2026 12:08:54 -0400 Subject: [PATCH 2/5] fix(dashboard): parse the flat auth operator so sign-out and roles work /v1/auth/login and /v1/auth/me return the operator flat ({operator_id, username, role}), but the client read `data.operator`, which is undefined. The user menu (user-menu.tsx returns null without an operator) never rendered, so a signed-in user had no way to sign out or switch roles, and every `usePermissions` role check denied admin actions to real admins. Map the wire shape into the client Operator and make isAuthenticated a strict boolean so a partial payload cannot read as authenticated. Verified in a browser: header shows the username with Notifications/Sign out, sign-out lands on /login, a viewer login shows the viewer role and hides the Admin nav. Fixes #147 --- frontend/__tests__/auth-lib.test.ts | 58 +++++++++++++++++++++++++++++ frontend/hooks/use-auth.tsx | 2 +- frontend/lib/auth.ts | 18 ++++++--- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 frontend/__tests__/auth-lib.test.ts 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/hooks/use-auth.tsx b/frontend/hooks/use-auth.tsx index db3e6d9..26b7565 100644 --- a/frontend/hooks/use-auth.tsx +++ b/frontend/hooks/use-auth.tsx @@ -57,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { () => ({ operator, isLoading, - isAuthenticated: operator !== null, + isAuthenticated: Boolean(operator), login, logout, refresh, diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index be78c68..3dc9988 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -14,8 +14,11 @@ export interface LoginRequest { password: string } -export interface LoginResponse { - operator: Operator +/** Wire shape of /v1/auth/login and /v1/auth/me responses (flat, snake_case). */ +export interface OperatorResponse { + operator_id: string + username: string + role: OperatorRole } export interface AuthState { @@ -40,8 +43,8 @@ export async function login(credentials: LoginRequest): Promise { throw new Error(`Login failed: ${error}`) } - const data: LoginResponse = await res.json() - return data.operator + const data: OperatorResponse = await res.json() + return { id: data.operator_id, username: data.username, role: data.role } } export async function logout(): Promise { @@ -64,8 +67,11 @@ export async function getCurrentUser(): Promise { throw new Error("Failed to get current user") } - const data: { operator: Operator } = await res.json() - return data.operator + const data: OperatorResponse = await res.json() + if (!data?.operator_id) { + return null + } + return { id: data.operator_id, username: data.username, role: data.role } } catch { return null } From c71b23b5afc4388ec289121720f476ff01eea4d1 Mon Sep 17 00:00:00 2001 From: Lance Tuller Date: Sat, 26 Sep 2026 12:08:55 -0400 Subject: [PATCH 3/5] fix(scripts): keep idle protocol lines from labelling the FastNetMon vector Vector inference matched the first protocol word anywhere in FastNetMon's details, so the idle line `outgoing udp traffic: 0 mbps` won over the actual attack type and SYN floods were reported as udp_flood. Ignore lines that report a zero metric, match tokens on word boundaries (so `ack` no longer matches inside "packets"), and prefer TCP flag vectors over udp. Verified: the reporter's details yield udp_flood with the old logic and syn_flood with the new one; covered by tests/fastnetmon_vector.rs, which drives the script with a stub curl. Fixes #145 --- docs/detectors/fastnetmon.md | 24 ++++--- scripts/prefixd-fastnetmon.sh | 25 ++++++-- tests/fastnetmon_vector.rs | 114 ++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 tests/fastnetmon_vector.rs 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/scripts/prefixd-fastnetmon.sh b/scripts/prefixd-fastnetmon.sh index 6321343..568c67f 100755 --- a/scripts/prefixd-fastnetmon.sh +++ b/scripts/prefixd-fastnetmon.sh @@ -142,15 +142,28 @@ EVENT_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null | # Infer attack vector from raw details VECTOR="unknown" -RAW_LOWER=$(echo "$RAW_DETAILS" | tr '[:upper:]' '[:lower:]') -if [[ "$RAW_LOWER" == *"udp"* ]]; then - VECTOR="udp_flood" -elif [[ "$RAW_LOWER" == *"syn"* ]]; then +RAW_LOWER=$(printf '%s' "$RAW_DETAILS" | tr '[:upper:]' '[:lower:]') + +# FastNetMon lists every protocol it tracks, including idle ones (e.g. +# "outgoing udp traffic: 0 mbps"). Lines reporting a zero metric carry no +# attack signal, so drop them before matching -- otherwise a SYN flood whose +# details also mention udp at 0 mbps was mislabelled as udp_flood. Details +# without a metric (e.g. "attack type: syn_flood") always survive. +ZERO_METRIC_RE='(^|[^0-9.])0+(\.0+)?[[:space:]]*(mbps|kbps|gbps|bps|pps|kpps|mpps|fps|flows)' +RAW_SIGNAL=$(printf '%s\n' "$RAW_LOWER" | grep -vE "$ZERO_METRIC_RE" || true) + +# \b keeps "ack" out of "packets"; the *_ and "tcp_*" alternatives keep +# "syn_flood"/"tcp_syn" matching. TCP flag vectors are tested before udp for +# mixed floods. +VECTOR="unknown" +if grep -qE '\bsyn\b|syn_|tcp[ _-]syn' <<< "$RAW_SIGNAL"; then VECTOR="syn_flood" -elif [[ "$RAW_LOWER" == *"ack"* ]]; then +elif grep -qE '\back\b|ack_|tcp[ _-]ack' <<< "$RAW_SIGNAL"; then VECTOR="ack_flood" -elif [[ "$RAW_LOWER" == *"icmp"* ]]; then +elif grep -qE '\bicmp\b|icmp_' <<< "$RAW_SIGNAL"; then VECTOR="icmp_flood" +elif grep -qE '\budp\b|udp_' <<< "$RAW_SIGNAL"; then + VECTOR="udp_flood" fi # Build JSON payload diff --git a/tests/fastnetmon_vector.rs b/tests/fastnetmon_vector.rs new file mode 100644 index 0000000..f8efd44 --- /dev/null +++ b/tests/fastnetmon_vector.rs @@ -0,0 +1,114 @@ +//! Regression tests for attack-vector inference in scripts/prefixd-fastnetmon.sh. +//! +//! The script maps FastNetMon's stdin details to a prefixd vector. Idle protocol +//! lines ("outgoing udp traffic: 0 mbps") must not select a vector: they were +//! previously matched first and mislabelled SYN floods as udp_flood. +//! +//! The script is driven with a stub `curl` that records the payload it would +//! have POSTed, so no network or prefixd instance is required. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::{Command, Stdio}; + +const SCRIPT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/prefixd-fastnetmon.sh"); + +/// Run a `ban` invocation with `details` on stdin, returning the vector in the +/// payload handed to curl. +fn detected_vector(details: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let bin_dir = dir.path().join("bin"); + fs::create_dir(&bin_dir).expect("mkdir bin"); + let args_log = dir.path().join("curl-args"); + + // Emits a body plus the trailing status line `curl -w "%{http_code}"` prints, + // so the script sees a successful ingest. + let stub = format!( + "#!/bin/bash\nprintf '%s\\0' \"$@\" >> '{}'\ncat > /dev/null\nprintf '{{\"status\":\"accepted\"}}\\n201'\nexit 0\n", + args_log.display() + ); + let curl_path = bin_dir.join("curl"); + fs::write(&curl_path, stub).expect("write curl stub"); + fs::set_permissions(&curl_path, fs::Permissions::from_mode(0o755)).expect("chmod curl stub"); + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + + let mut child = Command::new("bash") + .arg(SCRIPT) + .args(["198.51.100.7", "incoming", "250000", "ban"]) + .env("PATH", path) + .env("PREFIXD_API", "http://127.0.0.1:1") + .env("PREFIXD_OPERATOR", "test_operator") + .env("PREFIXD_LOG", dir.path().join("script.log")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn script"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("stdin"); + stdin.write_all(details.as_bytes()).expect("write stdin"); + } + + let output = child.wait_with_output().expect("script output"); + assert!( + output.status.success(), + "script exited with {:?}: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + + let logged = fs::read_to_string(&args_log).expect("curl was never called"); + let payload = logged + .split('\0') + .find(|arg| arg.contains("\"vector\"")) + .expect("payload with vector not passed to curl"); + let json: serde_json::Value = serde_json::from_str(payload).expect("payload is valid JSON"); + + json["vector"] + .as_str() + .expect("vector is a string") + .to_string() +} + +#[test] +fn syn_flood_wins_over_idle_udp_line() { + let details = "Incoming traffic: 152.4 mbps\n\ + Outgoing traffic: 0 mbps\n\ + Incoming udp traffic: 0.0 mbps\n\ + Outgoing udp traffic: 0 mbps\n\ + Incoming pps: 380000\n\ + Attack type: syn_flood"; + assert_eq!(detected_vector(details), "syn_flood"); +} + +#[test] +fn udp_flood_from_nonzero_udp_line() { + let details = "Incoming traffic: 0 mbps\n\ + Outgoing udp traffic: 900 mbps\n\ + Incoming tcp syn: 0 mbps"; + assert_eq!(detected_vector(details), "udp_flood"); +} + +#[test] +fn ack_flood_not_confused_by_packets_word() { + let details = "Incoming traffic: 420 mbps\n\ + Incoming pps: 900000 packets per second\n\ + Attack type: tcp_ack"; + assert_eq!(detected_vector(details), "ack_flood"); +} + +#[test] +fn all_zero_metrics_yield_unknown() { + let details = "Incoming traffic: 0 mbps\n\ + Outgoing traffic: 0 mbps\n\ + Incoming udp traffic: 0 mbps\n\ + Incoming tcp syn: 0 mbps"; + assert_eq!(detected_vector(details), "unknown"); +} From 83357275b0b5f30867a60e4383a10502dfc6dae6 Mon Sep 17 00:00:00 2001 From: Lance Tuller Date: Sat, 26 Sep 2026 12:09:01 -0400 Subject: [PATCH 4/5] fix(dashboard): report clipboard failures instead of silently not copying navigator.clipboard only exists in secure contexts, so copy buttons on a plain-HTTP dashboard threw a TypeError, copied nothing and said nothing -- "incident report not in clipboard" with no visible error. Route every copy through lib/clipboard.ts: use the Clipboard API when the context allows it, fall back to a hidden textarea, and check the clipboard-write permission first, because Chrome reports `denied` for insecure origins and still returns true from the legacy copy command. Callers now surface "Copy failed -- clipboard unavailable". Chrome blocks clipboard writes for plain-HTTP origins other than localhost, so production dashboards need HTTPS; documented in docs/deployment.md. Verified in Chrome: on a secure origin the incident report is copied verbatim (read back off the X11 clipboard), on an HTTP origin the failure toast shows and nothing is claimed as copied. Refs #144 --- docs/deployment.md | 12 ++ frontend/__tests__/clipboard.test.ts | 126 ++++++++++++++++++ .../app/(dashboard)/mitigations/[id]/page.tsx | 9 +- .../correlation/webhook-adapters.tsx | 9 +- .../dashboard/event-detail-panel.tsx | 9 +- .../dashboard/incident-report-dialog.tsx | 6 +- frontend/lib/clipboard.ts | 44 ++++++ 7 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 frontend/__tests__/clipboard.test.ts create mode 100644 frontend/lib/clipboard.ts 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/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/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({