Skip to content

Commit 669b2ca

Browse files
eshaanagkali
authored andcommitted
fix(frontend): sanitize customize numeric url params
1 parent 0783fae commit 669b2ca

2 files changed

Lines changed: 80 additions & 19 deletions

File tree

app/customize/page.test.tsx

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,22 @@ type MockContainerProps = HTMLAttributes<HTMLElement> & {
1414

1515
type MockControlsPanelProps = {
1616
username: string;
17-
timezone: string;
17+
radius: number;
1818
onUsernameChange: (value: string) => void;
19+
};
20+
21+
type MockAdvancedSettingsPanelProps = {
22+
timezone: string;
23+
badgeWidth: number | '';
24+
badgeHeight: number | '';
25+
grace: number;
1926
onTimezoneChange: (value: string) => void;
2027
};
2128

29+
const mockSearchParams = vi.hoisted(() => ({
30+
values: new Map<string, string>(),
31+
}));
32+
2233
vi.mock('next/link', () => ({
2334
default: ({ href, children, ...props }: MockLinkProps) => (
2435
<a href={href} {...props}>
@@ -39,7 +50,7 @@ vi.mock('next/navigation', () => ({
3950
replace: vi.fn(),
4051
}),
4152
useSearchParams: () => ({
42-
get: () => null,
53+
get: (key: string) => mockSearchParams.values.get(key) ?? null,
4354
}),
4455
}));
4556

@@ -48,33 +59,39 @@ vi.mock('@/components/InteractiveViewer', () => ({
4859
}));
4960

5061
vi.mock('./components/ControlsPanel', () => ({
51-
ControlsPanel: ({ username, onUsernameChange }: MockControlsPanelProps) => (
62+
ControlsPanel: ({ username, radius, onUsernameChange }: MockControlsPanelProps) => (
5263
<div>
5364
<input
5465
aria-label="Mock username"
5566
value={username}
5667
onChange={(event) => onUsernameChange(event.currentTarget.value)}
5768
/>
69+
<output aria-label="Mock radius">{String(radius)}</output>
5870
</div>
5971
),
6072
}));
6173

6274
vi.mock('./components/AdvancedSettingsPanel', () => ({
6375
AdvancedSettingsPanel: ({
6476
timezone,
77+
badgeWidth,
78+
badgeHeight,
79+
grace,
6580
onTimezoneChange,
66-
}: {
67-
timezone: string;
68-
onTimezoneChange: (value: string) => void;
69-
}) => (
70-
<select
71-
aria-label="Mock timezone"
72-
value={timezone}
73-
onChange={(event) => onTimezoneChange(event.currentTarget.value)}
74-
>
75-
<option value="UTC">UTC</option>
76-
<option value="Asia/Kolkata">Asia/Kolkata</option>
77-
</select>
81+
}: MockAdvancedSettingsPanelProps) => (
82+
<div>
83+
<select
84+
aria-label="Mock timezone"
85+
value={timezone}
86+
onChange={(event) => onTimezoneChange(event.currentTarget.value)}
87+
>
88+
<option value="UTC">UTC</option>
89+
<option value="Asia/Kolkata">Asia/Kolkata</option>
90+
</select>
91+
<output aria-label="Mock badge width">{String(badgeWidth)}</output>
92+
<output aria-label="Mock badge height">{String(badgeHeight)}</output>
93+
<output aria-label="Mock grace">{String(grace)}</output>
94+
</div>
7895
),
7996
}));
8097

@@ -86,6 +103,7 @@ vi.mock('./components/ExportPanel', () => ({
86103

87104
describe('CustomizePage timezone query params', () => {
88105
beforeEach(() => {
106+
mockSearchParams.values.clear();
89107
global.fetch = vi.fn().mockResolvedValue({
90108
ok: true,
91109
text: async () => '<svg></svg>',
@@ -126,4 +144,26 @@ describe('CustomizePage timezone query params', () => {
126144
expect(snippet).toContain('user=octocat');
127145
expect(snippet).toContain('tz=Asia%2FKolkata');
128146
});
147+
148+
it('falls back when numeric URL params are malformed', () => {
149+
mockSearchParams.values.set('user', 'octocat');
150+
mockSearchParams.values.set('radius', 'abc');
151+
mockSearchParams.values.set('width', 'NaN');
152+
mockSearchParams.values.set('height', 'nope');
153+
mockSearchParams.values.set('grace', 'bad');
154+
155+
render(<CustomizePage />);
156+
157+
expect(screen.getByLabelText('Mock radius').textContent).toBe('8');
158+
expect(screen.getByLabelText('Mock badge width').textContent).toBe('');
159+
expect(screen.getByLabelText('Mock badge height').textContent).toBe('');
160+
expect(screen.getByLabelText('Mock grace').textContent).toBe('1');
161+
162+
const snippet = screen.getByLabelText('Mock export snippet').textContent;
163+
expect(snippet).toContain('user=octocat');
164+
expect(snippet).not.toContain('radius=NaN');
165+
expect(snippet).not.toContain('width=NaN');
166+
expect(snippet).not.toContain('height=NaN');
167+
expect(snippet).not.toContain('grace=NaN');
168+
});
129169
});

app/customize/page.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ import type {
2323
import { useDebounce } from '@/hooks/useDebounce';
2424
import { getExportSnippet, buildQueryParams } from './utils';
2525

26+
function readNumericSearchParam(
27+
searchParams: URLSearchParams,
28+
key: string,
29+
fallback: number | '',
30+
min?: number,
31+
max?: number
32+
): number | '' {
33+
const raw = searchParams.get(key);
34+
if (!raw) return fallback;
35+
36+
const parsed = Number(raw);
37+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
38+
return fallback;
39+
}
40+
41+
if (min !== undefined && parsed < min) return fallback;
42+
if (max !== undefined && parsed > max) return fallback;
43+
44+
return parsed;
45+
}
46+
2647
// ─── Main Page ────────────────────────────────────────────────────────────────
2748

2849
function CustomizePageInner(): ReactElement {
@@ -76,16 +97,16 @@ function CustomizePageInner(): ReactElement {
7697
setSpeed(searchParams.get('speed') ?? '8s');
7798
setFont((searchParams.get('font') as Font) ?? 'Inter');
7899
setYear(searchParams.get('year') ?? '');
79-
setRadius(Number(searchParams.get('radius') ?? 8));
100+
setRadius(readNumericSearchParam(searchParams, 'radius', 8, 0, 50) as number);
80101
setSize((searchParams.get('size') as BadgeSize) ?? 'medium');
81102
setHideTitle(searchParams.get('hide_title') === 'true');
82103
setHideBackground(searchParams.get('hide_background') === 'true');
83104
setHideStats(searchParams.get('hide_stats') === 'true');
84105
setViewMode((searchParams.get('view') as ViewMode) ?? 'default');
85106
setDeltaFormat((searchParams.get('delta_format') as DeltaFormat) ?? 'percent');
86-
setBadgeWidth(searchParams.get('width') ? Number(searchParams.get('width')) : '');
87-
setBadgeHeight(searchParams.get('height') ? Number(searchParams.get('height')) : '');
88-
setGrace(Number(searchParams.get('grace') ?? 1));
107+
setBadgeWidth(readNumericSearchParam(searchParams, 'width', '', 100, 1200));
108+
setBadgeHeight(readNumericSearchParam(searchParams, 'height', '', 80, 800));
109+
setGrace(readNumericSearchParam(searchParams, 'grace', 1, 0, 7) as number);
89110
setLanguage((searchParams.get('lang') as Language) ?? 'en');
90111
setTimezone((searchParams.get('tz') as Timezone) ?? 'UTC');
91112
// eslint-disable-next-line react-hooks/exhaustive-deps

0 commit comments

Comments
 (0)