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

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">