Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ export function getHintUtils<Hints extends Record<string, ClientHint<any>>>(
.find((c: string) => c.startsWith(hint.cookieName + '='))
?.split('=')[1]

return value ? decodeURIComponent(value) : null
if (!value) return null

try {
return decodeURIComponent(value)
} catch (error) {
// Handle malformed URI gracefully by falling back to null
// This prevents crashes and allows the hint's fallback value to be used
console.warn(
`Failed to decode cookie value for ${hint.cookieName}:`,
error,
)
return null
}
}

function getHints(request?: Request): ClientHintsValue<Hints> {
Expand Down Expand Up @@ -77,20 +89,44 @@ function checkClientHints() {
})
.join(',\n')}
];

// Add safety check to prevent infinite refresh scenarios
let reloadAttempts = parseInt(sessionStorage.getItem('clientHintReloadAttempts') || '0');
if (reloadAttempts > 3) {
console.warn('Too many client hint reload attempts, skipping reload to prevent infinite loop');
return;
}

for (const hint of hints) {
document.cookie = encodeURIComponent(hint.name) + '=' + encodeURIComponent(hint.actual) + '; Max-Age=31536000; SameSite=Lax; path=/';
if (decodeURIComponent(hint.value) !== hint.actual) {

try {
const decodedValue = decodeURIComponent(hint.value);
if (decodedValue !== hint.actual) {
cookieChanged = true;
}
} catch (error) {
// Handle malformed URI gracefully
console.warn('Failed to decode cookie value during client hint check:', error);
// If we can't decode the value, assume it's different to be safe
cookieChanged = true;
}
}

if (cookieChanged) {
// Increment reload attempts counter
sessionStorage.setItem('clientHintReloadAttempts', String(reloadAttempts + 1));

// Hide the page content immediately to prevent visual flicker
const style = document.createElement('style');
style.textContent = 'html { visibility: hidden !important; }';
document.head.appendChild(style);

// Trigger the reload
window.location.reload();
} else {
// Reset reload attempts counter if no reload was needed
sessionStorage.removeItem('clientHintReloadAttempts');
}
}

Expand Down
107 changes: 107 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,110 @@ test('getting values from document', () => {
delete global.document
}
})

test('handles malformed URI in cookie values gracefully', () => {
const hints = getHintUtils({
colorScheme: colorSchemeHint,
timeZone: timeZoneHint,
reducedMotion: reducedMotionHint,
})

// Test with malformed URI that would cause decodeURIComponent to fail
const request = new Request('https://example.com', {
headers: {
Cookie:
'CH-prefers-color-scheme=dark; CH-time-zone=%C0%AF; CH-reduced-motion=reduce',
},
})

// The malformed timezone should fall back to the fallback value
const result = hints.getHints(request)
assert.strictEqual(result.colorScheme, 'dark')
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Should fall back due to malformed URI
assert.strictEqual(result.reducedMotion, 'reduce')
})

test('handles completely malformed cookie values', () => {
const hints = getHintUtils({
colorScheme: colorSchemeHint,
timeZone: timeZoneHint,
reducedMotion: reducedMotionHint,
})

// Test with completely invalid URI sequences
const request = new Request('https://example.com', {
headers: {
Cookie:
'CH-prefers-color-scheme=%C0%AF; CH-time-zone=%FF%FE; CH-reduced-motion=%E0%80%80',
},
})

// All malformed values should fall back to their fallback values
const result = hints.getHints(request)
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
})

test('handles mixed valid and invalid cookie values', () => {
const hints = getHintUtils({
colorScheme: colorSchemeHint,
timeZone: timeZoneHint,
reducedMotion: reducedMotionHint,
})

// Test with mix of valid and invalid values
const request = new Request('https://example.com', {
headers: {
Cookie:
'CH-prefers-color-scheme=light; CH-time-zone=%C0%AF; CH-reduced-motion=no-preference',
},
})

// Valid values should work, invalid ones should fall back
const result = hints.getHints(request)
assert.strictEqual(result.colorScheme, 'light') // Valid value
assert.strictEqual(result.timeZone, timeZoneHint.fallback) // Invalid value, should fall back
assert.strictEqual(result.reducedMotion, 'no-preference') // Valid value
})

test('handles empty cookie values gracefully', () => {
const hints = getHintUtils({
colorScheme: colorSchemeHint,
timeZone: timeZoneHint,
reducedMotion: reducedMotionHint,
})

// Test with empty cookie values
const request = new Request('https://example.com', {
headers: {
Cookie: 'CH-prefers-color-scheme=; CH-time-zone=; CH-reduced-motion=',
},
})

// Empty values should fall back to fallback values
const result = hints.getHints(request)
assert.strictEqual(result.colorScheme, colorSchemeHint.fallback)
assert.strictEqual(result.timeZone, timeZoneHint.fallback)
assert.strictEqual(result.reducedMotion, reducedMotionHint.fallback)
})

test('client script includes infinite refresh prevention', () => {
const hints = getHintUtils({
colorScheme: colorSchemeHint,
timeZone: timeZoneHint,
reducedMotion: reducedMotionHint,
})

const checkScript = hints.getClientHintCheckScript()

// Should include sessionStorage check for infinite refresh prevention
assert.ok(checkScript.includes('sessionStorage.getItem'))
assert.ok(checkScript.includes('clientHintReloadAttempts'))
assert.ok(checkScript.includes('Too many client hint reload attempts'))

// Should include try-catch around decodeURIComponent
assert.ok(checkScript.includes('try'))
assert.ok(checkScript.includes('catch'))
assert.ok(checkScript.includes('decodeURIComponent'))
})