From 129657673bec3e59a5e0be34d6ba15e47cc5e716 Mon Sep 17 00:00:00 2001 From: Faisal Salman Date: Tue, 22 Aug 2023 23:39:34 +0700 Subject: [PATCH] [helpers] Add new method: `UACHParser()`, parse client-hints HTTP headers into its JS API equivalent --- src/helpers/package.json | 6 +- src/helpers/readme.md | 72 +++++++++++- src/helpers/ua-parser-helpers.d.ts | 37 +++++- src/helpers/ua-parser-helpers.js | 179 +++++++++++++++++++++-------- test/mocha-test-helpers.js | 64 ++++++++++- 5 files changed, 298 insertions(+), 60 deletions(-) diff --git a/src/helpers/package.json b/src/helpers/package.json index 092ffc9..b5167cf 100644 --- a/src/helpers/package.json +++ b/src/helpers/package.json @@ -1,12 +1,12 @@ { "title": "UAParser.js Helpers", "name": "@ua-parser-js/helpers", - "version": "0.0.1", + "version": "0.0.3", "author": "Faisal Salman ", "description": "A collection of utility methods for UAParser.js", "main": "ua-parser-helpers.js", "module": "ua-parser-helpers.mjs", - "scripts" : { + "scripts": { "test": "echo 1" }, "repository": { @@ -26,4 +26,4 @@ "url": "https://github.com/faisalman/ua-parser-js/issues" }, "homepage": "https://github.com/faisalman/ua-parser-js#readme" -} \ No newline at end of file +} diff --git a/src/helpers/readme.md b/src/helpers/readme.md index e23abc2..5f1efd2 100644 --- a/src/helpers/readme.md +++ b/src/helpers/readme.md @@ -12,7 +12,11 @@ Check whether a user-agent string match with [frozen user-agent pattern](https:/ ### * `unfreezeUA():Promise` -construct new unfreezed user-agent string using real data from client hints +Construct new unfreezed user-agent string using real data from client hints + +### * `UACHParser(headers: object): object` + +Parse client hints HTTP headers (sec-ch-ua) into its JS API equivalent ## Code Example @@ -59,7 +63,69 @@ import { unfreezeUA } from '@ua-parser-js/helpers'; 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36' */ -unfreezeUA(); - +unfreezeUA() + .then(ua => console.log(ua)); // 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) New Browser/110.1.2.3 Chromium/110.1.2.3 Safari/537.36' +``` + +```js +import { UACHParser } from '@ua-parser-js/helpers'; + +/* + Suppose we're in a server having this client hints data: + + const headers = { + 'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"', + 'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"', + 'sec-ch-ua-arch' : 'arm', + 'sec-ch-ua-bitness' : '64', + 'sec-ch-ua-mobile' : '?1', + 'sec-ch-ua-model' : 'Pixel 99', + 'sec-ch-ua-platform' : 'Linux', + 'sec-ch-ua-platform-version' : '13', + 'user-agent' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36' + }; +*/ + +const userAgentData = UACHParser(headers); + +console.log(userAgentData); +/* + { + "architecture": "arm", + "bitness": "64", + "brands": [ + { + "brand": "Chromium", + "version": "93" + }, + { + "brand": "Google Chrome", + "version": "93" + }, + { + "brand": " Not;A Brand", + "version": "99" + } + ], + "fullVersionList": [ + { + "brand": "Chromium", + "version": "93.0.1.2" + }, + { + "brand": "Google Chrome", + "version": "93.0.1.2" + }, + { + "brand": " Not;A Brand", + "version": "99.0.1.2" + } + ], + "mobile": true, + "model": "Pixel 99", + "platform": "Linux", + "platformVersion": "13" + } +*/ ``` \ No newline at end of file diff --git a/src/helpers/ua-parser-helpers.d.ts b/src/helpers/ua-parser-helpers.d.ts index 61e041f..836de09 100644 --- a/src/helpers/ua-parser-helpers.d.ts +++ b/src/helpers/ua-parser-helpers.d.ts @@ -1,2 +1,37 @@ +interface IBrowser { + brand: string; + version: string; +} + +interface ClientHintsJSLowEntropy { + brands: Array; + mobile: boolean; + platform: string; +} + +export interface ClientHintsJSHighEntropy extends ClientHintsJSLowEntropy { + architecture?: string; + bitness?: string; + formFactor?: string; + fullVersionList?: Array; + model?: string; + platformVersion?: string; + wow64?: boolean; +}; + +export interface ClientHintsHTTPHeaders { + 'sec-ch-ua-arch'?: string; + 'sec-ch-ua-bitness'?: string; + 'sec-ch-ua'?: string; + 'sec-ch-ua-form-factor'?: string; + 'sec-ch-ua-full-version-list'?: string; + 'sec-ch-ua-mobile'?: string; + 'sec-ch-ua-model'?: string; + 'sec-ch-ua-platform'?: string; + 'sec-ch-ua-platform-version'?: string; + 'sec-ch-ua-wow64'?: string; +} + export function isFrozenUA(ua: string): boolean; -export function unfreezeUA(): Promise; \ No newline at end of file +export function unfreezeUA(): Promise; +export function UACHParser(headers: ClientHintsHTTPHeaders): ClientHintsJSHighEntropy; \ No newline at end of file diff --git a/src/helpers/ua-parser-helpers.js b/src/helpers/ua-parser-helpers.js index 45933eb..26d28f7 100644 --- a/src/helpers/ua-parser-helpers.js +++ b/src/helpers/ua-parser-helpers.js @@ -35,61 +35,138 @@ const isFrozenUA = ua => /^Mozilla\/5\.0 \((Windows NT 10\.0; Win64; x64|Macintosh; Intel Mac OS X 10_15_7|X11; Linux x86_64|X11; CrOS x86_64 14541\.0\.0|Fuchsia|Linux; Android 10; K)\) AppleWebKit\/537\.36 \(KHTML, like Gecko\) Chrome\/\d+\.0\.0\.0 (Mobile )?Safari\/537\.36$/.test(ua); -const unfreezeUA = async () => { - if (!navigator) { - throw new Error('Currently only support browser environment'); - } else { - let ua = navigator.userAgent; - if (navigator.userAgentData && isFrozenUA(ua)) { - const ch = await navigator.userAgentData.getHighEntropyValues(['architecture', 'bitness', 'fullVersionList', 'model', 'platform', 'platformVersion', 'wow64']); - switch (ch.platform) { - case 'Windows': - let [major, minor] = ch.platformVersion?.split('.').map(i => parseInt(i, 10)); - let osReplacer = (major < 1) ? - `$ 6.${minor}` : - (major >= 13) ? - `$ 11.${minor}` : - `$ 10.${minor}`; - let archReplacer = (ch.architecture == 'arm') ? - '; ARM' : - (ch.wow64) ? - '; WOW64' : - (ch.architecture == 'x86' && ch.bitness == '64') ? - '; $' : ''; - ua = ua.replace(/(?Windows NT) 10\.0/, osReplacer) - .replace(/; (?Win64; x64)/, archReplacer); - break; - case 'Android': - ua = ua.replace(/(?Android) 10; K/, `$ ${ch.platformVersion}; ${ch.model}`); - break; - case 'Linux': - case 'Chrome OS': - archReplacer = (ch.architecture == 'arm') ? - ((ch.bitness == '64') ? 'arm64' : 'arm') : - (ch.architecture == 'x86' && ch.bitness == '64') ? - '$' : 'x86'; - - ua = ua.replace(/(?x86_64)/, archReplacer); - break; - case 'macOS': - ua = ua.replace(/(?Mac OS X) 10_15_7/, `$ ${ch.platformVersion.replace(/\./, '_')}`); - break; - } - let browserReplacer = ''; - ch.fullVersionList?.forEach((browser) => { - if (!/not.a.brand/i.test(browser.brand)) { - browserReplacer += `${browser.brand}/${browser.version} `; - } - }); - if (browserReplacer) { - ua = ua.replace(/Chrome\/\d+\.0\.0\.0 /, browserReplacer); - } +const unfreezeUA = async (ua, ch) => { + const env = typeof navigator == 'undefined' ? 'node' : 'browser'; + if (env == 'node') { + if (!ua['user-agent']) { + throw new Error('User-Agent header not found'); } - return ua; + ch = UACHParser(ua); + ua = ua['user-agent']; + } else { + ua = ua || navigator.userAgent; + ch = ch || await navigator.userAgentData?.getHighEntropyValues(['arch', 'bitness', 'fullVersionList', 'model', 'platform', 'platformVersion', 'wow64']); } + if (isFrozenUA(ua) && ch) { + switch (ch.platform) { + case 'Windows': + let [major, minor] = ch.platformVersion + .split('.') + .map(num => parseInt(num, 10)); + major = (major < 1) ? '6' : (major >= 13) ? '11' : '10'; + ua = ua .replace(/(?Windows NT) 10\.0/, `$ ${major}.${minor}`) + .replace(/; (?Win64; x64)/, + (ch.architecture == 'arm') ? + '; ARM' : + (ch.wow64) ? + '; WOW64' : + (ch.architecture == 'x86' && ch.bitness != '64') ? + '' : '; $'); + break; + case 'Android': + ua = ua.replace(/(?Android) 10; K/, `$ ${ch.platformVersion}; ${ch.model}`); + break; + case 'Linux': + case 'Chrome OS': + ua = ua.replace(/(?x86_64)/, + (ch.architecture == 'arm') ? + ((ch.bitness == '64') ? 'arm64' : 'arm') : + (ch.architecture == 'x86' && ch.bitness != '64') ? + 'x86' : '$'); + break; + case 'macOS': + ua = ua.replace(/(?Mac OS X) 10_15_7/, `$ ${ch.platformVersion.replace(/\./, '_')}`); + break; + } + if (ch.fullVersionList) { + ua = ua.replace(/Chrome\/\d+\.0\.0\.0 /, + ch.fullVersionList + .filter(browser => !/not.a.brand/i.test(browser.brand)) + .map(browser => `${browser.brand.replace(/^google /i,'')}/${browser.version} `) + .join('')); + } + } + return ua; +}; + +const UACHMap = { + 'sec-ch-ua-arch' : { + prop : 'architecture', + type : 'sf-string' + }, + 'sec-ch-ua-bitness' : { + prop : 'bitness', + type : 'sf-string' + }, + 'sec-ch-ua' : { + prop : 'brands', + type : 'sf-list' + }, + 'sec-ch-ua-form-factor' : { + prop : 'formFactor', + type : 'sf-string' + }, + 'sec-ch-ua-full-version-list' : { + prop : 'fullVersionList', + type : 'sf-list' + }, + 'sec-ch-ua-mobile' : { + prop : 'mobile', + type : 'sf-boolean', + }, + 'sec-ch-ua-model' : { + prop : 'model', + type : 'sf-string', + }, + 'sec-ch-ua-platform' : { + prop : 'platform', + type : 'sf-string' + }, + 'sec-ch-ua-platform-version' : { + prop : 'platformVersion', + type : 'sf-string' + }, + 'sec-ch-ua-wow64' : { + prop : 'wow64', + type : 'sf-boolean' + } +}; + +const UACHParser = (headers) => { + const parse = (str, type) => { + if (!str) { + return ''; + } + switch (type) { + case 'sf-boolean': + return /\?1/.test(str); + case 'sf-list': + return str.replace(/\\?\"/g, '') + .split(', ') + .map(brands => { + const [brand, version] = brands.split(';v='); + return { + brand : brand, + version : version + }; + }); + case 'sf-string': + default: + return str.replace(/\\?\"/g, ''); + } + }; + let ch = {}; + Object.keys(UACHMap).forEach(field => { + if (headers.hasOwnProperty(field)) { + const { prop, type } = UACHMap[field]; + ch[prop] = parse(headers[field], type); + } + }); + return ch; }; module.exports = { isFrozenUA, - unfreezeUA + unfreezeUA, + UACHParser }; \ No newline at end of file diff --git a/test/mocha-test-helpers.js b/test/mocha-test-helpers.js index c8417d6..07ba15c 100644 --- a/test/mocha-test-helpers.js +++ b/test/mocha-test-helpers.js @@ -1,7 +1,7 @@ -const { isFrozenUA } = require('@ua-parser-js/helpers'); +const { isFrozenUA, unfreezeUA, UACHParser } = require('@ua-parser-js/helpers'); const assert = require('assert'); -describe('isFrozenUA', () => { +describe('isFrozenUA()', () => { it('Returns whether a user agent is frozen', () => { const regularWindowsUA = "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.1234.56 Safari/537.36"; @@ -32,4 +32,64 @@ describe('isFrozenUA', () => { assert.strictEqual(isFrozenUA(regularTabletUA), false); assert.strictEqual(isFrozenUA(freezedTabletUA), true); }); +}); + +const headers = { + 'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"', + 'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"', + 'sec-ch-ua-arch' : 'arm', + 'sec-ch-ua-bitness' : '64', + 'sec-ch-ua-mobile' : '?1', + 'sec-ch-ua-model' : 'Pixel 99', + 'sec-ch-ua-platform' : 'Linux', + 'sec-ch-ua-platform-version' : '13', + 'user-agent' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36' +}; + +describe('unfreezeUA()', () => { + it('returns an unfreezed user-agent using real data from client hints HTTP headers (sec-ch-ua)', async () => { + const unfreezed = await unfreezeUA(headers); + assert.strictEqual(unfreezed, 'Mozilla/5.0 (X11; Linux arm64) AppleWebKit/537.36 (KHTML, like Gecko) Chromium/93.0.1.2 Chrome/93.0.1.2 Safari/537.36'); + }); +}); + +describe('UACHParser()', () => { + it('parse client hints HTTP headers (sec-ch-ua) into a JavaScript object', () => { + assert.deepEqual(UACHParser(headers), { + "architecture": "arm", + "bitness": "64", + "brands": [ + { + "brand": "Chromium", + "version": "93" + }, + { + "brand": "Google Chrome", + "version": "93" + }, + { + "brand": " Not;A Brand", + "version": "99" + } + ], + "fullVersionList": [ + { + "brand": "Chromium", + "version": "93.0.1.2" + }, + { + "brand": "Google Chrome", + "version": "93.0.1.2" + }, + { + "brand": " Not;A Brand", + "version": "99.0.1.2" + } + ], + "mobile": true, + "model": "Pixel 99", + "platform": "Linux", + "platformVersion": "13" + }); + }); }); \ No newline at end of file