Skip to content

Commit

Permalink
Drop invalid client hints (close #1264)
Browse files Browse the repository at this point in the history
  • Loading branch information
adatzer committed Nov 14, 2023
1 parent 4447673 commit c092008
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 75 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/browser-plugin-client-hints",
"comment": "Drop invalid client hints",
"type": "none"
}
],
"packageName": "@snowplow/browser-plugin-client-hints"
}
2 changes: 2 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion common/config/rush/repo-state.json
Original file line number Diff line number Diff line change
@@ -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"
}
5 changes: 5 additions & 0 deletions plugins/browser-plugin-client-hints/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
reporters: ['jest-standard-reporter'],
testEnvironment: 'jest-environment-jsdom-global',
};
3 changes: 2 additions & 1 deletion plugins/browser-plugin-client-hints/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
],
"scripts": {
"build": "rollup -c --silent --failAfterWarnings",
"test": ""
"test": "jest"
},
"dependencies": {
"@snowplow/browser-tracker-core": "workspace:*",
Expand All @@ -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",
Expand Down
30 changes: 0 additions & 30 deletions plugins/browser-plugin-client-hints/src/contexts.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
95 changes: 52 additions & 43 deletions plugins/browser-plugin-client-hints/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -63,35 +33,74 @@ let uaClientHints: HttpClientHints;
function forceArray<T>(array: T[] | Record<string, T>): 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<NavigatorUABrandVersion>): Array<NavigatorUABrandVersion> {
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
*/
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;
}
}
};

Expand Down
170 changes: 170 additions & 0 deletions plugins/browser-plugin-client-hints/test/client-hints.test.ts
Original file line number Diff line number Diff line change
@@ -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' }));
});
});

0 comments on commit c092008

Please sign in to comment.