From 82c31f8b0a80579b1059f0f9225075709c324c69 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 6 Jun 2026 14:53:35 +0100 Subject: [PATCH 1/3] fix(auth): Treat any unauthorized API response as an expired session Only auth endpoints used to trigger a logout on 401. A dashboard tab left open past session expiry kept polling with a dead token, and the resulting stream of failed requests looked like a brute-force attack to the security module, which repeatedly auto-blocked the operator's IP. The first 401 outside the login and setup pages now clears the session and returns to the login page, which also stops all pollers. --- src/services/api.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 9dc5c7a..778f176 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -36,10 +36,12 @@ apiClient.interceptors.request.use((config) => { apiClient.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401 && !window.location.pathname.includes("/setup")) { + if (error.response?.status === 401) { const failedURL: string = error.config?.url || ""; - const isSessionEndpoint = /\/auth\/|\/users\/me(\b|\/)/.test(failedURL); - if (isSessionEndpoint) { + const isLoginAttempt = failedURL.includes("/auth/login"); + const onAuthPage = + window.location.pathname.includes("/login") || window.location.pathname.includes("/setup"); + if (!isLoginAttempt && !onAuthPage) { localStorage.removeItem("auth_token"); window.location.href = "/login"; } From 0a393b887f58bdcb539099903a5a5fbd1705f236 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 6 Jun 2026 14:53:45 +0100 Subject: [PATCH 2/3] feat(security): Manage whitelist and detection thresholds from the dashboard The security whitelist could only be edited through the API and the detection thresholds only through the config file. The Security view now lists whitelisted IPs, networks, and paths with add and remove controls, and the settings tab exposes the detection window, per-window limits, and auto-block duration. Threshold changes persist and apply to the running detector immediately. --- src/services/api.ts | 14 ++ src/stores/security.ts | 40 +++++- src/types/index.ts | 20 +++ src/views/SecurityView.vue | 273 ++++++++++++++++++++++++++++++++++++- 4 files changed, 340 insertions(+), 7 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 778f176..9a02e66 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -13,6 +13,8 @@ import type { SecurityStats, BlockedIP, ProtectedRoute, + WhitelistEntry, + ConfigEntry, DeploymentSecurityConfig, DomainConfig, ProtectedModeConfig, @@ -239,6 +241,13 @@ export const settingsApi = { generateSubdomain: () => apiClient.get("/subdomain/generate"), }; +export const configApi = { + list: () => apiClient.get<{ config: ConfigEntry[]; runtime: Record }>("/config"), + get: (key: string) => apiClient.get<{ entry: ConfigEntry; runtime: boolean }>(`/config/${key}`), + set: (key: string, value: unknown) => + apiClient.put<{ entry: ConfigEntry; applied: boolean }>(`/config/${key}`, { value }), +}; + export const pluginsApi = { list: () => apiClient.get("/plugins"), get: (name: string) => apiClient.get(`/plugins/${name}`), @@ -750,6 +759,11 @@ export const securityApi = { cleanup: (days?: number) => apiClient.post<{ events_deleted: number; blocks_deleted: number }>("/security/cleanup", { days }), + getWhitelist: () => apiClient.get<{ whitelist: WhitelistEntry[] }>("/security/whitelist"), + addWhitelistEntry: (entry: { value: string; type: WhitelistEntry["type"]; reason?: string }) => + apiClient.post<{ id: number }>("/security/whitelist", entry), + removeWhitelistEntry: (id: number) => apiClient.delete<{ message: string }>(`/security/whitelist/${id}`), + getBlockedIPs: () => apiClient.get<{ blocked_ips: BlockedIP[] }>("/security/blocked-ips"), blockIP: (ip: string, reason?: string, duration?: number) => apiClient.post<{ id: number; message: string }>("/security/blocked-ips", { ip, reason, duration }), diff --git a/src/stores/security.ts b/src/stores/security.ts index 5387af5..a05e9be 100644 --- a/src/stores/security.ts +++ b/src/stores/security.ts @@ -1,13 +1,14 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { securityApi, type SecurityHealthCheck, type SecurityRefreshResponse } from "@/services/api"; -import type { SecurityEvent, SecurityStats, BlockedIP, ProtectedRoute } from "@/types"; +import type { SecurityEvent, SecurityStats, BlockedIP, ProtectedRoute, WhitelistEntry } from "@/types"; export const useSecurityStore = defineStore("security", () => { const stats = ref(null); const events = ref([]); const eventsTotal = ref(0); const blockedIPs = ref([]); + const whitelist = ref([]); const protectedRoutes = ref([]); const securityEnabled = ref(false); const realtimeCapture = ref(false); @@ -82,6 +83,39 @@ export const useSecurityStore = defineStore("security", () => { } } + async function fetchWhitelist() { + loading.value = true; + error.value = null; + try { + const response = await securityApi.getWhitelist(); + whitelist.value = response.data.whitelist || []; + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + } finally { + loading.value = false; + } + } + + async function addWhitelistEntry(entry: { value: string; type: WhitelistEntry["type"]; reason?: string }) { + try { + await securityApi.addWhitelistEntry(entry); + await fetchWhitelist(); + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + throw e; + } + } + + async function removeWhitelistEntry(id: number) { + try { + await securityApi.removeWhitelistEntry(id); + await fetchWhitelist(); + } catch (e: any) { + error.value = e.response?.data?.error || e.message; + throw e; + } + } + async function fetchProtectedRoutes() { loading.value = true; error.value = null; @@ -188,6 +222,7 @@ export const useSecurityStore = defineStore("security", () => { events, eventsTotal, blockedIPs, + whitelist, protectedRoutes, securityEnabled, realtimeCapture, @@ -199,6 +234,9 @@ export const useSecurityStore = defineStore("security", () => { fetchBlockedIPs, blockIP, unblockIP, + fetchWhitelist, + addWhitelistEntry, + removeWhitelistEntry, fetchProtectedRoutes, addProtectedRoute, updateProtectedRoute, diff --git a/src/types/index.ts b/src/types/index.ts index c21ed3c..195d6d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -226,6 +226,24 @@ export interface ProtectedRoute { created_at: string; } +export interface WhitelistEntry { + id: number; + value: string; + type: "ip" | "cidr" | "path"; + reason?: string; + is_internal: boolean; + created_at: string; +} + +export interface ConfigEntry { + key: string; + type: string; + value: unknown; + default?: unknown; + description?: string; + sensitive?: boolean; +} + export interface SecurityStats { total_events: number; last_24_hours: number; @@ -327,6 +345,8 @@ export type Permission = | "apikeys:delete" | "settings:read" | "settings:write" + | "config:read" + | "config:write" | "audit:read" | "containers:read" | "containers:write" diff --git a/src/views/SecurityView.vue b/src/views/SecurityView.vue index dda5589..a1771ce 100644 --- a/src/views/SecurityView.vue +++ b/src/views/SecurityView.vue @@ -384,6 +384,48 @@ + +
+
+

Whitelisted Sources

+ +
+ +
+
+ +

No whitelist entries

+ Whitelisted IPs, networks, and paths never generate events or blocks +
+
+
+
+ {{ entry.value }} + {{ entry.reason }} +
+ {{ entry.type.toUpperCase() }} + Internal + Added {{ formatTime(entry.created_at) }} +
+
+ +
+
+
+
+
@@ -521,6 +563,46 @@
+
+
+
+

Detection Thresholds

+

+ Limits applied per source IP within the detection window. When a limit is exceeded, the IP is + automatically blocked for the configured duration. Changes apply immediately, no restart needed. +

+
+
+
+
+ + + + {{ field.hint }} +
+
+
+ +
+
+
@@ -573,6 +655,49 @@
+ + + + +