Skip to content

Commit 40699f9

Browse files
committed
refactor: replace inline SVG rendering with img tag for security and adjust layout spacing
1 parent ea059a6 commit 40699f9

5 files changed

Lines changed: 31 additions & 65 deletions

File tree

app/page.test.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ describe('LandingPage', () => {
127127
render(<LandingPage />);
128128

129129
expect(screen.getByText(/Enter a GitHub username above to instantly generate/i)).toBeDefined();
130-
// No SVG badge should be present yet
131-
expect(screen.queryByTestId('badge-svg')).toBeNull();
130+
// No badge img should be present yet
131+
expect(screen.queryByTestId('badge-img')).toBeNull();
132132
});
133133

134134
it('updates the username when input changes and fetches the badge', async () => {
@@ -148,9 +148,9 @@ describe('LandingPage', () => {
148148
);
149149
});
150150

151-
// After the fetch resolves the inline SVG should be in the DOM
151+
// After the fetch resolves the badge img element should be in the DOM
152152
await waitFor(() => {
153-
expect(screen.getByTestId('badge-svg')).toBeDefined();
153+
expect(screen.getByTestId('badge-img')).toBeDefined();
154154
});
155155
});
156156

@@ -334,7 +334,10 @@ describe('LandingPage', () => {
334334
expect(screen.queryByText(/Too Many Requests/)).toBeNull();
335335
});
336336

