Skip to content

Commit 8448468

Browse files
fix: handle malformed uri error in cookies (and handle infinite loops) (#24)
Co-authored-by: me <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
1 parent 2a4e223 commit 8448468

File tree

2 files changed

+145
-2
lines changed

2 files changed

+145
-2
lines changed

src/index.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ export function getHintUtils<Hints extends Record<string, ClientHint<any>>>(
1818
.find((c: string) => c.startsWith(hint.cookieName + '='))
1919
?.split('=')[1]
2020

21-
return value ? decodeURIComponent(value) : null
21+
if (!value) return null
22+
23+
try {
24+
return decodeURIComponent(value)
25+
} catch (error) {
26+
// Handle malformed URI gracefully by falling back to null
27+
// This prevents crashes and allows the hint's fallback value to be used
28+
console.warn(
29+
`Failed to decode cookie value for ${hint.cookieName}:`,
30+
error,
31+
)
32+
return null
33+
}
2234
}
2335

2436
function getHints(request?: Request): ClientHintsValue<Hints> {
@@ -77,20 +89,44 @@ function checkClientHints() {
7789
})
7890
.join(',\n')}
7991
];
92+
93+
// Add safety check to prevent infinite refresh scenarios
94+
let reloadAttempts = parseInt(sessionStorage.getItem('clientHintReloadAttempts') || '0');
95+
if (reloadAttempts > 3) {
96+
console.warn('Too many client hint reload attempts, skipping reload to prevent infinite loop');
97+
return;
98+
}
99+
80100
for (const hint of hints) {
81101
document.cookie = encodeURIComponent(hint.name) + '=' + encodeURIComponent(hint.actual) + '; Max-Age=31536000; SameSite=Lax; path=/';
82-
if (decodeURIComponent(hint.value) !== hint.actual) {
102+
103+
try {
104+
const decodedValue = decodeURIComponent(hint.value);
105+
if (decodedValue !== hint.actual) {
106+
cookieChanged = true;
107+
}
108+
} catch (error) {
109+
// Handle malformed URI gracefully
110+
console.warn('Failed to decode cookie value during client hint check:', error);
111+
// If we can't decode the value, assume it's different to be safe
83112
cookieChanged = true;
84113
}
85114
}
115+
86116
if (cookieChanged) {
117+
// Increment reload attempts counter
118+
sessionStorage.setItem('clientHintReloadAttempts', String(reloadAttempts + 1));
119+
87120
// Hide the page content immediately to prevent visual flicker
88121
const style = document.createElement('style');
89122
style.textContent = 'html { visibility: hidden !important; }';
90123
document.head.appendChild(style);
91124
92125
// Trigger the reload
93126
window.location.reload();
127+
} else {
128+
// Reset reload attempts counter if no reload was needed
129+
sessionStorage.removeItem('clientHintReloadAttempts');
94130
}
95131
}
96132

test/index.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,110 @@ test('getting values from document', () => {
8484
delete global.document
8585
}
8686
})
87+
88+
test('handles malformed URI in cookie values gracefully', () => {
89+
const hints = getHintUtils({
90+
colorScheme: colorSchemeHint,
91+
timeZone: timeZoneHint,
92+
reducedMotion: reducedMotionHint,
93+
})
94+
95+
// Test with malformed URI that would cause decodeURIComponent to fail
96+
const request = new Request('https://example.com', {
97+
headers: {
98+
Cookie:
99+
'CH-prefers-color-scheme=dark; CH-time-zone=%C0%AF; CH-reduced-motion=reduce',
100+
},
101+
})
102+
103+
// The malformed timezone should fall back to the fallback value
104+
const result = hints.getHints(request)
105+
assert.strictEqual(result.colorScheme, 'dark')
106+
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Should fall back due to malformed URI
107+
assert.strictEqual(result.reducedMotion, 'reduce')
108+
})
109+
110+
test('handles completely malformed cookie values', () => {
111+
const hints = getHintUtils({
112+
colorScheme: colorSchemeHint,
113+
timeZone: timeZoneHint,
114+
reducedMotion: reducedMotionHint,
115+
})
116+
117+
// Test with completely invalid URI sequences
118+
const request = new Request('https://example.com', {
119+
headers: {
120+
Cookie:
121+
'CH-prefers-color-scheme=%C0%AF; CH-time-zone=%FF%FE; CH-reduced-motion=%E0%80%80',
122+
},
123+
})
124+
125+
// All malformed values should fall back to their fallback values
126+
const result = hints.getHints(request)
127+
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
128+
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
129+
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
130+
})
131+
132+
test('handles mixed valid and invalid cookie values', () => {
133+
const hints = getHintUtils({
134+
colorScheme: colorSchemeHint,
135+
timeZone: timeZoneHint,
136+
reducedMotion: reducedMotionHint,
137+
})
138+
139+
// Test with mix of valid and invalid values
140+
const request = new Request('https://example.com', {
141+
headers: {
142+
Cookie:
143+
'CH-prefers-color-scheme=light; CH-time-zone=%C0%AF; CH-reduced-motion=no-preference',
144+
},
145+
})
146+
147+
// Valid values should work, invalid ones should fall back
148+
const result = hints.getHints(request)
149+
assert.strictEqual(result.colorScheme, 'light') // Valid value
150+
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Invalid value, should fall back
151+
assert.strictEqual(result.reducedMotion, 'no-preference') // Valid value
152+
})
153+
154+
test('handles empty cookie values gracefully', () => {
155+
const hints = getHintUtils({
156+
colorScheme: colorSchemeHint,
157+
timeZone: timeZoneHint,
158+
reducedMotion: reducedMotionHint,
159+
})
160+
161+
// Test with empty cookie values
162+
const request = new Request('https://example.com', {
163+
headers: {
164+
Cookie: 'CH-prefers-color-scheme=; CH-time-zone=; CH-reduced-motion=',
165+
},
166+
})
167+
168+
// Empty values should fall back to fallback values
169+
const result = hints.getHints(request)
170+
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
171+
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
172+
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
173+
})
174+
175+
test('client script includes infinite refresh prevention', () => {
176+
const hints = getHintUtils({
177+
colorScheme: colorSchemeHint,
178+
timeZone: timeZoneHint,
179+
reducedMotion: reducedMotionHint,
180+
})
181+
182+
const checkScript = hints.getClientHintCheckScript()
183+
184+
// Should include sessionStorage check for infinite refresh prevention
185+
assert.ok(checkScript.includes('sessionStorage.getItem'))
186+
assert.ok(checkScript.includes('clientHintReloadAttempts'))
187+
assert.ok(checkScript.includes('Too many client hint reload attempts'))
188+
189+
// Should include try-catch around decodeURIComponent
190+
assert.ok(checkScript.includes('try'))
191+
assert.ok(checkScript.includes('catch'))
192+
assert.ok(checkScript.includes('decodeURIComponent'))
193+
})

0 commit comments

Comments
 (0)