feat(waf): benutzerdefinierte App-Profile + Fix: CRS-Plugins wurden im Agent nie geladen — v1.3.17

Neu: eigene WAF-App-Profile (benannte, wiederverwendbare Rule-ID-Ausnahme-
Bündel) — zentrale Bibliothek im UI (eigener Tab), pro Domain zuweisbar,
Built-in-OWASP-Plugins bleiben read-only + als Vorlage klonbar. Nur reine
Rule-IDs/Ranges (keine SecLang-Ausführung, injektionssicher).
- Migration 0047: Tabelle waf_app_profiles (repliziert via reconcile) +
  waf_configs.app_profiles.
- Service/Handler: CRUD (/waf/profiles), Built-ins geschützt (builtin=false-Gate).
- Agent-Loader: app_profiles → in effektive rule_exclusions gemerged; ihr
  updated_at hebt das effektive updated_at der Domain → Engine-Rebuild bei
  Profil-Edit.
- UI: Profile-Tab (Liste/Editor mit durchsuchbaren Rule-IDs) + Multi-Select im
  Domain-Drawer.

FIX (wichtig): ListAllWithDomain — der EINZIGE Loader des laufenden WAF-Agents —
selektierte crs_plugins nie. Dadurch war cfg.CRSPlugins im Agent immer leer und
KEIN Built-in-CRS-Plugin (Nextcloud/WordPress/Drupal) wurde je in die Engine
inkludiert. Jetzt geladen (+ app_profiles). Die per-Domain-Plugin-Wahl wirkt
damit erstmals tatsächlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-08-03 15:51:39 +02:00
parent 0846eaa05b
commit 5c268425c1
10 changed files with 776 additions and 20 deletions

View File

@@ -1880,7 +1880,11 @@
"deleteFailed": "Löschen fehlgeschlagen",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch nichts gespeichert.",
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
"tabs": {
"settings": "Einstellungen",
"clients": "Clients (NAS)",
"users": "Benutzer"
},
"settings": {
"enabled": "RADIUS auf dieser Node aktiv",
"listen": "Listen-Adressen"
@@ -1951,11 +1955,15 @@
"exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…",
"exclusionAddNote": "Notiz (optional)",
"exclusionAddNotFound": "Keine Regel gefunden",
"exclusionAddBtn": "Hinzufügen"
"exclusionAddBtn": "Hinzufügen",
"appProfiles": "Eigene App-Profile",
"appProfilesHint": "Wiederverwendbare, selbst gepflegte Profile (Regel-ID-Bündel) — zentral unter „App-Profile“ anlegen und hier pro Domain zuweisen. Ergänzt die OWASP-Plugins oben.",
"appProfilesPlaceholder": "Eigene Profile wählen (optional)"
},
"tabs": {
"domains": "Domains",
"alerts": "Alarme"
"alerts": "Alarme",
"profiles": "App-Profile"
},
"alerts": {
"total": "Einträge",
@@ -1984,6 +1992,39 @@
"exceptionModalHint": "Optional: Begründung warum diese Regel ein False Positive für diese Domain ist.",
"exceptionNotePlaceholder": "z.B. Unsere API verwendet nicht-standardisierte Header die diese Regel auslösen.",
"alreadyExcluded": "Bereits Ausnahme"
},
"profiles": {
"intro": "Wiederverwendbare Ausnahme-Profile (Bündel von CRS-Regel-IDs). Einmal anlegen, pro Domain zuweisen. Die eingebauten OWASP-Plugins sind read-only und werden pro Domain gewählt.",
"new": "Neues Profil",
"empty": "Noch keine eigenen Profile. Lege eins an, um Regel-Ausnahmen wiederzuverwenden.",
"builtinInfo": "Eingebaute OWASP-CRS-Plugins (dateibasiert, gepflegt). Read-only — pro Domain im WAF-Drawer wählbar. „Als Vorlage“ erstellt daraus ein leeres eigenes Profil zum Befüllen.",
"typeBuiltin": "OWASP",
"typeCustom": "Eigen",
"col": {
"name": "Name",
"type": "Typ",
"description": "Beschreibung",
"rules": "Ausnahmen"
},
"edit": "Bearbeiten",
"delete": "Löschen",
"clone": "Klonen",
"asTemplate": "Als Vorlage",
"deleteConfirm": "Profil wirklich löschen? Zuweisungen an Domains verlieren dann diese Ausnahmen.",
"createTitle": "Neues App-Profil",
"editTitle": "App-Profil bearbeiten",
"name": "Name",
"namePlaceholder": "z.B. Meine WebApp",
"description": "Beschreibung",
"descriptionPlaceholder": "Wofür ist dieses Profil? (optional)",
"rules": "Regel-Ausnahmen",
"rulesHint": "CRS-Regel-IDs die für zugewiesene Domains deaktiviert werden. Durchsuchbar nach ID oder Beschreibung.",
"rulesPlaceholder": "Regel-IDs suchen und hinzufügen…",
"saved": "Profil gespeichert.",
"saveFailed": "Profil konnte nicht gespeichert werden.",
"deleted": "Profil gelöscht.",
"deleteFailed": "Profil konnte nicht gelöscht werden.",
"cloneSuffix": "Kopie"
}
}
}
}

