Skip to content

Commit b30fadc

Browse files
fix(security): sanitize SVG content with DOMPurify
1 parent be44fcb commit b30fadc

2 files changed

Lines changed: 43 additions & 11 deletions

File tree

app/page.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,33 @@ describe('LandingPage', () => {
333333

334334
expect(screen.queryByText(/Too Many Requests/)).toBeNull();
335335
});
336+
337+
it('sanitizes unsafe SVG content before rendering', async () => {
338+
vi.stubGlobal(
339+
'fetch',
340+
vi.fn().mockResolvedValue({
341+
ok: true,
342+
status: 200,
343+
text: () =>
344+
Promise.resolve(
345+
'<svg data-testid="badge-svg"><script>alert("xss")</script><circle /></svg>'
346+
),
347+
})
348+
);
349+
350+
render(<LandingPage />);
351+
352+
const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement;
353+
354+
await act(async () => {
355+
fireEvent.change(input, { target: { value: 'octocat' } });
356+
});
357+
358+
await waitFor(() => {
359+
expect(screen.getByTestId('badge-svg')).toBeDefined();
360+
});
361+
362+
expect(document.querySelector('script')).toBeNull();
363+
});
364+
336365
});

app/page.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useRecentSearches } from '@/hooks/useRecentSearches';
1212
import { useDebounce } from '@/hooks/useDebounce';
1313
import { Footer } from '@/app/components/Footer';
1414
import InteractiveViewer from '@/components/InteractiveViewer';
15+
import DOMPurify from 'dompurify';
1516

1617
const Icons = {
1718
Github: () => (
@@ -266,11 +267,10 @@ export default function LandingPage() {
266267
type="submit"
267268
suppressHydrationWarning
268269
disabled={!mounted || trimmedUsername.length === 0}
269-
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 py-4 text-sm font-semibold transition-all duration-300 transform cursor-pointer hover:scale-[1.02] hover:shadow-lg active:scale-[0.98] disabled:cursor-not-allowed ${
270-
mounted && trimmedUsername.length > 0
271-
? 'bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-gray-100 shadow-md'
272-
: 'bg-gray-100 text-gray-400 dark:bg-white/5 dark:text-white/20'
273-
}`}
270+
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 py-4 text-sm font-semibold transition-all duration-300 transform cursor-pointer hover:scale-[1.02] hover:shadow-lg active:scale-[0.98] disabled:cursor-not-allowed ${mounted && trimmedUsername.length > 0
271+
? 'bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-gray-100 shadow-md'
272+
: 'bg-gray-100 text-gray-400 dark:bg-white/5 dark:text-white/20'
273+
}`}
274274
>
275275
<AnimatePresence mode="wait">
276276
{copied ? (
@@ -308,11 +308,10 @@ export default function LandingPage() {
308308
addSearch(trimmedUsername);
309309
}
310310
}}
311-
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-2xl border px-6 py-4 text-sm font-semibold transition-all duration-300 hover:scale-[1.02] hover:shadow-lg active:scale-[0.98] ${
312-
mounted && trimmedUsername.length > 0
313-
? 'border-black/10 bg-white text-black hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10 shadow-sm'
314-
: 'border-black/5 bg-gray-50 text-gray-400 dark:border-white/5 dark:bg-transparent dark:text-white/20'
315-
}`}
311+
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-2xl border px-6 py-4 text-sm font-semibold transition-all duration-300 hover:scale-[1.02] hover:shadow-lg active:scale-[0.98] ${mounted && trimmedUsername.length > 0
312+
? 'border-black/10 bg-white text-black hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10 shadow-sm'
313+
: 'border-black/5 bg-gray-50 text-gray-400 dark:border-white/5 dark:bg-transparent dark:text-white/20'
314+
}`}
316315
>
317316
Watch Dashboard
318317
</Link>
@@ -393,7 +392,11 @@ export default function LandingPage() {
393392
animate={{ opacity: 1, scale: 1 }}
394393
transition={{ duration: 0.5, ease: 'easeOut' }}
395394
className="cp-svg-container w-full max-w-[700px] drop-shadow-[0_30px_60px_rgba(0,0,0,0.15)] dark:drop-shadow-[0_30px_60px_rgba(0,0,0,0.5)] [&>svg]:w-full [&>svg]:h-auto"
396-
dangerouslySetInnerHTML={{ __html: svgContent }}
395+
dangerouslySetInnerHTML={{
396+
__html: DOMPurify.sanitize(svgContent, {
397+
USE_PROFILES: { svg: true, html: true },
398+
}),
399+
}}
397400
/>
398401
)}
399402
{svgState === 'loaded' && !svgContent && errorMessage && (

0 commit comments

Comments
 (0)