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:
GitHub Actions
2025-12-18 15:56:47 +00:00
parent c039ef10cf
commit 555ab5e669
11 changed files with 878 additions and 97 deletions
+9
View File
@@ -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;
}
+77 -7
View File
@@ -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">
+7 -17
View File
@@ -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');
});