337-
it('sanitizes unsafe SVG content before rendering', async () => {
337+
it('renders a badge img (not inline SVG) so XSS via SVG content is structurally impossible', async () => {
338+
// The fetch mock returns an SVG with a <script> tag, but the new implementation
339+
// never injects SVG text into the DOM — it uses <img src=URL> which the browser
340+
// renders opaquely. No script tag should ever appear in the document.
338341
vi.stubGlobal(
339342
'fetch',
340343
vi.fn().mockResolvedValue({
@@ -355,10 +358,14 @@ describe('LandingPage', () => {
355358
fireEvent.change(input, { target: { value: 'octocat' } });
356359
});
357360

361+
// An <img> element with the API URL should appear (not inline SVG)
358362
await waitFor(() => {
359-
expect(screen.getByTestId('badge-svg')).toBeDefined();
363+
const img = screen.getByTestId('badge-img') as HTMLImageElement;
364+
expect(img).toBeDefined();
365+
expect(img.src).toContain('user=octocat');
360366
});
361367

368+
// The SVG text is never injected into the DOM, so no <script> tag can exist
362369
expect(document.querySelector('script')).toBeNull();
363370
});
364371
});

app/page.tsx

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import { CustomizeCTA } from './components/CustomizeCTA';
1111
import { useRecentSearches } from '@/hooks/useRecentSearches';
1212
import { useDebounce } from '@/hooks/useDebounce';
1313
import { Footer } from '@/app/components/Footer';
14-
import InteractiveViewer from '@/components/InteractiveViewer';
15-
import DOMPurify from 'dompurify';
1614

1715
const Icons = {
1816
Github: () => (
@@ -72,7 +70,6 @@ const Icons = {
7270
export default function LandingPage() {
7371
const [username, setUsername] = useState('');
7472
const [copied, setCopied] = useState(false);
75-
const [svgContent, setSvgContent] = useState<string | null>(null);
7673
const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
7774
const [errorMessage, setErrorMessage] = useState<string | null>(null);
7875
const guideRef = useRef<HTMLDivElement>(null);
@@ -92,27 +89,21 @@ export default function LandingPage() {
9289
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://commitpulse.vercel.app';
9390
const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${trimmedUsername})`;
9491

95-
// Fetch SVG content whenever debounced username changes.
92+
// Probe badge URL for errors; actual rendering uses a native <img> tag.
9693
useEffect(() => {
9794
if (!hasUsername) {
9895
// eslint-disable-next-line react-hooks/set-state-in-effect
99-
setSvgContent(null);
100-
10196
setSvgState('idle');
10297
return;
10398
}
10499

105100
setSvgState('loading');
106101

107-
setSvgContent(null);
108-
109102
const controller = new AbortController();
110103

111104
fetch(badgeUrl, { signal: controller.signal })
112105
.then(async (res) => {
113-
const text = await res.text();
114106
if (!res.ok) {
115-
setSvgContent(null);
116107
setSvgState('error');
117108
if (res.status === 404 || res.status === 400 || res.status === 429) {
118109
setErrorMessage('GitHub user not found');
@@ -121,11 +112,6 @@ export default function LandingPage() {
121112
}
122113
return;
123114
}
124-
return text;
125-
})
126-
.then((text) => {
127-
if (!text) return;
128-
setSvgContent(text);
129115
setSvgState('loaded');
130116
setErrorMessage(null);
131117
})
@@ -357,7 +343,7 @@ export default function LandingPage() {
357343

358344
<div className="group relative mt-10">
359345
<div className="absolute -inset-1 rounded-[2.5rem] bg-gradient-to-r from-emerald-500/20 to-cyan-500/20 opacity-50 blur-2xl transition duration-1000 group-hover:opacity-100" />
360-
<InteractiveViewer className="relative flex min-h-[360px] items-center justify-center overflow-hidden rounded-3xl border border-black/5 bg-white/50 p-8 backdrop-blur-xl shadow-2xl dark:border-white/10 dark:bg-[#0a0a0a]/80">
346+
<div className="relative flex min-h-[480px] md:min-h-[520px] items-center justify-center overflow-visible rounded-3xl border border-black/5 bg-white/50 p-8 backdrop-blur-xl shadow-2xl dark:border-white/10 dark:bg-[#0a0a0a]/80">
361347
{hasUsername ? (
362348
<div className="w-full flex items-center justify-center">
363349
{svgState === 'loading' && (
@@ -388,21 +374,21 @@ export default function LandingPage() {
388374
</p>
389375
</div>
390376
)}
391-
{svgState === 'loaded' && svgContent && (
377+
{svgState === 'loaded' && (
392378
<motion.div
393379
initial={{ opacity: 0, scale: 0.95 }}
394380
animate={{ opacity: 1, scale: 1 }}
395381
transition={{ duration: 0.5, ease: 'easeOut' }}
396-
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"
397-
dangerouslySetInnerHTML={{
398-
__html: DOMPurify.sanitize(svgContent, {
399-
USE_PROFILES: { svg: true, html: true },
400-
}),
401-
}}
402-
/>
403-
)}
404-
{svgState === 'loaded' && !svgContent && errorMessage && (
405-
<p className="text-red-400 text-sm text-center">{errorMessage}</p>
382+
className="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)]"
383+
>
384+
{/* eslint-disable-next-line @next/next/no-img-element */}
385+
<img
386+
data-testid="badge-img"
387+
src={badgeUrl}
388+
alt={`CommitPulse badge for ${debouncedUsername}`}
389+
className="w-full h-auto"
390+
/>
391+
</motion.div>
406392
)}
407393
</div>
408394
) : (
@@ -419,7 +405,7 @@ export default function LandingPage() {
419405
</p>
420406
</div>
421407
)}
422-
</InteractiveViewer>
408+
</div>
423409
</div>
424410
</section>
425411

lib/svg/generator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,7 @@ export function generateNotFoundSVG(
11771177
let ghostTowers = '';
11781178
for (const { col, row, h } of GHOST_LAYOUT) {
11791179
const tx = 300 + (col - row) * 16;
1180-
const ty = 120 + (col + row) * 9;
1180+
const ty = 120 + (col + row) * 10;
11811181

11821182
ghostTowers += `
11831183
<g transform="translate(${tx}, ${ty - h})">
@@ -1580,7 +1580,7 @@ export function generateRateLimitSVG(
15801580
let ghostTowers = '';
15811581
for (const { col, row, h } of ghostLayout) {
15821582
const tx = 300 + (col - row) * 16;
1583-
const ty = 120 + (col + row) * 9;
1583+
const ty = 120 + (col + row) * 10;
15841584

15851585
ghostTowers += `
15861586
<g transform="translate(${tx}, ${ty - h})">

lib/svg/layout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export function computeFaceOpacity(count: number, isGhostCityMode: boolean): Fac
6868
export function projectIsometric(weekIndex: number, dayIndex: number): { x: number; y: number } {
6969
return {
7070
x: 300 + (weekIndex - dayIndex) * 16,
71-
y: 120 + (weekIndex + dayIndex) * 9,
71+
y: 120 + (weekIndex + dayIndex) * 10,
7272
};
7373
}
7474

0 commit comments

Comments
 (0)