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
11 changes: 11 additions & 0 deletions src/interfaces/ReverseProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean;
}

Expand Down Expand Up @@ -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
Expand Down
35 changes: 31 additions & 4 deletions src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,6 +126,7 @@ function restrictionsToRules(
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
appsecMode?: AppSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
Expand All @@ -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;

Expand All @@ -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 }),
};
}

Expand All @@ -167,6 +176,7 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
supportsAppSec?: boolean;
};

function validateRule(rule: AccessRule): string {
Expand All @@ -193,13 +203,18 @@ export const ReverseProxyAccessControlRules = ({
onChange,
onValidationChange,
supportsCrowdSec,
supportsAppSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
value,
restrictionsToRules,
);

const [appsecMode, setAppsecMode] = useState<AppSecMode>(
value?.appsec_mode ?? AppSecMode.OFF,
);

const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
Expand Down Expand Up @@ -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);
Expand All @@ -242,6 +263,12 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode}
/>
)}
{supportsAppSec && (
<ReverseProxyAppSecInspection
value={appsecMode}
onChange={setAppsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<HelpText>
Expand Down
107 changes: 107 additions & 0 deletions src/modules/reverse-proxy/ReverseProxyAppSecInspection.tsx
Original file line number Diff line number Diff line change
@@ -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, AppSecOption> = {
[AppSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[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: <ShieldCheckIcon size={14} />,
},
[AppSecMode.OBSERVE]: {
label: "Observe",
description:
"Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.",
icon: <EyeIcon size={14} />,
},
};

export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => {
const selected = APPSEC_OPTIONS[value];

return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec AppSec (WAF)</Label>
<HelpText>
Inspect HTTP requests for exploits.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>

<Select value={value} onValueChange={(v) => onChange(v as AppSecMode)}>
<SelectTrigger className="w-[260px]" data-testid="appsec-mode-trigger">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(APPSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
data-testid={`appsec-mode-${mode}`}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
1 change: 1 addition & 0 deletions src/modules/reverse-proxy/ReverseProxyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
/>
</div>
</TabsContent>
Expand Down
12 changes: 10 additions & 2 deletions src/modules/reverse-proxy/clusters/ClustersFeaturesCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
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: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (cluster.private) {
features.push({
key: "private",
Expand All @@ -56,8 +65,7 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
Lets you publish services that are only reachable from peers in your
NetBird network. Required for{" "}
<span className={"font-medium text-white"}>NetBird-Only Access</span>{" "}
and{" "}
<span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
and <span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
target types.
</>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ export const ReverseProxyEventsAuthMethodCell = ({
icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable",
};
case "appsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Ban",
};
case "appsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Captcha",
};
case "appsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "AppSec Unavailable",
};
default:
return {
icon: null,
Expand Down
16 changes: 12 additions & 4 deletions src/modules/reverse-proxy/events/ReverseProxyEventsReasonCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle",
appsec_ban: "Ban",
appsec_captcha: "Captcha",
appsec_unavailable: "Unavailable",
};

type Props = {
Expand All @@ -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 (
Expand All @@ -49,7 +56,8 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
<div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel}
{source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "}
{verdictLabel}
</Badge>
</div>
</FullTooltip>
Expand Down
Loading
Loading