View File

@@ -1880,7 +1880,11 @@
"deleteFailed": "Delete failed",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "Nothing stored yet.",
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
"tabs": {
"settings": "Settings",
"clients": "Clients (NAS)",
"users": "Users"
},
"settings": {
"enabled": "RADIUS active on this node",
"listen": "Listen addresses"
@@ -1951,11 +1955,15 @@
"exclusionAddPlaceholder": "Search rule (ID or description)…",
"exclusionAddNote": "Note (optional)",
"exclusionAddNotFound": "No rule found",
"exclusionAddBtn": "Add"
"exclusionAddBtn": "Add",
"appProfiles": "Custom App Profiles",
"appProfilesHint": "Reusable, self-maintained profiles (bundles of rule IDs) — create them centrally under “App Profiles” and assign them here per domain. Complements the OWASP plugins above.",
"appProfilesPlaceholder": "Select custom profiles (optional)"
},
"tabs": {
"domains": "Domains",
"alerts": "Alerts"
"alerts": "Alerts",
"profiles": "App Profiles"
},
"alerts": {
"total": "entries",
@@ -1984,6 +1992,39 @@
"exceptionModalHint": "Optional: describe why this rule is a false positive for this domain.",
"exceptionNotePlaceholder": "e.g. Our custom API uses non-standard headers that trigger this rule.",
"alreadyExcluded": "Already excluded"
},
"profiles": {
"intro": "Reusable exclusion profiles (bundles of CRS rule IDs). Create once, assign per domain. The built-in OWASP plugins are read-only and selected per domain.",
"new": "New Profile",
"empty": "No custom profiles yet. Create one to reuse rule exclusions.",
"builtinInfo": "Built-in OWASP CRS plugins (file-based, maintained). Read-only — selectable per domain in the WAF drawer. “Use as template” creates an empty custom profile from it to fill in.",
"typeBuiltin": "OWASP",
"typeCustom": "Custom",
"col": {
"name": "Name",
"type": "Type",
"description": "Description",
"rules": "Exclusions"
},
"edit": "Edit",
"delete": "Delete",
"clone": "Clone",
"asTemplate": "Use as template",
"deleteConfirm": "Really delete this profile? Domains using it will lose these exclusions.",
"createTitle": "New App Profile",
"editTitle": "Edit App Profile",
"name": "Name",
"namePlaceholder": "e.g. My WebApp",
"description": "Description",
"descriptionPlaceholder": "What is this profile for? (optional)",
"rules": "Rule Exclusions",
"rulesHint": "CRS rule IDs disabled for assigned domains. Searchable by ID or description.",
"rulesPlaceholder": "Search and add rule IDs…",
"saved": "Profile saved.",
"saveFailed": "Could not save profile.",
"deleted": "Profile deleted.",
"deleteFailed": "Could not delete profile.",
"cloneSuffix": "Copy"
}
}
}
}

