chore: clean git cache
This commit is contained in:
332
frontend/src/components/CSPBuilder.tsx
Normal file
332
frontend/src/components/CSPBuilder.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, X, AlertCircle, Check, Code } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Input } from './ui/Input';
|
||||
import { NativeSelect } from './ui/NativeSelect';
|
||||
import { Card } from './ui/Card';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { Alert } from './ui/Alert';
|
||||
import type { CSPDirective } from '../api/securityHeaders';
|
||||
|
||||
interface CSPBuilderProps {
|
||||
value: string; // JSON string of CSPDirective[]
|
||||
onChange: (value: string) => void;
|
||||
onValidate?: (valid: boolean, errors: string[]) => void;
|
||||
}
|
||||
|
||||
const CSP_DIRECTIVES = [
|
||||
'default-src',
|
||||
'script-src',
|
||||
'style-src',
|
||||
'img-src',
|
||||
'font-src',
|
||||
'connect-src',
|
||||
'frame-src',
|
||||
'object-src',
|
||||
'media-src',
|
||||
'worker-src',
|
||||
'form-action',
|
||||
'base-uri',
|
||||
'frame-ancestors',
|
||||
'manifest-src',
|
||||
'prefetch-src',
|
||||
];
|
||||
|
||||
const CSP_VALUES = [
|
||||
"'self'",
|
||||
"'none'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
'data:',
|
||||
'https:',
|
||||
'http:',
|
||||
'blob:',
|
||||
'filesystem:',
|
||||
"'strict-dynamic'",
|
||||
"'report-sample'",
|
||||
"'unsafe-hashes'",
|
||||
];
|
||||
|
||||
const CSP_PRESETS: Record<string, CSPDirective[]> = {
|
||||
'Strict Default': [
|
||||
{ directive: 'default-src', values: ["'self'"] },
|
||||
{ directive: 'script-src', values: ["'self'"] },
|
||||
{ directive: 'style-src', values: ["'self'"] },
|
||||
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
|
||||
{ directive: 'font-src', values: ["'self'", 'data:'] },
|
||||
{ directive: 'connect-src', values: ["'self'"] },
|
||||
{ directive: 'frame-src', values: ["'none'"] },
|
||||
{ directive: 'object-src', values: ["'none'"] },
|
||||
],
|
||||
'Allow Inline Styles': [
|
||||
{ directive: 'default-src', values: ["'self'"] },
|
||||
{ directive: 'script-src', values: ["'self'"] },
|
||||
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
|
||||
{ directive: 'img-src', values: ["'self'", 'data:', 'https:'] },
|
||||
{ directive: 'font-src', values: ["'self'", 'data:'] },
|
||||
],
|
||||
'Development Mode': [
|
||||
{ directive: 'default-src', values: ["'self'"] },
|
||||
{ directive: 'script-src', values: ["'self'", "'unsafe-inline'", "'unsafe-eval'"] },
|
||||
{ directive: 'style-src', values: ["'self'", "'unsafe-inline'"] },
|
||||
{ directive: 'img-src', values: ["'self'", 'data:', 'https:', 'http:'] },
|
||||
],
|
||||
};
|
||||
|
||||
export function CSPBuilder({ value, onChange, onValidate }: CSPBuilderProps) {
|
||||
const [directives, setDirectives] = useState<CSPDirective[]>([]);
|
||||
const [newDirective, setNewDirective] = useState('default-src');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// Parse initial value
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (value) {
|
||||
const parsed = JSON.parse(value) as CSPDirective[];
|
||||
setDirectives(parsed);
|
||||
} else {
|
||||
setDirectives([]);
|
||||
}
|
||||
} catch {
|
||||
setDirectives([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Generate CSP string preview
|
||||
const generateCSPString = (dirs: CSPDirective[]): string => {
|
||||
return dirs
|
||||
.map((dir) => `${dir.directive} ${dir.values.join(' ')}`)
|
||||
.join('; ');
|
||||
};
|
||||
|
||||
const cspString = generateCSPString(directives);
|
||||
|
||||
// Update parent component
|
||||
const updateDirectives = (newDirectives: CSPDirective[]) => {
|
||||
setDirectives(newDirectives);
|
||||
onChange(JSON.stringify(newDirectives));
|
||||
validateCSP(newDirectives);
|
||||
};
|
||||
|
||||
const validateCSP = (dirs: CSPDirective[]) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for duplicate directives
|
||||
const directiveNames = dirs.map((d) => d.directive);
|
||||
const duplicates = directiveNames.filter((name, index) => directiveNames.indexOf(name) !== index);
|
||||
if (duplicates.length > 0) {
|
||||
errors.push(`Duplicate directives found: ${duplicates.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check for dangerous combinations
|
||||
const hasUnsafeInline = dirs.some((d) =>
|
||||
d.values.some((v) => v === "'unsafe-inline'" || v === "'unsafe-eval'")
|
||||
);
|
||||
if (hasUnsafeInline) {
|
||||
errors.push('Using unsafe-inline or unsafe-eval weakens CSP protection');
|
||||
}
|
||||
|
||||
// Check if default-src is set
|
||||
const hasDefaultSrc = dirs.some((d) => d.directive === 'default-src');
|
||||
if (!hasDefaultSrc && dirs.length > 0) {
|
||||
errors.push('Consider setting default-src as a fallback for all directives');
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
onValidate?.(errors.length === 0, errors);
|
||||
};
|
||||
|
||||
const handleAddDirective = () => {
|
||||
if (!newValue.trim()) return;
|
||||
|
||||
const existingIndex = directives.findIndex((d) => d.directive === newDirective);
|
||||
|
||||
let updated: CSPDirective[];
|
||||
if (existingIndex >= 0) {
|
||||
// Add to existing directive
|
||||
const existing = directives[existingIndex];
|
||||
if (!existing.values.includes(newValue.trim())) {
|
||||
const updatedDirective = {
|
||||
...existing,
|
||||
values: [...existing.values, newValue.trim()],
|
||||
};
|
||||
updated = [
|
||||
...directives.slice(0, existingIndex),
|
||||
updatedDirective,
|
||||
...directives.slice(existingIndex + 1),
|
||||
];
|
||||
} else {
|
||||
return; // Value already exists
|
||||
}
|
||||
} else {
|
||||
// Create new directive
|
||||
updated = [...directives, { directive: newDirective, values: [newValue.trim()] }];
|
||||
}
|
||||
|
||||
updateDirectives(updated);
|
||||
setNewValue('');
|
||||
};
|
||||
|
||||
const handleRemoveDirective = (directive: string) => {
|
||||
updateDirectives(directives.filter((d) => d.directive !== directive));
|
||||
};
|
||||
|
||||
const handleRemoveValue = (directive: string, value: string) => {
|
||||
updateDirectives(
|
||||
directives.map((d) =>
|
||||
d.directive === directive
|
||||
? { ...d, values: d.values.filter((v) => v !== value) }
|
||||
: d
|
||||
).filter((d) => d.values.length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApplyPreset = (presetName: string) => {
|
||||
const preset = CSP_PRESETS[presetName];
|
||||
if (preset) {
|
||||
updateDirectives(preset);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 Builder</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
{showPreview ? 'Hide' : 'Show'} Preview
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preset Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 self-center">Quick Presets:</span>
|
||||
{Object.keys(CSP_PRESETS).map((presetName) => (
|
||||
<Button
|
||||
key={presetName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyPreset(presetName)}
|
||||
>
|
||||
{presetName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Directive Form */}
|
||||
<div className="flex gap-2">
|
||||
<NativeSelect
|
||||
value={newDirective}
|
||||
onChange={(e) => setNewDirective(e.target.value)}
|
||||
className="w-48"
|
||||
>
|
||||
{CSP_DIRECTIVES.map((dir) => (
|
||||
<option key={dir} value={dir}>
|
||||
{dir}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddDirective()}
|
||||
placeholder="Enter value or select from suggestions..."
|
||||
list="csp-values"
|
||||
/>
|
||||
<datalist id="csp-values">
|
||||
{CSP_VALUES.map((val) => (
|
||||
<option key={val} value={val} />
|
||||
))}
|
||||
</datalist>
|
||||
<Button onClick={handleAddDirective} disabled={!newValue.trim()}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Directives */}
|
||||
<div className="space-y-2">
|
||||
{directives.length === 0 ? (
|
||||
<Alert variant="info">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>No CSP directives configured. Add directives above to build your policy.</span>
|
||||
</Alert>
|
||||
) : (
|
||||
directives.map((dir) => (
|
||||
<div key={dir.directive} className="flex items-start gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{dir.directive}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDirective(dir.directive)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dir.values.map((val) => (
|
||||
<Badge
|
||||
key={val}
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
onClick={() => handleRemoveValue(dir.directive, val)}
|
||||
>
|
||||
<span className="font-mono text-xs">{val}</span>
|
||||
<X className="w-3 h-3" />
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<div>
|
||||
<p className="font-semibold mb-1">CSP Validation Warnings:</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{validationErrors.length === 0 && directives.length > 0 && (
|
||||
<Alert variant="success">
|
||||
<Check className="w-4 h-4" />
|
||||
<span>CSP configuration looks good!</span>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* CSP String Preview */}
|
||||
{showPreview && cspString && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated CSP Header:</label>
|
||||
<pre className="p-3 bg-gray-900 dark:bg-gray-950 text-green-400 rounded-lg overflow-x-auto text-xs font-mono">
|
||||
{cspString || '(empty)'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user