From a0f4a3564aa3d9b350e0c7591b36fb75eb0039b9 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 26 Jul 2026 23:01:13 +0200 Subject: [PATCH] Add CrowdSec AppSec mode to reverse proxy access control --- src/interfaces/ReverseProxy.ts | 11 ++ .../ReverseProxyAccessControlRules.tsx | 35 +++++- .../ReverseProxyAppSecInspection.tsx | 107 ++++++++++++++++++ .../reverse-proxy/ReverseProxyModal.tsx | 1 + .../clusters/ClustersFeaturesCell.tsx | 12 +- .../ReverseProxyEventsAuthMethodCell.tsx | 15 +++ .../events/ReverseProxyEventsReasonCell.tsx | 16 ++- .../table/ReverseProxyAccessControlCell.tsx | 38 ++++++- 8 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 src/modules/reverse-proxy/ReverseProxyAppSecInspection.tsx diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 4196a2326..555e8e461 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -33,12 +33,21 @@ export const CrowdSecMode = { export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode]; +export const AppSecMode = { + OFF: "off", + ENFORCE: "enforce", + OBSERVE: "observe", +} as const; + +export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode]; + export interface AccessRestrictions { allowed_cidrs?: string[]; blocked_cidrs?: string[]; allowed_countries?: string[]; blocked_countries?: string[]; crowdsec_mode?: CrowdSecMode; + appsec_mode?: AppSecMode; } export interface ReverseProxyMeta { @@ -122,6 +131,7 @@ export interface ReverseProxyDomain { supports_custom_ports?: boolean; require_subdomain?: boolean; supports_crowdsec?: boolean; + supports_appsec?: boolean; supports_private?: boolean; } @@ -188,6 +198,7 @@ export interface ReverseProxyCluster { supports_custom_ports?: boolean; require_subdomain?: boolean; supports_crowdsec?: boolean; + supports_appsec?: boolean; // True when at least one connected proxy in this cluster is running embedded // in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. // Lets the dashboard distinguish per-peer / private clusters from centralised diff --git a/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx index 30ed21b03..443dc998d 100644 --- a/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx +++ b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx @@ -19,8 +19,13 @@ import { SelectOption, } from "@components/select/SelectDropdown"; import { CountrySelector } from "@/components/ui/CountrySelector"; -import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy"; +import { + AccessRestrictions, + AppSecMode, + CrowdSecMode, +} from "@/interfaces/ReverseProxy"; import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation"; +import { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection"; type AccessAction = "allow" | "block"; type AccessRuleType = "country" | "ip" | "cidr"; @@ -121,6 +126,7 @@ function restrictionsToRules( function rulesToRestrictions( rules: AccessRule[], crowdsecMode?: CrowdSecMode, + appsecMode?: AppSecMode, ): AccessRestrictions | undefined { const allowed_countries: string[] = []; const blocked_countries: string[] = []; @@ -144,12 +150,14 @@ function rulesToRestrictions( } const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF; + const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF; const hasAny = allowed_countries.length > 0 || blocked_countries.length > 0 || allowed_cidrs.length > 0 || blocked_cidrs.length > 0 || - hasCrowdSec; + hasCrowdSec || + hasAppSec; if (!hasAny) return undefined; @@ -159,6 +167,7 @@ function rulesToRestrictions( ...(allowed_cidrs.length > 0 && { allowed_cidrs }), ...(blocked_cidrs.length > 0 && { blocked_cidrs }), ...(hasCrowdSec && { crowdsec_mode: crowdsecMode }), + ...(hasAppSec && { appsec_mode: appsecMode }), }; } @@ -167,6 +176,7 @@ type Props = { onChange: (value: AccessRestrictions | undefined) => void; onValidationChange?: (hasErrors: boolean) => void; supportsCrowdSec?: boolean; + supportsAppSec?: boolean; }; function validateRule(rule: AccessRule): string { @@ -193,6 +203,7 @@ export const ReverseProxyAccessControlRules = ({ onChange, onValidationChange, supportsCrowdSec, + supportsAppSec, }: Props) => { const [rules, dispatch] = useReducer( rulesReducer, @@ -200,6 +211,10 @@ export const ReverseProxyAccessControlRules = ({ restrictionsToRules, ); + const [appsecMode, setAppsecMode] = useState( + value?.appsec_mode ?? AppSecMode.OFF, + ); + const [crowdsecMode, setCrowdsecMode] = useState( value?.crowdsec_mode ?? CrowdSecMode.OFF, ); @@ -227,8 +242,14 @@ export const ReverseProxyAccessControlRules = ({ }, [supportsCrowdSec]); useEffect(() => { - onChangeRef.current(rulesToRestrictions(rules, crowdsecMode)); - }, [rules, crowdsecMode]); + if (!supportsAppSec) { + setAppsecMode(AppSecMode.OFF); + } + }, [supportsAppSec]); + + useEffect(() => { + onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode)); + }, [rules, crowdsecMode, appsecMode]); useEffect(() => { onValidationChangeRef.current?.(hasErrors); @@ -242,6 +263,12 @@ export const ReverseProxyAccessControlRules = ({ onChange={setCrowdsecMode} /> )} + {supportsAppSec && ( + + )}
diff --git a/src/modules/reverse-proxy/ReverseProxyAppSecInspection.tsx b/src/modules/reverse-proxy/ReverseProxyAppSecInspection.tsx new file mode 100644 index 000000000..8a72c7286 --- /dev/null +++ b/src/modules/reverse-proxy/ReverseProxyAppSecInspection.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import { ReactNode } from "react"; +import { Label } from "@components/Label"; +import HelpText from "@components/HelpText"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react"; +import { HelpTooltip } from "@components/HelpTooltip"; +import { AppSecMode } from "@/interfaces/ReverseProxy"; +import Image from "next/image"; +import CrowdSecIconImage from "@/assets/integrations/crowdsec.png"; + +type Props = { + value: AppSecMode; + onChange: (value: AppSecMode) => void; +}; + +type AppSecOption = { + label: string; + description?: string; + icon: ReactNode; +}; + +const APPSEC_OPTIONS: Record = { + [AppSecMode.OFF]: { + label: "Disabled", + icon: , + }, + [AppSecMode.ENFORCE]: { + label: "Enforce", + description: + "Flagged requests are blocked with 403. Every request is inspected before it reaches the service, and requests are denied if the engine is unreachable (fail-closed).", + icon: , + }, + [AppSecMode.OBSERVE]: { + label: "Observe", + description: + "Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.", + icon: , + }, +}; + +export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => { + const selected = APPSEC_OPTIONS[value]; + + return ( +
+
+
+ {"CrowdSec"} +
+
+ + + Inspect HTTP requests for exploits.{" "} + Enforce to block them or{" "} + Observe to only log without + blocking. + +
+
+ + +
+ ); +}; diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 6e24df90a..9bf11e69b 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -814,6 +814,7 @@ export default function ReverseProxyModal({ onChange={setAccessRestrictions} onValidationChange={setAccessControlHasErrors} supportsCrowdSec={selectedDomain?.supports_crowdsec} + supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec} />
diff --git a/src/modules/reverse-proxy/clusters/ClustersFeaturesCell.tsx b/src/modules/reverse-proxy/clusters/ClustersFeaturesCell.tsx index 567127c17..93d4f745a 100644 --- a/src/modules/reverse-proxy/clusters/ClustersFeaturesCell.tsx +++ b/src/modules/reverse-proxy/clusters/ClustersFeaturesCell.tsx @@ -47,6 +47,15 @@ export default function ClustersFeaturesCell({ cluster }: Readonly) { icon: , }); } + if (cluster.supports_appsec) { + features.push({ + key: "appsec", + label: "AppSec (WAF)", + description: + "Cluster has a CrowdSec AppSec endpoint configured across all active proxies, so services can inspect HTTP requests for exploits.", + icon: , + }); + } if (cluster.private) { features.push({ key: "private", @@ -56,8 +65,7 @@ export default function ClustersFeaturesCell({ cluster }: Readonly) { Lets you publish services that are only reachable from peers in your NetBird network. Required for{" "} NetBird-Only Access{" "} - and{" "} - Proxy Cluster{" "} + and Proxy Cluster{" "} target types. ), diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx index 9e8abf9e9..564570290 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx @@ -111,6 +111,21 @@ export const ReverseProxyEventsAuthMethodCell = ({ icon: , label: "CrowdSec Unavailable", }; + case "appsec_ban": + return { + icon: , + label: "AppSec Ban", + }; + case "appsec_captcha": + return { + icon: , + label: "AppSec Captcha", + }; + case "appsec_unavailable": + return { + icon: , + label: "AppSec Unavailable", + }; default: return { icon: null, diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx index 18a32fe45..db63dac20 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx @@ -9,6 +9,9 @@ const VERDICT_LABELS: Record = { crowdsec_ban: "Ban", crowdsec_captcha: "Captcha", crowdsec_throttle: "Throttle", + appsec_ban: "Ban", + appsec_captcha: "Captcha", + appsec_unavailable: "Unavailable", }; type Props = { @@ -17,12 +20,16 @@ type Props = { export const ReverseProxyEventsReasonCell = ({ event }: Props) => { const metadata = event.metadata; - const verdict = metadata?.crowdsec_verdict; + // Only one of the two can be the reason a request was flagged without being + // blocked, since an enforced block sets auth_method_used instead. + const source = metadata?.appsec_verdict ? "appsec" : "crowdsec"; + const verdictKey = `${source}_verdict`; + const verdict = metadata?.[verdictKey]; - if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) { + if (verdict && !event.auth_method_used?.startsWith(`${source}_`)) { const verdictLabel = VERDICT_LABELS[verdict] ?? verdict; const metaEntries = Object.entries(metadata!).filter( - ([k]) => k !== "crowdsec_verdict", + ([k]) => k !== verdictKey, ); return ( @@ -49,7 +56,8 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
- CrowdSec Observe: {verdictLabel} + {source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "} + {verdictLabel}
diff --git a/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx b/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx index a00bfab15..2d86ff296 100644 --- a/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx +++ b/src/modules/reverse-proxy/table/ReverseProxyAccessControlCell.tsx @@ -20,7 +20,7 @@ import { useMemo } from "react"; import { useCountries } from "@/contexts/CountryProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; -import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy"; +import { AppSecMode, CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy"; type RuleEntry = { key: string; @@ -44,21 +44,35 @@ export default function ReverseProxyAccessControlCell({ const canConfigure = !!permission?.services?.update; const restrictions = reverseProxy.access_restrictions; - const supportsCrowdSec = domains?.find( - (d) => d.domain === reverseProxy.proxy_cluster, - )?.supports_crowdsec; + // Services on a custom domain carry its target_cluster in proxy_cluster, so + // matching on domain alone misses them and reports no capability. + const clusterDomain = domains?.find( + (d) => + d.domain === reverseProxy.proxy_cluster || + d.target_cluster === reverseProxy.proxy_cluster, + ); + + const supportsCrowdSec = clusterDomain?.supports_crowdsec; const hasCrowdSec = supportsCrowdSec && restrictions?.crowdsec_mode != null && restrictions.crowdsec_mode !== CrowdSecMode.OFF; + const supportsAppSec = clusterDomain?.supports_appsec; + + const hasAppSec = + supportsAppSec && + restrictions?.appsec_mode != null && + restrictions.appsec_mode !== AppSecMode.OFF; + const ruleCount = (restrictions?.allowed_cidrs?.length ?? 0) + (restrictions?.blocked_cidrs?.length ?? 0) + (restrictions?.allowed_countries?.length ?? 0) + (restrictions?.blocked_countries?.length ?? 0) + - (hasCrowdSec ? 1 : 0); + (hasCrowdSec ? 1 : 0) + + (hasAppSec ? 1 : 0); const rulesBadge = ( 0;