From 9192a6e93c55d4aa351f739cec1a47e0a951cb49 Mon Sep 17 00:00:00 2001 From: adatzer Date: Sat, 11 Nov 2023 13:09:11 +0200 Subject: [PATCH] Drop invalid client hints (close #1264) --- ...ue-1264_client_hints_2023-11-11-11-25.json | 10 ++ common/config/rush/pnpm-lock.yaml | 2 + common/config/rush/repo-state.json | 2 +- .../jest.config.js | 5 + .../browser-plugin-client-hints/package.json | 3 +- .../src/contexts.ts | 30 ---- .../browser-plugin-client-hints/src/index.ts | 95 +++++----- .../test/client-hints.test.ts | 170 ++++++++++++++++++ 8 files changed, 242 insertions(+), 75 deletions(-) create mode 100644 common/changes/@snowplow/browser-plugin-client-hints/issue-1264_client_hints_2023-11-11-11-25.json create mode 100644 plugins/browser-plugin-client-hints/jest.config.js create mode 100644 plugins/browser-plugin-client-hints/test/client-hints.test.ts diff --git a/common/changes/@snowplow/browser-plugin-client-hints/issue-1264_client_hints_2023-11-11-11-25.json b/common/changes/@snowplow/browser-plugin-client-hints/issue-1264_client_hints_2023-11-11-11-25.json new file mode 100644 index 000000000..e4a5135cf --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-client-hints/issue-1264_client_hints_2023-11-11-11-25.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-client-hints", + "comment": "Drop invalid client hints", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-client-hints" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 62165f52e..92caee30a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -213,6 +213,7 @@ importers: '@rollup/plugin-commonjs': ~21.0.2 '@rollup/plugin-node-resolve': ~13.1.3 '@snowplow/browser-tracker-core': workspace:* + '@snowplow/tracker-core': workspace:* '@types/jest': ~27.4.1 '@types/jsdom': ~16.2.14 '@typescript-eslint/eslint-plugin': ~5.15.0 @@ -237,6 +238,7 @@ importers: '@ampproject/rollup-plugin-closure-compiler': 0.27.0_rollup@2.70.1 '@rollup/plugin-commonjs': 21.0.2_rollup@2.70.1 '@rollup/plugin-node-resolve': 13.1.3_rollup@2.70.1 + '@snowplow/tracker-core': link:../../libraries/tracker-core '@types/jest': 27.4.1 '@types/jsdom': 16.2.14 '@typescript-eslint/eslint-plugin': 5.15.0_f2c49ce7d0e93ebcfdb4b7d25b131b28 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 77d8ad8e4..5b45dccaf 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "c272b447631dc0e0be0f52f2e7d1ed6aa6603939", + "pnpmShrinkwrapHash": "57af2bb401d45bb58b802eb1a04a7ee62b3aa732", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/plugins/browser-plugin-client-hints/jest.config.js b/plugins/browser-plugin-client-hints/jest.config.js new file mode 100644 index 000000000..bd3ea4e2a --- /dev/null +++ b/plugins/browser-plugin-client-hints/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-client-hints/package.json b/plugins/browser-plugin-client-hints/package.json index d74b53452..cff50908e 100644 --- a/plugins/browser-plugin-client-hints/package.json +++ b/plugins/browser-plugin-client-hints/package.json @@ -19,7 +19,7 @@ ], "scripts": { "build": "rollup -c --silent --failAfterWarnings", - "test": "" + "test": "jest" }, "dependencies": { "@snowplow/browser-tracker-core": "workspace:*", @@ -29,6 +29,7 @@ "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", "@rollup/plugin-commonjs": "~21.0.2", "@rollup/plugin-node-resolve": "~13.1.3", + "@snowplow/tracker-core": "workspace:*", "@types/jest": "~27.4.1", "@types/jsdom": "~16.2.14", "@typescript-eslint/eslint-plugin": "~5.15.0", diff --git a/plugins/browser-plugin-client-hints/src/contexts.ts b/plugins/browser-plugin-client-hints/src/contexts.ts index 068800ebb..5d55b5c0f 100644 --- a/plugins/browser-plugin-client-hints/src/contexts.ts +++ b/plugins/browser-plugin-client-hints/src/contexts.ts @@ -1,33 +1,3 @@ -/* - * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - /** * Values based upon the user agents characteristics, typically requested via the ACCEPT-CH HTTP header, as defined in the HTTP Client Hint specification */ diff --git a/plugins/browser-plugin-client-hints/src/index.ts b/plugins/browser-plugin-client-hints/src/index.ts index e8b4917ee..02205cc7b 100644 --- a/plugins/browser-plugin-client-hints/src/index.ts +++ b/plugins/browser-plugin-client-hints/src/index.ts @@ -1,33 +1,3 @@ -/* - * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - import { BrowserPlugin } from '@snowplow/browser-tracker-core'; import { HttpClientHints } from './contexts'; @@ -63,11 +33,44 @@ let uaClientHints: HttpClientHints; function forceArray(array: T[] | Record): T[] { if (Array.isArray(array)) return array; - return Object.keys(array).map((e) => { - return array[e]; + if (Object.prototype.toString.call(array) === '[object Object]') { + return Object.keys(array).map((e) => { + return array[e]; + }); + } + + return []; +} + +/** + * Returns the client-hints brands, ensuring no additional properties. + */ +function getBrands(brands: Array): Array { + return brands.map((b) => { + const { brand, version } = b; + return { brand, version }; }); } +/** + * Validates whether userAgentData is compliant to the client-hints interface. + * https://wicg.github.io/ua-client-hints/#interface + */ +function validClientHints(hints: HttpClientHints): boolean { + if (!hints || typeof hints.isMobile !== 'boolean' || !Array.isArray(hints.brands)) { + return false; + } + + if ( + hints.brands.length === 0 || + hints.brands.some((brand) => typeof brand.brand !== 'string' || typeof brand.version !== 'string') + ) { + return false; + } + + return true; +} + /** * Attaches Client Hint information where available * @param includeHighEntropy - Should high entropy values be included @@ -75,23 +78,29 @@ function forceArray(array: T[] | Record): T[] { export function ClientHintsPlugin(includeHighEntropy?: boolean): BrowserPlugin { const populateClientHints = () => { const navigatorAlias = navigator; + const uaData = navigatorAlias.userAgentData; - if (navigatorAlias.userAgentData) { - uaClientHints = { - isMobile: navigatorAlias.userAgentData.mobile, - brands: forceArray(navigatorAlias.userAgentData.brands), + if (uaData) { + let candidateHints: HttpClientHints; + candidateHints = { + isMobile: uaData.mobile, + brands: getBrands(forceArray(uaData.brands)), }; - if (includeHighEntropy && navigatorAlias.userAgentData.getHighEntropyValues) { - navigatorAlias.userAgentData + if (includeHighEntropy && uaData.getHighEntropyValues) { + uaData .getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion']) .then((res) => { - uaClientHints.architecture = res.architecture; - uaClientHints.model = res.model; - uaClientHints.platform = res.platform; - uaClientHints.uaFullVersion = res.uaFullVersion; - uaClientHints.platformVersion = res.platformVersion; + candidateHints.architecture = res.architecture; + candidateHints.model = res.model; + candidateHints.platform = res.platform; + candidateHints.uaFullVersion = res.uaFullVersion; + candidateHints.platformVersion = res.platformVersion; }); } + + if (validClientHints(candidateHints)) { + uaClientHints = candidateHints; + } } }; diff --git a/plugins/browser-plugin-client-hints/test/client-hints.test.ts b/plugins/browser-plugin-client-hints/test/client-hints.test.ts new file mode 100644 index 000000000..4da682972 --- /dev/null +++ b/plugins/browser-plugin-client-hints/test/client-hints.test.ts @@ -0,0 +1,170 @@ +import { buildLinkClick, trackerCore } from '@snowplow/tracker-core'; +import { BrowserTracker } from '@snowplow/browser-tracker-core'; +import { JSDOM } from 'jsdom'; +import { setImmediate } from 'timers'; +import { ClientHintsPlugin } from '../src'; + +declare var jsdom: JSDOM; + +describe('Client Hints plugin', () => { + const ctxSchema = 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0'; + const hintsSchema = 'iglu:org.ietf/http_client_hints/jsonschema/1-0-0'; + + it('Attaches no context on undefined userAgentData', (done) => { + const plugin = ClientHintsPlugin(false); + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json.length).toBe(0); + done(); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Attaches no context on invalid userAgentData(no mobile)', (done) => { + const sampleUserAgentData = { + brands: [ + { + brand: 'Opera GX', + version: '98', + }, + ], + } as any; + + Object.defineProperty(jsdom.window.navigator, 'userAgentData', { + value: sampleUserAgentData, + configurable: true, + }); + + const plugin = ClientHintsPlugin(false); + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json.length).toBe(0); + done(); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Attaches no context on invalid userAgentData(no brands)', (done) => { + const sampleUserAgentData = { + mobile: true, + } as any; + + Object.defineProperty(jsdom.window.navigator, 'userAgentData', { + value: sampleUserAgentData, + configurable: true, + }); + + const plugin = ClientHintsPlugin(false); + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json.length).toBe(0); + done(); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Attaches no context on invalid userAgentData(no valid brands)', (done) => { + const sampleUserAgentData = { + mobile: false, + brands: [ + { + brand: 'Opera GX', + version: 98, + }, + ], + } as any; + + Object.defineProperty(jsdom.window.navigator, 'userAgentData', { + value: sampleUserAgentData, + configurable: true, + }); + + const plugin = ClientHintsPlugin(true); + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json.length).toBe(0); + done(); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('Attaches context on valid userAgentData properties', async () => { + const highEntropyVals = { + platform: 'PhoneOS', + platformVersion: '10A', + architecture: 'arm', + model: 'X633GTM', + uaFullVersion: '73.32.AGX.5', + }; + const sampleUserAgentData = { + brands: [ + { + brand: 'Chromium', + version: '119', + }, + { + brand: 'Not?A_Brand', + version: '24', + }, + ], + mobile: false, + getHighEntropyValues: (_: any) => Promise.resolve(highEntropyVals), + }; + + const expected = { + schema: ctxSchema, + data: [ + { + schema: hintsSchema, + data: Object.assign( + { + isMobile: false, + brands: sampleUserAgentData.brands, + }, + highEntropyVals + ), + }, + ], + }; + + Object.defineProperty(jsdom.window.navigator, 'userAgentData', { + value: sampleUserAgentData, + configurable: true, + }); + + const plugin = ClientHintsPlugin(true); + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + + expect(json[0].json).toMatchObject(expected); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + const flushPromises = () => Promise.resolve(setImmediate); + await flushPromises(); + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); +});