Skip to content

Commit ab35c8f

Browse files
committed
fix: sanitize automatic analytics URLs and referrers
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 863ddf9 commit ab35c8f

3 files changed

Lines changed: 86 additions & 0 deletions

File tree

‎src/components/MarketplaceTracking.astro‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@ const sharedTrackingEnabled = import.meta.env.PUBLIC_SHARED_TRACKING_ENABLED ===
8080
buildMarketplacePageview,
8181
installMarketplaceActionTracking,
8282
marketplaceTrackingAllowed,
83+
marketplaceUmamiBeforeSend,
8384
} from '../tracking/marketplace-tracking.mjs';
8485

8586
declare global {
8687
interface Window {
8788
umami?: { track(name: string, data: Record<string, string>): void };
89+
marketplaceUmamiBeforeSend?: (
90+
type: string,
91+
payload: Record<string, unknown>,
92+
) => Record<string, unknown> | false;
8893
}
8994
}
9095

@@ -109,6 +114,8 @@ const sharedTrackingEnabled = import.meta.env.PUBLIC_SHARED_TRACKING_ENABLED ===
109114
script.dataset.domains = analyticsConfig.dataset.umamiDomain ?? 'marketplace.registrystack.org';
110115
script.dataset.excludeSearch = 'true';
111116
script.dataset.excludeHash = 'true';
117+
window.marketplaceUmamiBeforeSend = marketplaceUmamiBeforeSend;
118+
script.dataset.beforeSend = 'marketplaceUmamiBeforeSend';
112119
document.head.append(script);
113120
}
114121

‎src/tracking/marketplace-tracking.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,30 @@ export function marketplaceActionPayload(element, path) {
9797
return { action, source_path: sourcePath, ...(pack ? { pack } : {}) };
9898
}
9999

100+
function marketplaceReferrerOrigin(value) {
101+
if (typeof value !== 'string') return '';
102+
try {
103+
const url = new URL(value);
104+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return '';
105+
return url.origin;
106+
} catch {
107+
return '';
108+
}
109+
}
110+
111+
export function marketplaceUmamiBeforeSend(type, payload) {
112+
if (type !== 'event' || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
113+
return false;
114+
}
115+
const url = marketplaceCanonicalPath(payload.url);
116+
if (!url) return false;
117+
return {
118+
...payload,
119+
url,
120+
referrer: marketplaceReferrerOrigin(payload.referrer),
121+
};
122+
}
123+
100124
export function trackMarketplaceAction(element, { path, umami } = {}) {
101125
const payload = marketplaceActionPayload(element, path);
102126
if (!payload || typeof umami?.track !== 'function') return false;

‎tests/tracking.test.mjs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
marketplaceAnalyticsConfig,
1111
marketplaceCanonicalPath,
1212
marketplaceTrackingAllowed,
13+
marketplaceUmamiBeforeSend,
1314
trackMarketplaceAction,
1415
} from '../src/tracking/marketplace-tracking.mjs';
1516
import {
@@ -126,6 +127,52 @@ test('allows only reviewed aggregate actions and public pack identifiers', () =>
126127
assert.equal(trackMarketplaceAction(element, { path: '/', umami: undefined }), false);
127128
});
128129

130+
test('sanitizes Umami event location without changing SDK metadata or event data', () => {
131+
const payload = {
132+
website: '00000000-0000-4000-8000-000000000001',
133+
hostname: 'marketplace.registrystack.org',
134+
screen: '1440x900',
135+
language: 'en-US',
136+
title: 'Professional licensing',
137+
url: 'https://marketplace.registrystack.org/packs/professional-licensing/?email=person@example.org#request-form',
138+
referrer: 'https://marketplace.registrystack.org/contribute/?message=private#form',
139+
name: 'marketplace_action',
140+
data: { action: 'download_intent', source_path: '/packs/professional-licensing/' },
141+
};
142+
const sanitized = marketplaceUmamiBeforeSend('event', payload);
143+
assert.deepEqual(sanitized, {
144+
...payload,
145+
url: '/packs/professional-licensing/',
146+
referrer: 'https://marketplace.registrystack.org',
147+
});
148+
assert.notEqual(sanitized, payload);
149+
assert.match(payload.url, /person@example\.org/);
150+
151+
assert.equal(marketplaceUmamiBeforeSend('event', {
152+
...payload,
153+
url: '/privacy/?email=person@example.org',
154+
referrer: 'not a URL',
155+
}).referrer, '');
156+
assert.equal(marketplaceUmamiBeforeSend('event', {
157+
...payload,
158+
url: '/contribute/#private',
159+
referrer: 'http://partner.example/private?email=person@example.org',
160+
}).referrer, 'http://partner.example');
161+
});
162+
163+
test('rejects non-events and Umami payloads for invalid or private routes', () => {
164+
const payload = { url: '/', referrer: '' };
165+
for (const [type, candidate] of [
166+
['identify', payload],
167+
['pageview', payload],
168+
['event', null],
169+
['event', []],
170+
['event', {}],
171+
['event', { ...payload, url: '/search/person@example.org/' }],
172+
['event', { ...payload, url: 'https://other.example/privacy/' }],
173+
]) assert.equal(marketplaceUmamiBeforeSend(type, candidate), false);
174+
});
175+
129176
test('DNT and GPC keep optional Mautic tracking off', () => {
130177
assert.equal(trackingDeniedByBrowser({ navigator: { doNotTrack: '1' } }), true);
131178
assert.equal(trackingDeniedByBrowser({ navigator: { doNotTrack: 'yes' } }), true);
@@ -159,12 +206,20 @@ test('component separates aggregate events from consented pageviews and never re
159206
assert.match(component, /Tracking is disabled in local preview/);
160207
assert.match(component, /PUBLIC_SHARED_TRACKING_ENABLED === 'true'/);
161208
assert.match(component, /script\.dataset\.excludeHash = 'true'/);
209+
assert.match(component, /window\.marketplaceUmamiBeforeSend = marketplaceUmamiBeforeSend/);
210+
assert.match(component, /script\.dataset\.beforeSend = 'marketplaceUmamiBeforeSend'/);
211+
assert.ok(
212+
component.indexOf('window.marketplaceUmamiBeforeSend = marketplaceUmamiBeforeSend')
213+
< component.indexOf('document.head.append(script)'),
214+
'The sanitizer must be installed before the Umami SDK executes.',
215+
);
162216
assert.match(component, /if \(!canonicalRuntime \|\| !sharedTrackingEnabled\)/);
163217
assert.match(component, /Optional visit tracking is not enabled on this site/);
164218
assert.match(component, /website and documentation/i);
165219
assert.match(component, /Marketplace tracking starts only after you accept here/);
166220
assert.match(privacy, /activity\s+already recorded from those sites/);
167221
assert.doesNotMatch(component, /\.associate\(|deviceId|mautic_device_id|mtc_id/);
222+
assert.doesNotMatch(component, /\.identify\(/);
168223
assert.match(privacy, /fresh acceptance/i);
169224
assert.match(privacy, /Do Not Track and Global Privacy Control/i);
170225
assert.match(

0 commit comments

Comments
 (0)