Skip to content
Open
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
226 changes: 226 additions & 0 deletions src/express-useragent.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -163,6 +187,10 @@ export interface AgentDetails extends Record<string, unknown> {
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<Record<string, string | string[] | undefined>>;
Expand Down Expand Up @@ -228,6 +256,8 @@ const DEFAULT_AGENT: AgentDetails = {
geoIp: {},
source: '',
electronVersion: '',
isDuckDuckGo: false,
clientHints: null,
};

function createDefaultAgent(): AgentDetails {
Expand All @@ -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<string, RegExp> = {
Edge: /(?:edge|edga|edgios|edg)\/([\d\w.-]+)/i,
Expand All @@ -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<string, RegExp> = {
Expand All @@ -288,6 +323,7 @@ export class UserAgent {
AlamoFire: /alamofire/i,
UC: /UCBrowser/i,
Facebook: /FBA[NV]/,
DuckDuckGo: /\sDdg\/[\d.]+$/i,
};

private readonly os: Record<string, RegExp> = {
Expand Down Expand Up @@ -378,6 +414,162 @@ 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 => {
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 '';
}
};

// 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<string, string> = {};
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'];
// Return null if no client hints are present
if (!secChUa) {
return null;
}

const parseBrandList = (header: string): ClientHintBrand[] => {
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 [];
}
};

const parseMobile = (header: string): boolean => {
try {
// ?1 = true, ?0 or empty = false
if (typeof header !== 'string') return false;
return header === '?1';
} catch {
return false;
}
};

const parseQuotedString = (header: string): string => {
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 '';
}
};

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;
}
}

/**
* 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);
Expand Down Expand Up @@ -584,6 +776,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();
Expand Down Expand Up @@ -762,6 +966,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');
Expand All @@ -788,6 +994,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) {
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading