Skip to content

Commit

Permalink
Correctly parse base64-encoded payloads
Browse files Browse the repository at this point in the history
  • Loading branch information
mscwilson committed Jan 7, 2025
1 parent d9c8257 commit f59f198
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 17 deletions.
36 changes: 21 additions & 15 deletions plugins/browser-plugin-webview/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { BrowserPlugin, BrowserTracker, Payload } from '@snowplow/browser-tracker-core';
import { hasMobileInterface, trackWebViewEvent } from '@snowplow/webview-tracker';
import { Logger, SelfDescribingEvent, SelfDescribingJson } from '@snowplow/tracker-core';
import { base64urldecode } from './utils';

const _trackers: Record<string, BrowserTracker> = {};

/**
* Forwards events to Snowplow mobile trackers running in a WebView.
*/
export function WebViewPlugin(): BrowserPlugin {
let LOG: Logger;

Expand All @@ -23,19 +27,19 @@ export function WebViewPlugin(): BrowserPlugin {
let atomicProperties = {
eventName: payload.e as string,
trackerVersion: payload.tv as string,
useragent: payload.ua as string,
url: payload.url as string,
title: payload.page as string,
referrer: payload.refr as string,
category: payload.se_ca as string,
action: payload.se_ac as string,
label: payload.se_la as string,
property: payload.se_pr as string,
value: payload.se_va as number,
minXOffset: payload.pp_mix as number,
maxXOffset: payload.pp_max as number,
minYOffset: payload.pp_miy as number,
maxYOffset: payload.pp_may as number,
useragent: (payload.ua as string) ?? window.navigator.userAgent,
url: payload.url as string | undefined,
title: payload.page as string | undefined,
referrer: payload.refr as string | undefined,
category: payload.se_ca as string | undefined,
action: payload.se_ac as string | undefined,
label: payload.se_la as string | undefined,
property: payload.se_pr as string | undefined,
value: payload.se_va ? parseFloat(payload.se_va as string) : undefined,
minXOffset: payload.pp_mix ? parseInt(payload.pp_mix as string) : undefined,
maxXOffset: payload.pp_max ? parseInt(payload.pp_max as string) : undefined,
minYOffset: payload.pp_miy ? parseInt(payload.pp_miy as string) : undefined,
maxYOffset: payload.pp_may ? parseInt(payload.pp_may as string) : undefined,
};
let event = getSelfDescribingEventData(payload);
let entities = getEntities(payload);
Expand All @@ -55,7 +59,8 @@ function getSelfDescribingEventData(payload: Payload): SelfDescribingEvent | und
if (payload.ue_pr) {
return JSON.parse(payload.ue_pr as string);
} else if (payload.ue_px) {
return JSON.parse(payload.ue_px as string);
let decoded = base64urldecode(payload.ue_px as string);
return JSON.parse(decoded);
}
return undefined;
}
Expand All @@ -64,7 +69,8 @@ function getEntities(payload: Payload): SelfDescribingJson[] {
if (payload.co) {
return JSON.parse(payload.co as string)['data'];
} else if (payload.cx) {
return JSON.parse(payload.cx as string)['data'];
let decoded = base64urldecode(payload.cx as string);
return JSON.parse(decoded)['data'];
}
return [];
}
102 changes: 102 additions & 0 deletions plugins/browser-plugin-webview/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Decodes base64-encoded strings.
* Copied from the core library.
*/
export function base64urldecode(data: string): string {
if (!data) {
return data;
}
const padding = 4 - (data.length % 4);
switch (padding) {
case 2:
data += '==';
break;
case 3:
data += '=';
break;
}
const b64Data = data.replace(/-/g, '+').replace(/_/g, '/');
return base64decode(b64Data);
}

const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';

function base64decode(encodedData: string): string {
// discuss at: http://locutus.io/php/base64_decode/
// original by: Tyler Akins (http://rumkin.com)
// improved by: Thunder.m
// improved by: Kevin van Zonneveld (http://kvz.io)
// improved by: Kevin van Zonneveld (http://kvz.io)
// input by: Aman Gupta
// input by: Brett Zamir (http://brett-zamir.me)
// bugfixed by: Onno Marsman (https://twitter.com/onnomarsman)
// bugfixed by: Pellentesque Malesuada
// bugfixed by: Kevin van Zonneveld (http://kvz.io)
// improved by: Indigo744
// example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA==')
// returns 1: 'Kevin van Zonneveld'
// example 2: base64_decode('YQ==')
// returns 2: 'a'
// example 3: base64_decode('4pyTIMOgIGxhIG1vZGU=')
// returns 3: '✓ à la mode'

// decodeUTF8string()
// Internal function to decode properly UTF8 string
// Adapted from Solution #1 at https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
const decodeUTF8string = function (str: string) {
// Going backwards: from bytestream, to percent-encoding, to original string.
return decodeURIComponent(
str
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
};

let o1,
o2,
o3,
h1,
h2,
h3,
h4,
bits,
i = 0,
ac = 0,
dec = '';
const tmpArr: Array<string> = [];

if (!encodedData) {
return encodedData;
}

encodedData += '';

do {
// unpack four hexets into three octets using index points in b64
h1 = b64.indexOf(encodedData.charAt(i++));
h2 = b64.indexOf(encodedData.charAt(i++));
h3 = b64.indexOf(encodedData.charAt(i++));
h4 = b64.indexOf(encodedData.charAt(i++));

bits = (h1 << 18) | (h2 << 12) | (h3 << 6) | h4;

o1 = (bits >> 16) & 0xff;
o2 = (bits >> 8) & 0xff;
o3 = bits & 0xff;

if (h3 === 64) {
tmpArr[ac++] = String.fromCharCode(o1);
} else if (h4 === 64) {
tmpArr[ac++] = String.fromCharCode(o1, o2);
} else {
tmpArr[ac++] = String.fromCharCode(o1, o2, o3);
}
} while (i < encodedData.length);

dec = tmpArr.join('');

return decodeUTF8string(dec.replace(/\0+$/, ''));
}
141 changes: 139 additions & 2 deletions plugins/browser-plugin-webview/test/webview.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { addTracker, SharedState, EventStore, BrowserTracker } from '@snowplow/browser-tracker-core';
import { newInMemoryEventStore } from '@snowplow/tracker-core';
import { newInMemoryEventStore, buildSelfDescribingEvent } from '@snowplow/tracker-core';
import { WebViewPlugin } from '../src';
import { hasMobileInterface } from '@snowplow/webview-tracker';
import { hasMobileInterface, trackWebViewEvent } from '@snowplow/webview-tracker';

jest.mock('@snowplow/webview-tracker');

describe('WebView plugin', () => {
afterEach(() => {
jest.clearAllMocks();
});

let idx = 1;
let eventStore: EventStore;
let tracker: BrowserTracker | null;

let mockHasMobileInterface = hasMobileInterface as jest.Mock<boolean>;
let mockTrackWebViewEvent = trackWebViewEvent as jest.Mock<void>;

it('Does not filter events if mobile interface not found', async () => {
mockHasMobileInterface.mockImplementation(() => {
Expand All @@ -29,6 +34,7 @@ describe('WebView plugin', () => {

let events = await eventStore.getAllPayloads();
expect(events).toHaveLength(1);
expect(mockTrackWebViewEvent).not.toHaveBeenCalled();
});

it('Filters out the events if a mobile interface is present', async () => {
Expand All @@ -45,8 +51,139 @@ describe('WebView plugin', () => {
});

tracker?.trackPageView();
tracker?.core.track(
buildSelfDescribingEvent({
event: {
schema: 'iglu:com.snowplowanalytics.snowplow.media/play_event/jsonschema/1-0-0',
data: {
a: 'b',
},
},
})
);

let events = await eventStore.getAllPayloads();
expect(events).toHaveLength(0);
expect(mockTrackWebViewEvent).toHaveBeenCalled();

let calls = mockTrackWebViewEvent.mock.calls;
expect(calls).toHaveLength(2);

// two tracker namespaces because this is the second test
expect(calls[0][1]).toHaveLength(2);
expect(calls[0][1][0]).toMatch(/^sp\d$/);

// page view event properties
expect(calls[0][0]).toMatchObject({
properties: {
eventName: 'pv',
trackerVersion: 'js-4.0.0',
useragent: expect.any(String),
url: expect.any(String),
},
context: [
{
schema: 'iglu:com.snowplowanalytics.snowplow/web_page/jsonschema/1-0-0',
data: {
id: expect.any(String),
},
},
],
});

// self-describing event properties
expect(calls[1][0]).toMatchObject({
properties: {
eventName: 'ue',
trackerVersion: 'js-4.0.0',
useragent: expect.any(String),
url: expect.any(String),
title: undefined,
referrer: undefined,
category: undefined,
action: undefined,
label: undefined,
property: undefined,
value: undefined,
minXOffset: undefined,
maxXOffset: undefined,
minYOffset: undefined,
maxYOffset: undefined,
},
event: {
schema: 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0',
data: {
schema: 'iglu:com.snowplowanalytics.snowplow.media/play_event/jsonschema/1-0-0',
data: { a: 'b' },
},
},
context: [
{
schema: 'iglu:com.snowplowanalytics.snowplow/web_page/jsonschema/1-0-0',
data: {
id: expect.any(String),
},
},
],
});
});

it('Decodes base64-encoded payloads', async () => {
mockHasMobileInterface.mockImplementation(() => {
return true;
});

eventStore = newInMemoryEventStore({});
const customFetch = async () => new Response(null, { status: 500 });
tracker = addTracker(`sp${idx++}`, `sp${idx++}`, 'js-4.0.0', '', new SharedState(), {
plugins: [WebViewPlugin()],
eventStore,
customFetch,
});

tracker?.core.setBase64Encoding(true);

tracker?.core.track(
buildSelfDescribingEvent({
event: {
schema: 'iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0',
data: {
hello: 'world',
},
},
})
);

let events = await eventStore.getAllPayloads();
expect(events).toHaveLength(0);
expect(mockTrackWebViewEvent).toHaveBeenCalled();

let calls = mockTrackWebViewEvent.mock.calls;
expect(calls).toHaveLength(1);

// decoded event properties
expect(calls[0][0]).toMatchObject({
properties: {
eventName: 'ue',
trackerVersion: 'js-4.0.0',
useragent: expect.any(String),
url: expect.any(String),
},
event: {
schema: 'iglu:com.snowplowanalytics.snowplow/unstruct_event/jsonschema/1-0-0',
data: {
schema: 'iglu:com.snowplowanalytics.snowplow/example/jsonschema/1-0-0',
data: { hello: 'world' },
},
},
context: [
{
schema: 'iglu:com.snowplowanalytics.snowplow/web_page/jsonschema/1-0-0',
data: {
id: expect.any(String),
},
},
],
});
});
});

0 comments on commit f59f198

Please sign in to comment.