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:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user