feat: implement HTTP Security Headers management (Issue #20)

Add comprehensive security header management system with reusable
profiles, interactive builders, and security scoring.

Features:
- SecurityHeaderProfile model with 11+ header types
- CRUD API with 10 endpoints (/api/v1/security/headers/*)
- Caddy integration for automatic header injection
- 3 built-in presets (Basic, Strict, Paranoid)
- Security score calculator (0-100) with suggestions
- Interactive CSP builder with validation
- Permissions-Policy builder
- Real-time security score preview
- Per-host profile assignment

Headers Supported:
- HSTS with preload support
- Content-Security-Policy with report-only mode
- X-Frame-Options, X-Content-Type-Options
- Referrer-Policy, Permissions-Policy
- Cross-Origin-Opener/Resource/Embedder-Policy
- X-XSS-Protection, Cache-Control security

Implementation:
- Backend: models, handlers, services (85% coverage)
- Frontend: React components, hooks (87.46% coverage)
- Tests: 1,163 total tests passing
- Docs: Comprehensive feature documentation

Closes #20
This commit is contained in:
GitHub Actions
2025-12-18 02:58:26 +00:00
parent 01ec910d58
commit 8cf762164f
33 changed files with 7978 additions and 69 deletions

View File

@@ -0,0 +1,235 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CSPBuilder } from '../CSPBuilder';
describe('CSPBuilder', () => {
const mockOnChange = vi.fn();
const mockOnValidate = vi.fn();
const defaultProps = {
value: '',
onChange: mockOnChange,
onValidate: mockOnValidate,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render with empty directives', () => {
render(<CSPBuilder {...defaultProps} />);
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
expect(screen.getByText('No CSP directives configured. Add directives above to build your policy.')).toBeInTheDocument();
});
it('should add a directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getByRole('button', { name: '' }); // Plus button
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.click(addButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed).toEqual([
{ directive: 'default-src', values: ["'self'"] },
]);
});
it('should remove a directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
await waitFor(() => {
const directiveElements = screen.getAllByText('default-src');
expect(directiveElements.length).toBeGreaterThan(0);
});
// Find the X button in the directive row (not in the select)
const allButtons = screen.getAllByRole('button');
const removeButton = allButtons.find(btn => {
const svg = btn.querySelector('svg');
return svg && btn.closest('.bg-gray-50, .dark\\:bg-gray-800');
});
if (removeButton) {
fireEvent.click(removeButton);
}
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should apply preset', async () => {
render(<CSPBuilder {...defaultProps} />);
const presetButton = screen.getByRole('button', { name: 'Strict Default' });
fireEvent.click(presetButton);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[0][0];
const parsed = JSON.parse(callArg);
expect(parsed.length).toBeGreaterThan(0);
expect(parsed[0].directive).toBe('default-src');
});
it('should toggle preview display', () => {
render(<CSPBuilder {...defaultProps} />);
const previewButton = screen.getByRole('button', { name: /Show Preview/ });
expect(screen.queryByText('Generated CSP Header:')).not.toBeInTheDocument();
fireEvent.click(previewButton);
expect(screen.getByRole('button', { name: /Hide Preview/ })).toBeInTheDocument();
});
it('should validate CSP and show warnings', async () => {
render(<CSPBuilder {...defaultProps} />);
// Add an unsafe directive to trigger validation
const directiveSelect = screen.getAllByRole('combobox')[0];
const valueInput = screen.getByPlaceholderText(/Enter value/);
const addButton = screen.getAllByRole('button').find(btn => btn.querySelector('.lucide-plus'));
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
fireEvent.change(valueInput, { target: { value: "'unsafe-inline'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
expect(mockOnValidate).toHaveBeenCalled();
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall).toBeDefined();
});
const validateCall = mockOnValidate.mock.calls.find(call => call[1]?.length > 0);
expect(validateCall?.[0]).toBe(false);
expect(validateCall?.[1]).toContain('Using unsafe-inline or unsafe-eval weakens CSP protection');
});
it('should not add duplicate values to same directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
const allButtons = screen.getAllByRole('button');
const addButton = allButtons.find(btn => btn.querySelector('.lucide-plus'));
// Try to add the same value again
fireEvent.change(valueInput, { target: { value: "'self'" } });
if (addButton) {
fireEvent.click(addButton);
}
await waitFor(() => {
// Should not call onChange since it's a duplicate
const calls = mockOnChange.mock.calls.filter(call => {
const parsed = JSON.parse(call[0]);
return parsed[0].values.filter((v: string) => v === "'self'").length > 1;
});
expect(calls.length).toBe(0);
});
});
it('should parse initial value correctly', () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:'] },
{ directive: 'script-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
// Use getAllByText since these appear in both the select and the directive list
const defaultSrcElements = screen.getAllByText('default-src');
expect(defaultSrcElements.length).toBeGreaterThan(0);
const scriptSrcElements = screen.getAllByText('script-src');
expect(scriptSrcElements.length).toBeGreaterThan(0);
const selfElements = screen.getAllByText("'self'");
expect(selfElements.length).toBeGreaterThan(0);
});
it('should change directive selector', () => {
render(<CSPBuilder {...defaultProps} />);
// Get the first combobox (the directive selector)
const allSelects = screen.getAllByRole('combobox');
const directiveSelect = allSelects[0];
fireEvent.change(directiveSelect, { target: { value: 'script-src' } });
expect(directiveSelect).toHaveValue('script-src');
});
it('should handle Enter key to add directive', async () => {
render(<CSPBuilder {...defaultProps} />);
const valueInput = screen.getByPlaceholderText(/Enter value/);
fireEvent.change(valueInput, { target: { value: "'self'" } });
fireEvent.keyDown(valueInput, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
});
it('should not add empty values', () => {
render(<CSPBuilder {...defaultProps} />);
const addButton = screen.getByRole('button', { name: '' });
fireEvent.click(addButton);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('should remove individual values from directive', async () => {
const initialValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'", 'https:', 'data:'] },
]);
render(<CSPBuilder {...defaultProps} value={initialValue} />);
const selfBadge = screen.getByText("'self'");
fireEvent.click(selfBadge);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalled();
});
const callArg = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
const parsed = JSON.parse(callArg);
expect(parsed[0].values).not.toContain("'self'");
expect(parsed[0].values).toContain('https:');
});
it('should show success alert when valid', async () => {
const validValue = JSON.stringify([
{ directive: 'default-src', values: ["'self'"] },
]);
render(<CSPBuilder {...defaultProps} value={validValue} />);
await waitFor(() => {
expect(screen.getByText('CSP configuration looks good!')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,280 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import { SecurityHeaderProfileForm } from '../SecurityHeaderProfileForm';
import { securityHeadersApi } from '../../api/securityHeaders';
vi.mock('../../api/securityHeaders');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('SecurityHeaderProfileForm', () => {
const mockOnSubmit = vi.fn();
const mockOnCancel = vi.fn();
const mockOnDelete = vi.fn();
const defaultProps = {
onSubmit: mockOnSubmit,
onCancel: mockOnCancel,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(securityHeadersApi.calculateScore).mockResolvedValue({
score: 85,
max_score: 100,
breakdown: {},
suggestions: [],
});
});
it('should render with empty form', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByPlaceholderText(/Production Security Headers/)).toBeInTheDocument();
expect(screen.getByText('HTTP Strict Transport Security (HSTS)')).toBeInTheDocument();
});
it('should render with initial data', () => {
const initialData = {
id: 1,
name: 'Test Profile',
description: 'Test description',
hsts_enabled: true,
hsts_max_age: 31536000,
security_score: 85,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={initialData as any} />,
{ wrapper: createWrapper() }
);
expect(screen.getByDisplayValue('Test Profile')).toBeInTheDocument();
expect(screen.getByDisplayValue('Test description')).toBeInTheDocument();
});
it('should submit form with valid data', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'New Profile' } });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled();
});
const submitData = mockOnSubmit.mock.calls[0][0];
expect(submitData.name).toBe('New Profile');
});
it('should not submit with empty name', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const submitButton = screen.getByRole('button', { name: /Save Profile/ });
fireEvent.click(submitButton);
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should call onCancel when cancel button clicked', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const cancelButton = screen.getByRole('button', { name: /Cancel/ });
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('should toggle HSTS enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Switch component uses checkbox with sr-only class
const hstsSection = screen.getByText('HTTP Strict Transport Security (HSTS)').closest('div');
const hstsToggle = hstsSection?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(hstsToggle).toBeTruthy();
expect(hstsToggle.checked).toBe(true);
fireEvent.click(hstsToggle);
expect(hstsToggle.checked).toBe(false);
});
it('should show HSTS options when enabled', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/Max Age \(seconds\)/)).toBeInTheDocument();
expect(screen.getByText('Include Subdomains')).toBeInTheDocument();
expect(screen.getByText('Preload')).toBeInTheDocument();
});
it('should show preload warning when enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// Find the preload switch by finding the parent container with the "Preload" label
const preloadText = screen.getByText('Preload');
const preloadContainer = preloadText.closest('div')?.parentElement; // Go up to the flex container
const preloadSwitch = preloadContainer?.querySelector('input[type="checkbox"]');
expect(preloadSwitch).toBeTruthy();
if (preloadSwitch) {
fireEvent.click(preloadSwitch);
}
await waitFor(() => {
expect(screen.getByText(/Warning: HSTS Preload is Permanent/)).toBeInTheDocument();
});
});
it('should toggle CSP enabled', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
// CSP is disabled by default, so builder should not be visible
expect(screen.queryByText('Content Security Policy Builder')).not.toBeInTheDocument();
// Find and click the CSP toggle switch (checkbox with sr-only class)
const cspSection = screen.getByText('Content Security Policy (CSP)').closest('div');
const cspCheckbox = cspSection?.querySelector('input[type="checkbox"]');
if (cspCheckbox) {
fireEvent.click(cspCheckbox);
}
// Builder should now be visible
await waitFor(() => {
expect(screen.getByText('Content Security Policy Builder')).toBeInTheDocument();
});
});
it('should disable form for presets', () => {
const presetData = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm {...defaultProps} initialData={presetData as any} />,
{ wrapper: createWrapper() }
);
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
expect(nameInput).toBeDisabled();
expect(screen.getByText(/This is a system preset and cannot be modified/)).toBeInTheDocument();
});
it('should show delete button for non-presets', () => {
const profileData = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as any}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('button', { name: /Delete Profile/ })).toBeInTheDocument();
});
it('should not show delete button for presets', () => {
const presetData = {
id: 1,
name: 'Basic Security',
is_preset: true,
preset_type: 'basic',
security_score: 65,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={presetData as any}
onDelete={mockOnDelete}
/>,
{ wrapper: createWrapper() }
);
expect(screen.queryByRole('button', { name: /Delete Profile/ })).not.toBeInTheDocument();
});
it('should change referrer policy', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const referrerSelect = screen.getAllByRole('combobox')[1]; // Referrer policy is second select
fireEvent.change(referrerSelect, { target: { value: 'no-referrer' } });
expect(referrerSelect).toHaveValue('no-referrer');
});
it('should change x-frame-options', () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const xfoSelect = screen.getAllByRole('combobox')[0]; // X-Frame-Options is first select
fireEvent.change(xfoSelect, { target: { value: 'SAMEORIGIN' } });
expect(xfoSelect).toHaveValue('SAMEORIGIN');
});
it('should show loading state', () => {
render(<SecurityHeaderProfileForm {...defaultProps} isLoading={true} />, { wrapper: createWrapper() });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('should show deleting state', () => {
const profileData = {
id: 1,
name: 'Custom Profile',
is_preset: false,
security_score: 80,
};
render(
<SecurityHeaderProfileForm
{...defaultProps}
initialData={profileData as any}
onDelete={mockOnDelete}
isDeleting={true}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText('Deleting...')).toBeInTheDocument();
});
it('should calculate security score on form changes', async () => {
render(<SecurityHeaderProfileForm {...defaultProps} />, { wrapper: createWrapper() });
const nameInput = screen.getByPlaceholderText(/Production Security Headers/);
fireEvent.change(nameInput, { target: { value: 'Test' } });
await waitFor(() => {
expect(securityHeadersApi.calculateScore).toHaveBeenCalled();
}, { timeout: 1000 });
});
});

View File

@@ -0,0 +1,152 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SecurityScoreDisplay } from '../SecurityScoreDisplay';
describe('SecurityScoreDisplay', () => {
const mockBreakdown = {
hsts: 25,
csp: 20,
x_frame_options: 10,
x_content_type_options: 10,
};
const mockSuggestions = [
'Enable HSTS to enforce HTTPS',
'Add Content-Security-Policy',
];
it('should render with basic score', () => {
render(<SecurityScoreDisplay score={85} />);
expect(screen.getByText('85')).toBeInTheDocument();
expect(screen.getByText('/100')).toBeInTheDocument();
});
it('should render small size variant', () => {
render(<SecurityScoreDisplay score={50} size="sm" showDetails={false} />);
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('Security Score')).not.toBeInTheDocument();
});
it('should show correct color for high score', () => {
const { container } = render(<SecurityScoreDisplay score={85} maxScore={100} />);
const scoreElement = container.querySelector('.text-green-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for medium score', () => {
const { container } = render(<SecurityScoreDisplay score={60} maxScore={100} />);
const scoreElement = container.querySelector('.text-yellow-600');
expect(scoreElement).toBeInTheDocument();
});
it('should show correct color for low score', () => {
const { container } = render(<SecurityScoreDisplay score={30} maxScore={100} />);
const scoreElement = container.querySelector('.text-red-600');
expect(scoreElement).toBeInTheDocument();
});
it('should display breakdown when provided', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
expect(screen.getByText('Score Breakdown by Category')).toBeInTheDocument();
});
it('should toggle breakdown visibility', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
const breakdownButton = screen.getByText('Score Breakdown by Category');
expect(screen.queryByText('HSTS')).not.toBeInTheDocument();
fireEvent.click(breakdownButton);
expect(screen.getByText('HSTS')).toBeInTheDocument();
});
it('should display suggestions when provided', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
expect(screen.getByText(/Security Suggestions \(2\)/)).toBeInTheDocument();
});
it('should toggle suggestions visibility', () => {
render(
<SecurityScoreDisplay
score={50}
suggestions={mockSuggestions}
showDetails={true}
/>
);
const suggestionsButton = screen.getByText(/Security Suggestions/);
expect(screen.queryByText('Enable HSTS to enforce HTTPS')).not.toBeInTheDocument();
fireEvent.click(suggestionsButton);
expect(screen.getByText('Enable HSTS to enforce HTTPS')).toBeInTheDocument();
});
it('should not show details when showDetails is false', () => {
render(
<SecurityScoreDisplay
score={75}
breakdown={mockBreakdown}
suggestions={mockSuggestions}
showDetails={false}
/>
);
expect(screen.queryByText('Score Breakdown by Category')).not.toBeInTheDocument();
expect(screen.queryByText('Security Suggestions')).not.toBeInTheDocument();
});
it('should display custom max score', () => {
render(<SecurityScoreDisplay score={40} maxScore={50} />);
expect(screen.getByText('40')).toBeInTheDocument();
expect(screen.getByText('/50')).toBeInTheDocument();
});
it('should calculate percentage correctly', () => {
render(<SecurityScoreDisplay score={75} maxScore={100} />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('should render all breakdown categories', () => {
render(
<SecurityScoreDisplay
score={65}
breakdown={mockBreakdown}
showDetails={true}
/>
);
fireEvent.click(screen.getByText('Score Breakdown by Category'));
expect(screen.getByText('HSTS')).toBeInTheDocument();
expect(screen.getByText('Content Security Policy')).toBeInTheDocument();
expect(screen.getByText('X-Frame-Options')).toBeInTheDocument();
expect(screen.getByText('X-Content-Type-Options')).toBeInTheDocument();
});
});