Files
Charon/frontend/src/components/SecurityHeaderProfileForm.tsx

470 lines
17 KiB
TypeScript

import { AlertTriangle, Save, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { CSPBuilder } from './CSPBuilder';
import { PermissionsPolicyBuilder } from './PermissionsPolicyBuilder';
import { SecurityScoreDisplay } from './SecurityScoreDisplay';
import { Alert } from './ui/Alert';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { Input } from './ui/Input';
import { NativeSelect } from './ui/NativeSelect';
import { Switch } from './ui/Switch';
import { Textarea } from './ui/Textarea';
import { useCalculateSecurityScore } from '../hooks/useSecurityHeaders';
import type { SecurityHeaderProfile, CreateProfileRequest } from '../api/securityHeaders';
interface SecurityHeaderProfileFormProps {
initialData?: SecurityHeaderProfile;
onSubmit: (data: CreateProfileRequest) => void;
onCancel: () => void;
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
}
export function SecurityHeaderProfileForm({
initialData,
onSubmit,
onCancel,
onDelete,
isLoading,
isDeleting,
}: SecurityHeaderProfileFormProps) {
const [formData, setFormData] = useState<CreateProfileRequest>({
name: initialData?.name || '',
description: initialData?.description || '',
hsts_enabled: initialData?.hsts_enabled ?? true,
hsts_max_age: initialData?.hsts_max_age || 31536000,
hsts_include_subdomains: initialData?.hsts_include_subdomains ?? true,
hsts_preload: initialData?.hsts_preload ?? false,
csp_enabled: initialData?.csp_enabled ?? false,
csp_directives: initialData?.csp_directives || '',
csp_report_only: initialData?.csp_report_only ?? false,
csp_report_uri: initialData?.csp_report_uri || '',
x_frame_options: initialData?.x_frame_options || 'DENY',
x_content_type_options: initialData?.x_content_type_options ?? true,
referrer_policy: initialData?.referrer_policy || 'strict-origin-when-cross-origin',
permissions_policy: initialData?.permissions_policy || '',
cross_origin_opener_policy: initialData?.cross_origin_opener_policy || 'same-origin',
cross_origin_resource_policy: initialData?.cross_origin_resource_policy || 'same-origin',
cross_origin_embedder_policy: initialData?.cross_origin_embedder_policy || '',
xss_protection: initialData?.xss_protection ?? true,
cache_control_no_store: initialData?.cache_control_no_store ?? false,
});
const [cspValid, setCspValid] = useState(true);
const [, setCspErrors] = useState<string[]>([]);
const calculateScoreMutation = useCalculateSecurityScore();
const { mutate: calculateScore } = calculateScoreMutation;
// Calculate score when form data changes
useEffect(() => {
const timer = setTimeout(() => {
calculateScore(formData);
}, 500);
return () => clearTimeout(timer);
}, [formData, calculateScore]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
return;
}
onSubmit(formData);
};
const updateField = <K extends keyof CreateProfileRequest>(
field: K,
value: CreateProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const isPreset = initialData?.is_preset ?? false;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Profile Name *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="e.g., Production Security Headers"
required
disabled={isPreset}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<Textarea
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Optional description of this security profile..."
rows={2}
disabled={isPreset}
/>
</div>
{isPreset && (
<Alert variant="info">
This is a system preset and cannot be modified. Clone it to create a custom profile.
</Alert>
)}
</Card>
{/* Live Security Score */}
{calculateScoreMutation.data && (
<SecurityScoreDisplay
score={calculateScoreMutation.data.score}
maxScore={calculateScoreMutation.data.max_score}
breakdown={calculateScoreMutation.data.breakdown}
suggestions={calculateScoreMutation.data.suggestions}
size="md"
showDetails={true}
/>
)}
{/* HSTS Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
HTTP Strict Transport Security (HSTS)
</h3>
<Switch
checked={formData.hsts_enabled}
onCheckedChange={(checked) => updateField('hsts_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_enabled && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Age (seconds)
</label>
<Input
type="number"
value={formData.hsts_max_age}
onChange={(e) => updateField('hsts_max_age', parseInt(e.target.value) || 0)}
min={0}
disabled={isPreset}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Recommended: 31536000 (1 year) or 63072000 (2 years)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Include Subdomains
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Apply HSTS to all subdomains
</p>
</div>
<Switch
checked={formData.hsts_include_subdomains}
onCheckedChange={(checked) => updateField('hsts_include_subdomains', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Preload
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Submit to browser preload lists
</p>
</div>
<Switch
checked={formData.hsts_preload}
onCheckedChange={(checked) => updateField('hsts_preload', checked)}
disabled={isPreset}
/>
</div>
{formData.hsts_preload && (
<Alert variant="warning">
<AlertTriangle className="w-4 h-4" />
<div>
<p className="font-semibold">Warning: HSTS Preload is Permanent</p>
<p className="text-sm mt-1">
Once submitted to browser preload lists, removal can take months. Only enable if you're
committed to HTTPS forever.
</p>
</div>
</Alert>
)}
</>
)}
</Card>
{/* CSP Section */}
<Card className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Content Security Policy (CSP)
</h3>
<Switch
checked={formData.csp_enabled}
onCheckedChange={(checked) => updateField('csp_enabled', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_enabled && (
<>
<CSPBuilder
value={formData.csp_directives || ''}
onChange={(value) => updateField('csp_directives', value)}
onValidate={(valid, errors) => {
setCspValid(valid);
setCspErrors(errors);
}}
/>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Report-Only Mode
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Test CSP without blocking content
</p>
</div>
<Switch
checked={formData.csp_report_only}
onCheckedChange={(checked) => updateField('csp_report_only', checked)}
disabled={isPreset}
/>
</div>
{formData.csp_report_only && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Report URI (optional)
</label>
<Input
type="url"
value={formData.csp_report_uri || ''}
onChange={(e) => updateField('csp_report_uri', e.target.value)}
placeholder="https://example.com/csp-report"
disabled={isPreset}
/>
</div>
)}
</>
)}
</Card>
{/* Frame Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Clickjacking Protection</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
X-Frame-Options
</label>
<NativeSelect
value={formData.x_frame_options}
onChange={(e) => updateField('x_frame_options', e.target.value)}
disabled={isPreset}
>
<option value="DENY">DENY (Recommended - no framing allowed)</option>
<option value="SAMEORIGIN">SAMEORIGIN (allow same origin framing)</option>
<option value="">None (allow all framing - not recommended)</option>
</NativeSelect>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-Content-Type-Options: nosniff
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent MIME type sniffing attacks
</p>
</div>
<Switch
checked={formData.x_content_type_options}
onCheckedChange={(checked) => updateField('x_content_type_options', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Privacy Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Privacy Controls</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Referrer-Policy
</label>
<NativeSelect
value={formData.referrer_policy}
onChange={(e) => updateField('referrer_policy', e.target.value)}
disabled={isPreset}
>
<option value="no-referrer">no-referrer (Most Private)</option>
<option value="no-referrer-when-downgrade">no-referrer-when-downgrade</option>
<option value="origin">origin</option>
<option value="origin-when-cross-origin">origin-when-cross-origin</option>
<option value="same-origin">same-origin</option>
<option value="strict-origin">strict-origin</option>
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin (Recommended)</option>
<option value="unsafe-url">unsafe-url (Least Private)</option>
</NativeSelect>
</div>
</Card>
{/* Permissions Policy */}
<PermissionsPolicyBuilder
value={formData.permissions_policy || ''}
onChange={(value) => updateField('permissions_policy', value)}
/>
{/* Cross-Origin Headers */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Cross-Origin Isolation</h3>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Opener-Policy
</label>
<NativeSelect
value={formData.cross_origin_opener_policy}
onChange={(e) => updateField('cross_origin_opener_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="unsafe-none">unsafe-none</option>
<option value="same-origin-allow-popups">same-origin-allow-popups</option>
<option value="same-origin">same-origin (Recommended)</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Resource-Policy
</label>
<NativeSelect
value={formData.cross_origin_resource_policy}
onChange={(e) => updateField('cross_origin_resource_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None</option>
<option value="same-site">same-site</option>
<option value="same-origin">same-origin (Recommended)</option>
<option value="cross-origin">cross-origin</option>
</NativeSelect>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cross-Origin-Embedder-Policy
</label>
<NativeSelect
value={formData.cross_origin_embedder_policy}
onChange={(e) => updateField('cross_origin_embedder_policy', e.target.value)}
disabled={isPreset}
>
<option value="">None (Default)</option>
<option value="require-corp">require-corp (Strict)</option>
</NativeSelect>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Only enable if you need SharedArrayBuffer or high-resolution timers
</p>
</div>
</Card>
{/* Additional Options */}
<Card className="p-4 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Additional Options</h3>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
X-XSS-Protection
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Legacy XSS protection header
</p>
</div>
<Switch
checked={formData.xss_protection}
onCheckedChange={(checked) => updateField('xss_protection', checked)}
disabled={isPreset}
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cache-Control: no-store
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Prevent caching of sensitive content
</p>
</div>
<Switch
checked={formData.cache_control_no_store}
onCheckedChange={(checked) => updateField('cache_control_no_store', checked)}
disabled={isPreset}
/>
</div>
</Card>
{/* Form Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{onDelete && !isPreset && (
<Button
type="button"
variant="danger"
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete Profile'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || isPreset || (!cspValid && formData.csp_enabled)}
>
<Save className="w-4 h-4 mr-2" />
{isLoading ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
</form>
);
}