feat: add security header profile assignment to proxy hosts
Implement complete workflow for assigning security header profiles to proxy hosts via dropdown selector in ProxyHostForm. Backend Changes: - Add security_header_profile_id handling to proxy host update endpoint - Add SecurityHeaderProfile preloading in service layer - Add 5 comprehensive tests for profile CRUD operations Frontend Changes: - Add Security Headers section to ProxyHostForm with dropdown - Group profiles: System Profiles (presets) vs Custom Profiles - Remove confusing "Apply" button from SecurityHeaders page - Rename section to "System Profiles (Read-Only)" for clarity - Show security score inline when profile selected UX Improvements: - Clear workflow: Select profile → Assign to host → Caddy applies - No more confusion about what "Apply" does - Discoverable security header assignment - Visual distinction between presets and custom profiles Tests: Backend 85.6%, Frontend 87.21% coverage Docs: Updated workflows in docs/features.md
This commit is contained in:
@@ -40,6 +40,15 @@ export interface ProxyHost {
|
||||
certificate_id?: number | null;
|
||||
certificate?: Certificate | null;
|
||||
access_list_id?: number | null;
|
||||
security_header_profile_id?: number | null;
|
||||
security_header_profile?: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
security_score: number;
|
||||
is_preset: boolean;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useDomains } from '../hooks/useDomains'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useDocker } from '../hooks/useDocker'
|
||||
import AccessListSelector from './AccessListSelector'
|
||||
import { useSecurityHeaderProfiles } from '../hooks/useSecurityHeaders'
|
||||
import { SecurityScoreDisplay } from './SecurityScoreDisplay'
|
||||
import { parse } from 'tldts'
|
||||
|
||||
// Application preset configurations
|
||||
@@ -105,6 +107,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
enabled: host?.enabled ?? true,
|
||||
certificate_id: host?.certificate_id,
|
||||
access_list_id: host?.access_list_id,
|
||||
security_header_profile_id: host?.security_header_profile_id,
|
||||
})
|
||||
|
||||
// Charon internal IP for config helpers (previously CPMP internal IP)
|
||||
@@ -141,7 +144,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
} catch {
|
||||
console.error('Failed to copy to clipboard')
|
||||
// Silently fail if clipboard access is denied
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +158,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
const { servers: remoteServers } = useRemoteServers()
|
||||
const { domains, createDomain } = useDomains()
|
||||
const { certificates } = useCertificates()
|
||||
const { data: securityProfiles } = useSecurityHeaderProfiles()
|
||||
|
||||
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
|
||||
|
||||
@@ -239,9 +243,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
try {
|
||||
await createDomain(pendingDomain)
|
||||
setShowDomainPrompt(false)
|
||||
} catch (err) {
|
||||
console.error("Failed to save domain", err)
|
||||
// Optionally show error
|
||||
} catch {
|
||||
// Failed to save domain - user can retry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +264,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
setTestStatus('success')
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
} catch (err) {
|
||||
console.error("Test connection failed", err)
|
||||
} catch {
|
||||
setTestStatus('error')
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
@@ -348,7 +350,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
} else {
|
||||
// If no public port is mapped, we can't reach it from outside
|
||||
// But we'll leave the internal port as a fallback, though it likely won't work
|
||||
console.warn('No public port mapped for container on remote server')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,6 +607,75 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
onChange={id => setFormData({ ...formData, access_list_id: id })}
|
||||
/>
|
||||
|
||||
{/* Security Headers Profile */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Security Headers
|
||||
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={formData.security_header_profile_id || 0}
|
||||
onChange={e => {
|
||||
const value = parseInt(e.target.value) || null
|
||||
setFormData({ ...formData, security_header_profile_id: value })
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={0}>None (No Security Headers)</option>
|
||||
<optgroup label="Quick Presets">
|
||||
{securityProfiles
|
||||
?.filter(p => p.is_preset)
|
||||
.sort((a, b) => a.security_score - b.security_score)
|
||||
.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} (Score: {profile.security_score}/100)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(securityProfiles?.filter(p => !p.is_preset) || []).length > 0 && (
|
||||
<optgroup label="Custom Profiles">
|
||||
{(securityProfiles || [])
|
||||
.filter(p => !p.is_preset)
|
||||
.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} (Score: {profile.security_score}/100)
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{formData.security_header_profile_id && (() => {
|
||||
const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id)
|
||||
if (!selected) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<SecurityScoreDisplay
|
||||
score={selected.security_score}
|
||||
size="sm"
|
||||
showDetails={false}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{selected.description}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Apply HTTP security headers to protect against common web vulnerabilities.{' '}
|
||||
<a
|
||||
href="/security-headers"
|
||||
target="_blank"
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Manage Profiles →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Application Preset */}
|
||||
<div>
|
||||
<label htmlFor="application-preset" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Shield, Copy, Eye, Play } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Shield, Copy, Eye } from 'lucide-react';
|
||||
import {
|
||||
useSecurityHeaderProfiles,
|
||||
useCreateSecurityHeaderProfile,
|
||||
useUpdateSecurityHeaderProfile,
|
||||
useDeleteSecurityHeaderProfile,
|
||||
useApplySecurityHeaderPreset,
|
||||
} from '../hooks/useSecurityHeaders';
|
||||
import { SecurityHeaderProfileForm } from '../components/SecurityHeaderProfileForm';
|
||||
import { SecurityScoreDisplay } from '../components/SecurityScoreDisplay';
|
||||
@@ -31,7 +30,6 @@ export default function SecurityHeaders() {
|
||||
const createMutation = useCreateSecurityHeaderProfile();
|
||||
const updateMutation = useUpdateSecurityHeaderProfile();
|
||||
const deleteMutation = useDeleteSecurityHeaderProfile();
|
||||
const applyPresetMutation = useApplySecurityHeaderPreset();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<SecurityHeaderProfile | null>(null);
|
||||
@@ -80,11 +78,6 @@ export default function SecurityHeaders() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = (presetType: string) => {
|
||||
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
|
||||
applyPresetMutation.mutate({ preset_type: presetType, name });
|
||||
};
|
||||
|
||||
const handleCloneProfile = (profile: SecurityHeaderProfile) => {
|
||||
const clonedData: CreateProfileRequest = {
|
||||
name: `${profile.name} (Copy)`,
|
||||
@@ -141,7 +134,12 @@ export default function SecurityHeaders() {
|
||||
{/* Quick Presets (Read-Only) */}
|
||||
{presetProfiles.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Quick Presets</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
System Profiles (Read-Only)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Pre-configured security profiles you can assign to proxy hosts. Clone to customize.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{presetProfiles.map((profile: SecurityHeaderProfile) => (
|
||||
<Card key={profile.id} className="p-4">
|
||||
@@ -166,14 +164,6 @@ export default function SecurityHeaders() {
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" /> View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyPreset(profile.preset_type)}
|
||||
disabled={applyPresetMutation.isPending}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" /> Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -164,44 +164,6 @@ describe('SecurityHeaders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply preset', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Basic Security',
|
||||
description: 'Essential headers',
|
||||
is_preset: true,
|
||||
preset_type: 'basic',
|
||||
security_score: 65,
|
||||
updated_at: '2025-12-18T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(securityHeadersApi.listProfiles).mockResolvedValue(mockProfiles as any);
|
||||
vi.mocked(securityHeadersApi.getPresets).mockResolvedValue([]);
|
||||
vi.mocked(securityHeadersApi.applyPreset).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'Basic Security Profile',
|
||||
security_score: 65,
|
||||
} as any);
|
||||
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Basic Security')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: /Apply/ });
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(securityHeadersApi.applyPreset).toHaveBeenCalledWith({
|
||||
preset_type: 'basic',
|
||||
name: 'Basic Security Profile',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone profile', async () => {
|
||||
const mockProfiles = [
|
||||
{
|
||||
@@ -312,15 +274,15 @@ describe('SecurityHeaders', () => {
|
||||
render(<SecurityHeaders />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quick Presets')).toBeInTheDocument();
|
||||
expect(screen.getByText('System Profiles (Read-Only)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Profiles')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Quick preset should have preset_type badge and View/Apply/Clone buttons
|
||||
// System profiles should have View and Clone buttons
|
||||
const presetCard = screen.getByText('Basic Security').closest('div');
|
||||
expect(presetCard?.textContent).toContain('basic');
|
||||
expect(presetCard).toBeInTheDocument();
|
||||
|
||||
// Custom profile should have "Edit" and delete buttons
|
||||
// Custom profile should have Edit button
|
||||
const customCard = screen.getByText('Custom Profile').closest('div');
|
||||
expect(customCard?.textContent).toContain('Custom Profile');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user