From e1d9d637723e7530e7be2c5110cac9bc092b8d8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 16 Aug 2025 21:18:54 +0000 Subject: [PATCH 1/2] Improve client hint handling with error resilience and reload prevention Co-authored-by: me --- src/index.ts | 39 ++++++++++++++++- test/index.test.ts | 107 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index d37c04f..ff03f37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,18 @@ export function getHintUtils>>( .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 { @@ -77,13 +88,34 @@ 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; }'; @@ -91,6 +123,9 @@ function checkClientHints() { // Trigger the reload window.location.reload(); + } else { + // Reset reload attempts counter if no reload was needed + sessionStorage.removeItem('clientHintReloadAttempts'); } } diff --git a/test/index.test.ts b/test/index.test.ts index afa02b8..07046d7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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')) +}) From da1215482a05c01cf83c3428e46876935f03b842 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 16 Aug 2025 21:28:42 +0000 Subject: [PATCH 2/2] Improve error logging for cookie decoding with more detailed error output Co-authored-by: me --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ff03f37..e932eb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,8 @@ export function getHintUtils>>( // 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}`, + `Failed to decode cookie value for ${hint.cookieName}:`, + error, ) return null }