feat(security): add global error boundary for defense in depth
- Adds `ErrorBoundary` component to catch unhandled React exceptions - Prevents UI crashes (White Screen of Death) - Ensures stack traces are not leaked to the user (Security Enhancement) - Wraps `App` in `main.tsx` - Adds tests verifying fallback UI and absence of error details in DOM Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
This commit is contained in:
23
src/components/layout/ErrorBoundary.module.css
Normal file
23
src/components/layout/ErrorBoundary.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: var(--md-sys-color-background);
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
49
src/components/layout/ErrorBoundary.tsx
Normal file
49
src/components/layout/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Button } from '../ui';
|
||||
import styles from './ErrorBoundary.module.css';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(_: Error): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error securely - do not expose to user
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className={styles.container} role="alert">
|
||||
<h1 className={styles.title}>Something went wrong</h1>
|
||||
<p className={styles.message}>
|
||||
We apologize for the inconvenience. Please try reloading the page.
|
||||
</p>
|
||||
<Button onClick={this.handleReload} variant="primary">
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
44
src/components/layout/__tests__/ErrorBoundary.test.tsx
Normal file
44
src/components/layout/__tests__/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ErrorBoundary } from '../ErrorBoundary';
|
||||
|
||||
// Mock component that throws
|
||||
const ThrowError = () => {
|
||||
throw new Error('Test Error');
|
||||
};
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when there is no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
expect(screen.getByText('Content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders fallback UI when there is an error', () => {
|
||||
// Prevent console.error from cluttering output during this test
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /reload page/i })).toBeTruthy();
|
||||
|
||||
// Ensure stack trace/error message is NOT rendered (security check)
|
||||
expect(screen.queryByText('Test Error')).toBeNull();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@ export { Navbar } from './Navbar';
|
||||
export { Footer } from './Footer';
|
||||
export { FancyCursor } from './FancyCursor';
|
||||
export { ScrollToTop } from './ScrollToTop';
|
||||
export { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { ErrorBoundary } from './components/layout';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user