From daf9ace9e313e73adcaf9f0923627c5a84a2d803 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 29 Jan 2026 02:51:48 +0000 Subject: [PATCH 1/2] Add client hints and DuckDuckGo detection --- src/express-useragent.ts | 171 ++++++++++++++++++++++++++++++++ src/index.ts | 4 +- tests/duckduckgo.test.ts | 204 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 tests/duckduckgo.test.ts diff --git a/src/express-useragent.ts b/src/express-useragent.ts index 307eabc..343834b 100644 --- a/src/express-useragent.ts +++ b/src/express-useragent.ts @@ -1,5 +1,29 @@ import type { IncomingHttpHeaders } from 'http'; +/** + * Represents a single brand entry from User-Agent Client Hints + * @see https://wicg.github.io/ua-client-hints/ + */ +export interface ClientHintBrand { + brand: string; + version: string; +} + +/** + * Parsed User-Agent Client Hints data from HTTP headers + * Maps to the NavigatorUAData interface exposed by browsers + */ +export interface ClientHints { + brands: ClientHintBrand[]; + mobile: boolean; + platform: string; + platformVersion: string; + architecture: string; + bitness: string; + model: string; + fullVersionList: ClientHintBrand[]; +} + const BOTS = [ '\\+https:\\/\\/developers.google.com\\/\\+\\/web\\/snippet\\/', 'ad\\smonitoring', @@ -163,6 +187,10 @@ export interface AgentDetails extends Record { isWindowsPhone: boolean; electronVersion: string; SilkAccelerated?: boolean; + /** DuckDuckGo browser detected via Client Hints brand or WebKit UA pattern */ + isDuckDuckGo: boolean; + /** Parsed User-Agent Client Hints when available */ + clientHints: ClientHints | null; } export type HeadersLike = Partial>; @@ -228,6 +256,8 @@ const DEFAULT_AGENT: AgentDetails = { geoIp: {}, source: '', electronVersion: '', + isDuckDuckGo: false, + clientHints: null, }; function createDefaultAgent(): AgentDetails { @@ -237,9 +267,13 @@ function createDefaultAgent(): AgentDetails { source: '', electronVersion: '', botName: '', + clientHints: null, }; } +/** WebKit DuckDuckGo pattern: " Ddg/X.Y.Z" at end of UA string */ +const DUCKDUCKGO_WEBKIT_REGEXP = /\sDdg\/[\d.]+$/; + export class UserAgent { private readonly versions: Record = { Edge: /(?:edge|edga|edgios|edg)\/([\d\w.-]+)/i, @@ -264,6 +298,7 @@ export class UserAgent { WebKit: /applewebkit\/([\d\w.]+)/i, Wechat: /micromessenger\/([\d\w.]+)/i, Electron: /Electron\/([\d\w.]+)/i, + DuckDuckGo: /\sDdg\/([\d.]+)$/i, }; private readonly browsers: Record = { @@ -378,6 +413,108 @@ export class UserAgent { return this; } + /** + * Parse User-Agent Client Hints from HTTP headers + * @see https://wicg.github.io/ua-client-hints/ + */ + public parseClientHints(headers: HeadersLike | IncomingHttpHeaders): ClientHints | null { + const resolveHeader = (value: string | string[] | undefined): string => { + if (Array.isArray(value)) { + return value[0] ?? ''; + } + return value ?? ''; + }; + + // Normalize header keys to lowercase for case-insensitive lookup + const normalizedHeaders: Record = {}; + for (const [key, value] of Object.entries(headers ?? {})) { + normalizedHeaders[key.toLowerCase()] = resolveHeader(value); + } + + const secChUa = normalizedHeaders['sec-ch-ua']; + // Return null if no client hints are present + if (!secChUa) { + return null; + } + + const parseBrandList = (header: string): ClientHintBrand[] => { + if (!header) return []; + const brands: ClientHintBrand[] = []; + // Match patterns like: "Brand";v="version" or "Brand"; v="version" + const brandRegex = /"([^"]+)";\s*v="([^"]+)"/g; + let match; + while ((match = brandRegex.exec(header)) !== null) { + brands.push({ brand: match[1], version: match[2] }); + } + return brands; + }; + + const parseMobile = (header: string): boolean => { + // ?1 = true, ?0 or empty = false + return header === '?1'; + }; + + const parseQuotedString = (header: string): string => { + // Remove surrounding quotes if present + const match = /^"([^"]*)"$/.exec(header); + return match ? match[1] : header; + }; + + const clientHints: ClientHints = { + brands: parseBrandList(secChUa), + mobile: parseMobile(normalizedHeaders['sec-ch-ua-mobile'] ?? ''), + platform: parseQuotedString(normalizedHeaders['sec-ch-ua-platform'] ?? ''), + platformVersion: parseQuotedString(normalizedHeaders['sec-ch-ua-platform-version'] ?? ''), + architecture: parseQuotedString(normalizedHeaders['sec-ch-ua-arch'] ?? ''), + bitness: parseQuotedString(normalizedHeaders['sec-ch-ua-bitness'] ?? ''), + model: parseQuotedString(normalizedHeaders['sec-ch-ua-model'] ?? ''), + fullVersionList: parseBrandList(normalizedHeaders['sec-ch-ua-full-version-list'] ?? ''), + }; + + this.Agent.clientHints = clientHints; + return clientHints; + } + + /** + * Test for DuckDuckGo browser using both Client Hints and UA string patterns + * - Chromium platforms (Android, Windows): Sec-CH-UA brand "DuckDuckGo" + * - WebKit platforms (iOS, macOS): UA string ends with " Ddg/X.Y.Z" + */ + public testDuckDuckGo(): void { + // Check client hints brands first (Chromium-based DDG) + if (this.Agent.clientHints?.brands) { + const hasDdgBrand = this.Agent.clientHints.brands.some( + (brand) => brand.brand === 'DuckDuckGo', + ); + if (hasDdgBrand) { + this.Agent.isDuckDuckGo = true; + this.Agent.browser = 'DuckDuckGo'; + this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; + return; + } + } + + // Check full version list as well + if (this.Agent.clientHints?.fullVersionList) { + const hasDdgBrand = this.Agent.clientHints.fullVersionList.some( + (brand) => brand.brand === 'DuckDuckGo', + ); + if (hasDdgBrand) { + this.Agent.isDuckDuckGo = true; + this.Agent.browser = 'DuckDuckGo'; + this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; + return; + } + } + + // Fallback: check WebKit UA string pattern (iOS/macOS DDG) + if (DUCKDUCKGO_WEBKIT_REGEXP.test(this.Agent.source)) { + this.Agent.isDuckDuckGo = true; + this.Agent.browser = 'DuckDuckGo'; + this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; + } + } + public testBot(): void { const source = this.Agent.source.toLowerCase(); const match = IS_BOT_REGEXP.exec(source); @@ -584,6 +721,18 @@ export class UserAgent { return new UserAgent().hydrate(source).Agent; } + /** + * Hydrate agent from UA string and HTTP headers (including Client Hints) + * This method should be preferred when headers are available as it enables + * detection of browsers that use Client Hints (e.g., DuckDuckGo on Chromium) + */ + public hydrateFromHeaders(source: string, headers: HeadersLike | IncomingHttpHeaders): this { + this.hydrate(source); + this.parseClientHints(headers); + this.testDuckDuckGo(); + return this; + } + public hydrate(source: string): this { this.Agent = createDefaultAgent(); this.Agent.source = source.trim(); @@ -762,6 +911,8 @@ export class UserAgent { return match(this.versions.UC) ?? 'unknown'; case 'Facebook': return match(this.versions.Facebook) ?? 'unknown'; + case 'DuckDuckGo': + return this.getDuckDuckGoVersion() ?? 'unknown'; default: if (browser !== 'unknown') { const regex = new RegExp(`${browser}[\\/ ]([\\d\\w.\\-]+)`, 'i'); @@ -788,6 +939,26 @@ export class UserAgent { return match ? match[1] : 'unknown'; } + private getDuckDuckGoVersion(): string | null { + // Try client hints first + const hints = this.Agent.clientHints; + if (hints) { + // Check fullVersionList first for more precise version + const fullBrand = hints.fullVersionList.find((b) => b.brand === 'DuckDuckGo'); + if (fullBrand) { + return fullBrand.version; + } + // Fall back to brands + const brand = hints.brands.find((b) => b.brand === 'DuckDuckGo'); + if (brand) { + return brand.version; + } + } + // Fall back to UA string pattern + const match = this.Agent.source.match(this.versions.DuckDuckGo); + return match ? match[1] : null; + } + private getElectronVersion(string: string): string { const match = string.match(this.versions.Electron); if (match) { diff --git a/src/index.ts b/src/index.ts index 099ea13..301ff33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { IncomingHttpHeaders } from 'http'; import { UserAgent } from './express-useragent'; import type { AgentDetails } from './express-useragent'; -export type { AgentDetails, HeadersLike } from './express-useragent'; +export type { AgentDetails, HeadersLike, ClientHints, ClientHintBrand } from './express-useragent'; export { UserAgent } from './express-useragent'; // Declaration merging for Express Request @@ -40,7 +40,7 @@ export const express = (): UserAgentMiddleware => { const ucHeader = resolveHeader(headers['x-ucbrowser-ua']); const source = (ucHeader || uaHeader || 'unknown').trim() || 'unknown'; - const parser = new UserAgent().hydrate(source); + const parser = new UserAgent().hydrateFromHeaders(source, headers); parser.testNginxGeoIP(headers); // middleware duplicates tests to match legacy behaviour parser.testBot(); diff --git a/tests/duckduckgo.test.ts b/tests/duckduckgo.test.ts new file mode 100644 index 0000000..8a8434d --- /dev/null +++ b/tests/duckduckgo.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { UserAgent } from '../src'; + +describe('DuckDuckGo Browser Detection', () => { + describe('Client Hints detection (Chromium-based)', () => { + it('detects DuckDuckGo via Sec-CH-UA brand', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + const headers = { + 'sec-ch-ua': '"DuckDuckGo";v="1", "Chromium";v="120", "Not A;Brand";v="99"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + }; + + ua.hydrateFromHeaders(source, headers); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.version).toBe('1'); + }); + + it('detects DuckDuckGo on Android via Client Hints', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + const headers = { + 'sec-ch-ua': '"DuckDuckGo";v="5.207", "Chromium";v="120", "Not_A Brand";v="8"', + 'sec-ch-ua-mobile': '?1', + 'sec-ch-ua-platform': '"Android"', + }; + + ua.hydrateFromHeaders(source, headers); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.isMobile).toBe(true); + expect(ua.Agent.clientHints?.mobile).toBe(true); + expect(ua.Agent.clientHints?.platform).toBe('Android'); + }); + + it('extracts version from fullVersionList when available', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + const headers = { + 'sec-ch-ua': '"DuckDuckGo";v="1", "Chromium";v="120"', + 'sec-ch-ua-full-version-list': '"DuckDuckGo";v="1.2.3.456", "Chromium";v="120.0.6099.199"', + }; + + ua.hydrateFromHeaders(source, headers); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.version).toBe('1.2.3.456'); + }); + }); + + describe('WebKit UA string detection (iOS/macOS)', () => { + it('detects DuckDuckGo on macOS via Ddg/ pattern', () => { + const ua = new UserAgent(); + // Typical macOS DuckDuckGo UA + const source = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 Ddg/17.0'; + + ua.hydrate(source); + ua.testDuckDuckGo(); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.version).toBe('17.0'); + }); + + it('detects DuckDuckGo on iOS via Ddg/ pattern', () => { + const ua = new UserAgent(); + // Typical iOS DuckDuckGo UA + const source = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1 Ddg/7.92.1'; + + ua.hydrate(source); + ua.testDuckDuckGo(); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.version).toBe('7.92.1'); + expect(ua.Agent.isiPhone).toBe(true); + expect(ua.Agent.isMobile).toBe(true); + }); + + it('detects DuckDuckGo on iPad via Ddg/ pattern', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1 Ddg/7.92.1'; + + ua.hydrate(source); + ua.testDuckDuckGo(); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.isiPad).toBe(true); + }); + }); + + describe('Non-DuckDuckGo browsers', () => { + it('does not detect regular Chrome as DuckDuckGo', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + const headers = { + 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + }; + + ua.hydrateFromHeaders(source, headers); + + expect(ua.Agent.isDuckDuckGo).toBe(false); + expect(ua.Agent.browser).toBe('Chrome'); + }); + + it('does not detect regular Safari as DuckDuckGo', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; + + ua.hydrate(source); + ua.testDuckDuckGo(); + + expect(ua.Agent.isDuckDuckGo).toBe(false); + expect(ua.Agent.browser).toBe('Safari'); + }); + + it('distinguishes DuckDuckGo bot from DuckDuckGo browser', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (compatible; DuckDuckGo-Favicons-Bot/1.0; +http://duckduckgo.com)'; + + ua.hydrate(source); + ua.testDuckDuckGo(); + + // Bot detection should take precedence + expect(ua.Agent.isBot).toBe(true); + expect(ua.Agent.botName).toBe('duckduckgo-favicons-bot'); + // Browser detection via Ddg/ pattern shouldn't match bot UA + expect(ua.Agent.isDuckDuckGo).toBe(false); + }); + }); + + describe('Client Hints parsing', () => { + it('parses complete client hints headers', () => { + const ua = new UserAgent(); + const headers = { + 'sec-ch-ua': '"DuckDuckGo";v="1", "Chromium";v="120"', + 'sec-ch-ua-mobile': '?1', + 'sec-ch-ua-platform': '"Android"', + 'sec-ch-ua-platform-version': '"13.0.0"', + 'sec-ch-ua-arch': '"arm"', + 'sec-ch-ua-bitness': '"64"', + 'sec-ch-ua-model': '"Pixel 7"', + 'sec-ch-ua-full-version-list': '"DuckDuckGo";v="1.2.3", "Chromium";v="120.0.6099.199"', + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(2); + expect(hints?.brands[0]).toEqual({ brand: 'DuckDuckGo', version: '1' }); + expect(hints?.brands[1]).toEqual({ brand: 'Chromium', version: '120' }); + expect(hints?.mobile).toBe(true); + expect(hints?.platform).toBe('Android'); + expect(hints?.platformVersion).toBe('13.0.0'); + expect(hints?.architecture).toBe('arm'); + expect(hints?.bitness).toBe('64'); + expect(hints?.model).toBe('Pixel 7'); + expect(hints?.fullVersionList).toHaveLength(2); + }); + + it('returns null when no client hints present', () => { + const ua = new UserAgent(); + const headers = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).toBeNull(); + }); + + it('handles case-insensitive header names', () => { + const ua = new UserAgent(); + const headers = { + 'SEC-CH-UA': '"DuckDuckGo";v="1"', + 'Sec-Ch-Ua-Mobile': '?0', + 'SEC-CH-UA-PLATFORM': '"Windows"', + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands[0].brand).toBe('DuckDuckGo'); + expect(hints?.mobile).toBe(false); + expect(hints?.platform).toBe('Windows'); + }); + }); +}); From 3cd2664606ec4379d19e8201cd4996ac6b32ef08 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sun, 1 Feb 2026 11:54:01 +0000 Subject: [PATCH 2/2] Improve error handling and testing --- src/express-useragent.ts | 117 ++++++++++++++++++++++++++---------- tests/duckduckgo.test.ts | 127 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 31 deletions(-) diff --git a/src/express-useragent.ts b/src/express-useragent.ts index 343834b..86e839c 100644 --- a/src/express-useragent.ts +++ b/src/express-useragent.ts @@ -323,6 +323,7 @@ export class UserAgent { AlamoFire: /alamofire/i, UC: /UCBrowser/i, Facebook: /FBA[NV]/, + DuckDuckGo: /\sDdg\/[\d.]+$/i, }; private readonly os: Record = { @@ -413,22 +414,54 @@ export class UserAgent { return this; } + /** Maximum header length to process (prevents DoS from oversized headers) */ + private static readonly MAX_HEADER_LENGTH = 2048; + /** Maximum number of brands to parse from a brand list */ + private static readonly MAX_BRAND_COUNT = 20; + /** * Parse User-Agent Client Hints from HTTP headers * @see https://wicg.github.io/ua-client-hints/ */ public parseClientHints(headers: HeadersLike | IncomingHttpHeaders): ClientHints | null { const resolveHeader = (value: string | string[] | undefined): string => { - if (Array.isArray(value)) { - return value[0] ?? ''; + try { + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + const first = value[0]; + if (typeof first !== 'string') { + return ''; + } + return first.slice(0, UserAgent.MAX_HEADER_LENGTH); + } + if (typeof value !== 'string') { + return ''; + } + return value.slice(0, UserAgent.MAX_HEADER_LENGTH); + } catch { + return ''; } - return value ?? ''; }; + // Validate headers input + if (headers === null || headers === undefined || typeof headers !== 'object') { + return null; + } + // Normalize header keys to lowercase for case-insensitive lookup const normalizedHeaders: Record = {}; - for (const [key, value] of Object.entries(headers ?? {})) { - normalizedHeaders[key.toLowerCase()] = resolveHeader(value); + try { + let headerCount = 0; + const maxHeaders = 50; // Limit iterations over headers object + for (const [key, value] of Object.entries(headers)) { + if (++headerCount > maxHeaders) break; + if (typeof key !== 'string') continue; + normalizedHeaders[key.toLowerCase()] = resolveHeader(value); + } + } catch { + return null; } const secChUa = normalizedHeaders['sec-ch-ua']; @@ -438,41 +471,63 @@ export class UserAgent { } const parseBrandList = (header: string): ClientHintBrand[] => { - if (!header) return []; - const brands: ClientHintBrand[] = []; - // Match patterns like: "Brand";v="version" or "Brand"; v="version" - const brandRegex = /"([^"]+)";\s*v="([^"]+)"/g; - let match; - while ((match = brandRegex.exec(header)) !== null) { - brands.push({ brand: match[1], version: match[2] }); + try { + if (!header || typeof header !== 'string') return []; + const brands: ClientHintBrand[] = []; + // Match patterns like: "Brand";v="version" or "Brand"; v="version" + const brandRegex = /"([^"]{1,128})";\s*v="([^"]{1,64})"/g; + let match; + let iterations = 0; + while ((match = brandRegex.exec(header)) !== null) { + if (++iterations > UserAgent.MAX_BRAND_COUNT) break; + brands.push({ brand: match[1], version: match[2] }); + } + return brands; + } catch { + return []; } - return brands; }; const parseMobile = (header: string): boolean => { - // ?1 = true, ?0 or empty = false - return header === '?1'; + try { + // ?1 = true, ?0 or empty = false + if (typeof header !== 'string') return false; + return header === '?1'; + } catch { + return false; + } }; const parseQuotedString = (header: string): string => { - // Remove surrounding quotes if present - const match = /^"([^"]*)"$/.exec(header); - return match ? match[1] : header; - }; - - const clientHints: ClientHints = { - brands: parseBrandList(secChUa), - mobile: parseMobile(normalizedHeaders['sec-ch-ua-mobile'] ?? ''), - platform: parseQuotedString(normalizedHeaders['sec-ch-ua-platform'] ?? ''), - platformVersion: parseQuotedString(normalizedHeaders['sec-ch-ua-platform-version'] ?? ''), - architecture: parseQuotedString(normalizedHeaders['sec-ch-ua-arch'] ?? ''), - bitness: parseQuotedString(normalizedHeaders['sec-ch-ua-bitness'] ?? ''), - model: parseQuotedString(normalizedHeaders['sec-ch-ua-model'] ?? ''), - fullVersionList: parseBrandList(normalizedHeaders['sec-ch-ua-full-version-list'] ?? ''), + try { + if (typeof header !== 'string') return ''; + // Limit input length before regex + const truncated = header.slice(0, 256); + // Remove surrounding quotes if present + const match = /^"([^"]*)"$/.exec(truncated); + return match ? match[1] : truncated; + } catch { + return ''; + } }; - this.Agent.clientHints = clientHints; - return clientHints; + try { + const clientHints: ClientHints = { + brands: parseBrandList(secChUa), + mobile: parseMobile(normalizedHeaders['sec-ch-ua-mobile'] ?? ''), + platform: parseQuotedString(normalizedHeaders['sec-ch-ua-platform'] ?? ''), + platformVersion: parseQuotedString(normalizedHeaders['sec-ch-ua-platform-version'] ?? ''), + architecture: parseQuotedString(normalizedHeaders['sec-ch-ua-arch'] ?? ''), + bitness: parseQuotedString(normalizedHeaders['sec-ch-ua-bitness'] ?? ''), + model: parseQuotedString(normalizedHeaders['sec-ch-ua-model'] ?? ''), + fullVersionList: parseBrandList(normalizedHeaders['sec-ch-ua-full-version-list'] ?? ''), + }; + + this.Agent.clientHints = clientHints; + return clientHints; + } catch { + return null; + } } /** diff --git a/tests/duckduckgo.test.ts b/tests/duckduckgo.test.ts index 8a8434d..19d8f85 100644 --- a/tests/duckduckgo.test.ts +++ b/tests/duckduckgo.test.ts @@ -53,6 +53,30 @@ describe('DuckDuckGo Browser Detection', () => { expect(ua.Agent.isDuckDuckGo).toBe(true); expect(ua.Agent.version).toBe('1.2.3.456'); }); + + it('detects DuckDuckGo when present only in fullVersionList', () => { + const ua = new UserAgent(); + const source = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + // DuckDuckGo only in fullVersionList, not in sec-ch-ua brands + const headers = { + 'sec-ch-ua': '"Chromium";v="120", "Not_A Brand";v="8"', + 'sec-ch-ua-full-version-list': + '"DuckDuckGo";v="5.207.0", "Chromium";v="120.0.6099.199", "Not_A Brand";v="8.0.0.0"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + }; + + ua.hydrateFromHeaders(source, headers); + + expect(ua.Agent.isDuckDuckGo).toBe(true); + expect(ua.Agent.browser).toBe('DuckDuckGo'); + expect(ua.Agent.version).toBe('5.207.0'); + expect(ua.Agent.clientHints?.fullVersionList).toContainEqual({ + brand: 'DuckDuckGo', + version: '5.207.0', + }); + }); }); describe('WebKit UA string detection (iOS/macOS)', () => { @@ -201,4 +225,107 @@ describe('DuckDuckGo Browser Detection', () => { expect(hints?.platform).toBe('Windows'); }); }); + + describe('Client Hints error handling', () => { + it('returns null for null headers', () => { + const ua = new UserAgent(); + const hints = ua.parseClientHints(null as unknown as Record); + expect(hints).toBeNull(); + }); + + it('returns null for undefined headers', () => { + const ua = new UserAgent(); + const hints = ua.parseClientHints(undefined as unknown as Record); + expect(hints).toBeNull(); + }); + + it('handles non-string header values gracefully', () => { + const ua = new UserAgent(); + const headers = { + 'sec-ch-ua': '"Brand";v="1"', + 'sec-ch-ua-mobile': 123 as unknown as string, // Invalid type + 'sec-ch-ua-platform': { nested: 'object' } as unknown as string, // Invalid type + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(1); + expect(hints?.mobile).toBe(false); // Falls back to false + expect(hints?.platform).toBe(''); // Falls back to empty string + }); + + it('handles array header values', () => { + const ua = new UserAgent(); + const headers = { + 'sec-ch-ua': ['"Brand";v="1"', '"Second";v="2"'] as unknown as string, + 'sec-ch-ua-platform': ['"Windows"'] as unknown as string, + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(1); + expect(hints?.platform).toBe('Windows'); + }); + + it('truncates oversized brand names and versions', () => { + const ua = new UserAgent(); + const longBrand = 'A'.repeat(200); + const longVersion = 'B'.repeat(100); + const headers = { + 'sec-ch-ua': `"${longBrand}";v="${longVersion}"`, + }; + + const hints = ua.parseClientHints(headers); + + // Regex limits brand to 128 chars, version to 64 chars - so no match + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(0); + }); + + it('limits number of parsed brands', () => { + const ua = new UserAgent(); + // Create header with more than MAX_BRAND_COUNT (20) brands + const brands = Array.from({ length: 30 }, (_, i) => `"Brand${i}";v="${i}"`).join(', '); + const headers = { + 'sec-ch-ua': brands, + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands.length).toBeLessThanOrEqual(20); + }); + + it('handles malformed brand list gracefully', () => { + const ua = new UserAgent(); + const headers = { + 'sec-ch-ua': 'not a valid brand list {{{{', + 'sec-ch-ua-mobile': '?1', + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(0); + expect(hints?.mobile).toBe(true); + }); + + it('handles empty string values', () => { + const ua = new UserAgent(); + const headers = { + 'sec-ch-ua': '""', + 'sec-ch-ua-mobile': '', + 'sec-ch-ua-platform': '""', + }; + + const hints = ua.parseClientHints(headers); + + expect(hints).not.toBeNull(); + expect(hints?.brands).toHaveLength(0); + expect(hints?.mobile).toBe(false); + expect(hints?.platform).toBe(''); + }); + }); });