View File

@@ -1,11 +1,12 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import {
CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined, PlusOutlined,
SafetyCertificateOutlined, SettingOutlined, WarningOutlined,
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -34,11 +35,22 @@ interface WafConfig {
paranoia_level: number
rule_exclusions: string[]
crs_plugins: string[]
app_profiles: string[]
exclusion_notes: Record<string, string>
trusted_proxies: string[]
custom_rules: string
}
interface WafProfile {
id: number
name: string
description: string
rule_exclusions: string[]
builtin: boolean
created_at?: string
updated_at?: string
}
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
const CRS_PLUGIN_OPTIONS = [
{ value: 'nextcloud', label: 'Nextcloud' },
@@ -74,17 +86,25 @@ function defaultConfig(domainId: number): WafConfig {
paranoia_level: 1,
rule_exclusions: [],
crs_plugins: [],
app_profiles: [],
exclusion_notes: {},
trusted_proxies: [],
custom_rules: '',
}
}
async function fetchProfiles(): Promise<WafProfile[]> {
const r = await apiClient.get('/waf/profiles')
if (!isEnvelope(r.data)) return []
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
}
interface WafFormValues {
enabled: boolean
mode: 'detection' | 'blocking'
paranoia_level: number
crs_plugins: string[]
app_profiles: string[]
trusted_proxies_str: string
custom_rules: string
}
@@ -117,6 +137,13 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
enabled: domainId !== null,
})
// Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen.
const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const profileOptions = useMemo(
() => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })),
[profiles],
)
const save = useMutation({
mutationFn: (values: WafConfig) =>
apiClient.put(`/waf/configs/${domainId}`, values),
@@ -154,6 +181,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
layout="vertical"
initialValues={{
...cfg,
app_profiles: cfg.app_profiles ?? [],
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
}}
onFinish={(vals) => {
@@ -166,6 +194,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
paranoia_level: vals.paranoia_level,
rule_exclusions: cfg?.rule_exclusions ?? [],
crs_plugins: vals.crs_plugins ?? [],
app_profiles: vals.app_profiles ?? [],
exclusion_notes: cfg?.exclusion_notes ?? {},
trusted_proxies: proxies,
custom_rules: vals.custom_rules ?? '',
@@ -223,6 +252,21 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
/>
</Form.Item>
<Form.Item
label={t('waf.config.appProfiles')}
name="app_profiles"
help={t('waf.config.appProfilesHint')}
>
<Select
mode="multiple"
allowClear
disabled={isViewer}
placeholder={t('waf.config.appProfilesPlaceholder')}
options={profileOptions}
notFoundContent={t('waf.profiles.empty')}
/>
</Form.Item>
{/* Exclusions list — shows existing exclusions with notes + remove button */}
<Form.Item label={t('waf.config.exclusions')}>
{(cfg?.rule_exclusions ?? []).length === 0 ? (
@@ -258,6 +302,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: newExclusions,
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
@@ -306,6 +351,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
paranoia_level: cfg?.paranoia_level ?? 1,
rule_exclusions: [...existing, addRuleId],
crs_plugins: cfg?.crs_plugins ?? [],
app_profiles: cfg?.app_profiles ?? [],
exclusion_notes: newNotes,
trusted_proxies: cfg?.trusted_proxies ?? [],
custom_rules: cfg?.custom_rules ?? '',
@@ -562,6 +608,220 @@ function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map<
)
}
// ---------- Profiles tab ----------------------------------------------------
interface ProfileFormValues {
name: string
description: string
rule_exclusions: string[]
}
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [form] = Form.useForm<ProfileFormValues>()
const isCreate = profile !== null && profile.id === 0
const ruleOptions = useMemo(
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id}${desc}` })),
[],
)
// Formular bei jedem Öffnen/Wechsel neu befüllen.
useEffect(() => {
if (profile) {
form.setFieldsValue({
name: profile.name,
description: profile.description,
rule_exclusions: profile.rule_exclusions ?? [],
})
}
}, [profile, form])
const save = useMutation({
mutationFn: (vals: ProfileFormValues) =>
isCreate
? apiClient.post('/waf/profiles', vals)
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
onSuccess: () => {
message.success(t('waf.profiles.saved'))
void qc.invalidateQueries({ queryKey: ['waf'] })
onClose()
},
onError: () => message.error(t('waf.profiles.saveFailed')),
})
return (
<Drawer
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
open={profile !== null}
onClose={onClose}
width={520}
footer={
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
{t('common.save')}
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={(vals) => save.mutate({
name: vals.name,
description: vals.description ?? '',
rule_exclusions: vals.rule_exclusions ?? [],
})}
>
<Form.Item
label={t('waf.profiles.name')}
name="name"
rules={[{ required: true, max: 60 }]}
>
<Input placeholder={t('waf.profiles.namePlaceholder')} />
</Form.Item>
<Form.Item label={t('waf.profiles.description')} name="description">
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
</Form.Item>
<Form.Item
label={t('waf.profiles.rules')}
name="rule_exclusions"
help={t('waf.profiles.rulesHint')}
>
<Select
mode="multiple"
showSearch
allowClear
placeholder={t('waf.profiles.rulesPlaceholder')}
options={ruleOptions}
optionFilterProp="label"
/>
</Form.Item>
</Form>
</Drawer>
)
}
function ProfilesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [editing, setEditing] = useState<WafProfile | null>(null)
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
const custom = (profiles ?? []).filter(p => !p.builtin)
const del = useMutation({
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
onSuccess: () => {
message.success(t('waf.profiles.deleted'))
void qc.invalidateQueries({ queryKey: ['waf'] })
},
onError: () => message.error(t('waf.profiles.deleteFailed')),
})
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
id: 0,
name: seed?.name ?? '',
description: seed?.description ?? '',
rule_exclusions: seed?.rule_exclusions ?? [],
builtin: false,
})
const columns = [
{
title: t('waf.profiles.col.name'),
dataIndex: 'name',
key: 'name',
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
},
{
title: t('waf.profiles.col.description'),
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
},
{
title: t('waf.profiles.col.rules'),
key: 'rules',
width: 90,
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
},
{
title: '',
key: 'actions',
width: 210,
render: (_: unknown, row: WafProfile) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
{t('waf.profiles.edit')}
</Button>
<Tooltip title={t('waf.profiles.clone')}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={isViewer}
onClick={() => openCreate({
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
description: row.description,
rule_exclusions: row.rule_exclusions,
})}
/>
</Tooltip>
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
</Popconfirm>
</Space>
),
},
]
return (
<div className="mt-2">
<Alert
type="info"
showIcon
className="mb-16"
message={t('waf.profiles.intro')}
description={
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{CRS_PLUGIN_OPTIONS.map(p => (
<Space key={p.value} size={2}>
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
{!isViewer && (
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
{t('waf.profiles.asTemplate')}
</Button>
)}
</Space>
))}
</div>
</div>
}
/>
<div className="flex-between mb-12">
<Text type="secondary" style={{ fontSize: 12 }}>
{custom.length} {t('waf.profiles.typeCustom')}
</Text>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
{t('waf.profiles.new')}
</Button>
</div>
{custom.length === 0 && !isLoading ? (
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
) : (
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
)}
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
</div>
)
}
// ---------- Page ------------------------------------------------------------
export default function WAFPage() {
@@ -735,6 +995,11 @@ export default function WAFPage() {
</>
),
},
{
key: 'profiles',
label: t('waf.tabs.profiles'),
children: <ProfilesTab />,
},
{
key: 'alerts',
label: t('waf.tabs.alerts'),