Skip to content

Commit bfc0bbb

Browse files
authored
fix(exports): sanitize and canonicalize SVG before download (JhaSourav07#1946)
## Description Fixes JhaSourav07#1941 Improves SVG export safety and consistency by sanitizing and canonicalizing SVG content before bundling or downloading through `useShareActions`. ### Changes * Sanitize exported SVG content to remove unsafe attributes and elements. * Canonicalize/normalize SVG output for consistent formatting. * Ensure markdown exports only include sanitized SVG assets. * Improve export reliability and security for client-side downloads. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [ ] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents ea582d1 + dc66974 commit bfc0bbb

3 files changed

Lines changed: 152 additions & 8 deletions

File tree

components/dashboard/ShareSheet.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,12 +354,13 @@ describe('ShareSheet', () => {
354354
expect(screen.getByText('SVG Downloaded!')).toBeDefined();
355355
});
356356

357-
expect(global.fetch).toHaveBeenCalledWith(
358-
`/api/streak?user=${encodeURIComponent(defaultProps.username)}`
357+
const fetchedUrl = vi.mocked(global.fetch).mock.calls[0][0] as string;
358+
expect(fetchedUrl).toMatch(
359+
new RegExp(`/api/streak\\?user=${encodeURIComponent(defaultProps.username)}$`)
359360
);
360361

361362
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob;
362-
expect(blob.type).toBe('image/svg+xml');
363+
expect(blob.type).toContain('image/svg+xml');
363364

364365
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
365366
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-download');

hooks/useShareActions.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ describe('useShareActions', () => {
3636
vi.clearAllMocks();
3737
vi.useFakeTimers();
3838

39+
global.fetch = vi.fn();
40+
3941
Object.defineProperty(navigator, 'clipboard', {
4042
value: {
4143
writeText: vi.fn().mockResolvedValue(undefined),
@@ -65,6 +67,7 @@ describe('useShareActions', () => {
6567
vi.useRealTimers();
6668
vi.restoreAllMocks();
6769
document.body.innerHTML = '';
70+
Reflect.deleteProperty(globalThis, 'fetch');
6871
Reflect.deleteProperty(globalThis, 'ClipboardItem');
6972
});
7073

@@ -184,6 +187,39 @@ describe('useShareActions', () => {
184187
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
185188
expect.stringContaining(`/api/streak?user=${mockUsername}`)
186189
);
190+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
191+
expect.stringMatching(/^!\[CommitPulse\]\(https?:\/\/[^)]+\/api\/streak\?user=atharv96k\)$/)
192+
);
193+
});
194+
195+
it('handleDownloadSVG sanitizes unsafe attributes before creating the download blob', async () => {
196+
const maliciousSvg =
197+
'<svg><script>alert(1)</script><rect width="10" height="10" onload="evil()" /><a href="javascript:alert(1)"><text>x</text></a></svg>';
198+
199+
vi.mocked(global.fetch).mockResolvedValue({
200+
ok: true,
201+
text: vi.fn().mockResolvedValue(maliciousSvg),
202+
} as unknown as Response);
203+
204+
const { result } = renderHook(() => useShareActions(mockUsername, mockExportData, mockClose));
205+
206+
await act(async () => {
207+
await result.current.handleDownloadSVG();
208+
});
209+
210+
expect(global.fetch).toHaveBeenCalledWith(
211+
expect.stringMatching(/\/api\/streak\?user=atharv96k$/)
212+
);
213+
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
214+
215+
const blob = vi.mocked(global.URL.createObjectURL).mock.calls[0][0] as Blob;
216+
const sanitizedSvg = await blob.text();
217+
218+
expect(sanitizedSvg).toContain('<svg');
219+
expect(sanitizedSvg).not.toContain('<script');
220+
expect(sanitizedSvg).not.toContain('onload=');
221+
expect(sanitizedSvg).not.toContain('javascript:');
222+
expect(result.current.states['svg']).toBe('success');
187223
});
188224

189225
it('handleDownloadJSON bundles and formats a structured JSON data blob download', () => {

hooks/useShareActions.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,113 @@ const BASE_ORIGIN =
1111
'https://commitpulse.vercel.app';
1212
const PROFILE_URL = (username: string) => `${BASE_ORIGIN}/dashboard/${username}`;
1313

14+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
15+
const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
16+
const UNSAFE_SVG_ELEMENTS = new Set([
17+
'script',
18+
'foreignobject',
19+
'iframe',
20+
'object',
21+
'embed',
22+
'audio',
23+
'video',
24+
'canvas',
25+
'meta',
26+
'base',
27+
]);
28+
29+
const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F]+/g;
30+
31+
function normalizeLineEndings(value: string): string {
32+
return value.replace(/\r\n?/g, '\n');
33+
}
34+
35+
function sanitizeUsernameForUrl(username: string): string {
36+
return username.trim().replace(CONTROL_CHARS_REGEX, '');
37+
}
38+
39+
function sanitizeFilenameSegment(value: string): string {
40+
const cleaned = value
41+
.trim()
42+
.replace(CONTROL_CHARS_REGEX, '')
43+
.replace(/[^a-zA-Z0-9._-]/g, '-')
44+
.replace(/-+/g, '-')
45+
.replace(/^-|-$/g, '');
46+
47+
return cleaned || 'commitpulse-export';
48+
}
49+
50+
function buildStreakSvgUrl(username: string): string {
51+
const url = new URL('/api/streak', BASE_ORIGIN);
52+
url.searchParams.set('user', sanitizeUsernameForUrl(username));
53+
return url.toString();
54+
}
55+
56+
function removeUnsafeSvgAttributes(element: Element) {
57+
const attributes = Array.from(element.attributes);
58+
for (const attr of attributes) {
59+
const attrName = attr.name.toLowerCase();
60+
const attrValue = attr.value.trim();
61+
62+
if (attrName.startsWith('on')) {
63+
element.removeAttribute(attr.name);
64+
continue;
65+
}
66+
67+
if (attrName === 'href' || attrName === 'xlink:href') {
68+
const normalized = attrValue.toLowerCase();
69+
if (
70+
normalized.startsWith('javascript:') ||
71+
normalized.startsWith('vbscript:') ||
72+
normalized.startsWith('data:')
73+
) {
74+
element.removeAttribute(attr.name);
75+
}
76+
}
77+
}
78+
}
79+
80+
function sanitizeAndCanonicalizeSvg(svgText: string): string {
81+
const normalizedText = normalizeLineEndings(svgText).trim();
82+
const parser = new DOMParser();
83+
const parsed = parser.parseFromString(normalizedText, 'image/svg+xml');
84+
const parseError = parsed.querySelector('parsererror');
85+
86+
if (parseError) {
87+
throw new Error('Invalid SVG payload');
88+
}
89+
90+
const root = parsed.documentElement;
91+
if (!root || root.tagName.toLowerCase() !== 'svg') {
92+
throw new Error('SVG root element is required');
93+
}
94+
95+
if (!root.getAttribute('xmlns')) {
96+
root.setAttribute('xmlns', SVG_NAMESPACE);
97+
}
98+
99+
if (!root.getAttribute('xmlns:xlink')) {
100+
root.setAttribute('xmlns:xlink', XLINK_NAMESPACE);
101+
}
102+
103+
const elements = Array.from(parsed.querySelectorAll('*'));
104+
for (const element of elements) {
105+
if (UNSAFE_SVG_ELEMENTS.has(element.tagName.toLowerCase())) {
106+
element.remove();
107+
continue;
108+
}
109+
110+
removeUnsafeSvgAttributes(element);
111+
}
112+
113+
removeUnsafeSvgAttributes(root);
114+
return `${new XMLSerializer().serializeToString(root)}\n`;
115+
}
116+
117+
function buildMarkdownExport(username: string): string {
118+
return `![CommitPulse](${buildStreakSvgUrl(username)})`;
119+
}
120+
14121
export function useShareActions(
15122
username: string,
16123
exportData: DashboardExportData,
@@ -189,13 +296,13 @@ export function useShareActions(
189296
const handleDownloadSVG = async () => {
190297
setOptionState('svg', 'loading');
191298
try {
192-
const response = await fetch(`/api/streak?user=${encodeURIComponent(username)}`);
299+
const response = await fetch(buildStreakSvgUrl(username));
193300
if (!response.ok) throw new Error('Failed to fetch SVG');
194-
const svgText = await response.text();
195-
const blob = new Blob([svgText], { type: 'image/svg+xml' });
301+
const svgText = sanitizeAndCanonicalizeSvg(await response.text());
302+
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
196303
const url = URL.createObjectURL(blob);
197304
const link = document.createElement('a');
198-
link.download = `${username}-commitpulse.svg`;
305+
link.download = `${sanitizeFilenameSegment(username)}-commitpulse.svg`;
199306
link.href = url;
200307
link.click();
201308
URL.revokeObjectURL(url);
@@ -208,7 +315,7 @@ export function useShareActions(
208315
const handleCopyMarkdown = async () => {
209316
setOptionState('markdown', 'loading');
210317
try {
211-
const markdown = `![CommitPulse](${window.location.origin}/api/streak?user=${encodeURIComponent(username)})`;
318+
const markdown = buildMarkdownExport(username);
212319
await navigator.clipboard.writeText(markdown);
213320
setOptionState('markdown', 'success');
214321
setTimeout(() => onClose(), 800);

0 commit comments

Comments
 (0)