Skip to content

Commit f5cf527

Browse files
Tsvetan StoychevTsvetan Stoychev
authored andcommitted
Fix consent opt-out injection race
1 parent d92ca89 commit f5cf527

4 files changed

Lines changed: 360 additions & 1 deletion

File tree

‎plugins/basicrum/assets/js/loaders/consent-boomerang-loader-v1-15.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,19 @@
220220

221221
// Consent wrapper opt-out callback: disable collection and remove cookies.
222222
mainWin.OPT_OUT_BASICRUM_LOADER_WRAPPER = function() {
223+
// Neutralize the inline configuration only after an opt-in has already
224+
// started injecting Boomerang on this page: a script that is still
225+
// downloading when consent is withdrawn must not initialize when it
226+
// arrives, because the bundle only calls BOOMR.init() while
227+
// basicRumBoomerangConfig is truthy. A deny that happens before any
228+
// opt-in must keep the configuration - fail-closed adapters report deny
229+
// before the visitor decides, and a later allow on the same page must
230+
// still initialize. After injection, re-granting requires a reload,
231+
// matching the documented consent-loader behavior.
232+
if (mainWin.BOOMR && mainWin.BOOMR.snippetExecuted) {
233+
mainWin.basicRumBoomerangConfig = null;
234+
}
235+
223236
if (mainWin.BOOMR) {
224237
if (typeof mainWin.BOOMR.disable === "function") {
225238
mainWin.BOOMR.disable();

‎plugins/basicrum/assets/js/loaders/consent-boomerang-loader-v1-15.min.js‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
const fs = require( 'node:fs' );
2+
const path = require( 'node:path' );
3+
const { test, expect } = require( '@playwright/test' );
4+
const {
5+
BOOMERANG_URL,
6+
CONSENT_LOADERS,
7+
createLoaderHarness,
8+
} = require( './fixtures/browser' );
9+
10+
const BEACON_URL = 'https://collector.basicrum.test/beacon';
11+
const REPOSITORY_ROOT = path.resolve( __dirname, '../..' );
12+
const REAL_BOOMERANG = fs.readFileSync(
13+
path.join(
14+
REPOSITORY_ROOT,
15+
'plugins/basicrum/assets/js/boomr/boomerang-1.815.60.cutting-edge.min.js'
16+
),
17+
'utf8'
18+
);
19+
20+
// How long to watch for a beacon after the real bundle has executed before
21+
// concluding that none will be sent.
22+
const BEACON_SILENCE_MS = 1500;
23+
24+
/**
25+
* Install routes that serve the REAL bundled Boomerang build through a gate
26+
* the test controls, and count beacons to the configured collector. Routes
27+
* registered after the harness route take precedence, so the harness never
28+
* blocks these requests.
29+
*
30+
* @param {import('@playwright/test').Page} page Playwright page.
31+
* @return {Promise<object>} Gate controls and request counters.
32+
*/
33+
async function installGatedRealBoomerang( page ) {
34+
let releaseBoomerang;
35+
const boomerangReleased = new Promise( ( resolve ) => {
36+
releaseBoomerang = resolve;
37+
} );
38+
let boomerangRequested;
39+
const boomerangRequestInFlight = new Promise( ( resolve ) => {
40+
boomerangRequested = resolve;
41+
} );
42+
let beaconRequests = 0;
43+
44+
await page.route( `${ BEACON_URL }*`, async ( route ) => {
45+
beaconRequests += 1;
46+
await route.fulfill( {
47+
status: 204,
48+
headers: { 'access-control-allow-origin': '*' },
49+
body: '',
50+
} );
51+
} );
52+
53+
await page.route( BOOMERANG_URL, async ( route ) => {
54+
boomerangRequested();
55+
await boomerangReleased;
56+
await route.fulfill( {
57+
status: 200,
58+
contentType: 'application/javascript; charset=utf-8',
59+
body: REAL_BOOMERANG,
60+
} );
61+
} );
62+
63+
return {
64+
boomerangRequestInFlight,
65+
releaseBoomerang,
66+
beaconRequests: () => beaconRequests,
67+
};
68+
}
69+
70+
/**
71+
* Mirror the inline configuration that Assets.php prints before the loader.
72+
*
73+
* @param {import('@playwright/test').Page} page Playwright page.
74+
*/
75+
async function injectInlineConfig( page ) {
76+
await page.evaluate( ( beaconUrl ) => {
77+
window.basicRumBoomerangConfig = {
78+
beacon_url: beaconUrl,
79+
instrument_xhr: false,
80+
Continuity: { enabled: true },
81+
secure_cookie: false,
82+
same_site_cookie: 'Strict',
83+
};
84+
}, BEACON_URL );
85+
}
86+
87+
/**
88+
* Wait until the real bundle has executed in the page.
89+
*
90+
* @param {import('@playwright/test').Page} page Playwright page.
91+
*/
92+
async function waitForRealBoomerang( page ) {
93+
await expect
94+
.poll( () => page.evaluate( () => window.BOOMR && window.BOOMR.version ) )
95+
.toBe( '1.815.60' );
96+
}
97+
98+
for ( const loaderPath of CONSENT_LOADERS ) {
99+
test.describe( `Real bundled Boomerang with ${ path.basename( loaderPath ) }`, () => {
100+
test( 'granted consent initializes the real bundle: cookie set and beacon sent', async ( { context, page } ) => {
101+
const harness = await createLoaderHarness( page );
102+
const gate = await installGatedRealBoomerang( page );
103+
104+
await harness.load( loaderPath );
105+
await injectInlineConfig( page );
106+
107+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
108+
await gate.boomerangRequestInFlight;
109+
gate.releaseBoomerang();
110+
await waitForRealBoomerang( page );
111+
112+
// Positive control proving the instrument: the real bundle, given
113+
// an intact config, initializes, sets its RT cookie, and beacons.
114+
await expect.poll( () => gate.beaconRequests(), { timeout: 10000 } ).toBeGreaterThan( 0 );
115+
const cookies = await context.cookies( harness.pageUrl );
116+
expect( cookies.some( ( cookie ) => 'RT' === cookie.name ) ).toBe( true );
117+
} );
118+
119+
test( 'withdrawal during the real download leaves the arrived bundle inert: no cookie, no beacon', async ( { context, page } ) => {
120+
const harness = await createLoaderHarness( page );
121+
const gate = await installGatedRealBoomerang( page );
122+
123+
await harness.load( loaderPath );
124+
await injectInlineConfig( page );
125+
126+
// 1. Consent granted: the loader starts the real download.
127+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
128+
await gate.boomerangRequestInFlight;
129+
130+
// 2. Consent withdrawn while the real bundle is still in transit.
131+
await page.evaluate( () => window.OPT_OUT_BASICRUM_LOADER_WRAPPER() );
132+
133+
// 3. The real bundle arrives and executes anyway.
134+
gate.releaseBoomerang();
135+
await waitForRealBoomerang( page );
136+
137+
// The bundle executed but must not have initialized: its config
138+
// bootstrap saw the neutralized config, so no cookie and no beacon.
139+
await page.waitForTimeout( BEACON_SILENCE_MS );
140+
expect( gate.beaconRequests() ).toBe( 0 );
141+
const cookies = await context.cookies( harness.pageUrl );
142+
expect( cookies.some( ( cookie ) => [ 'RT', 'BA' ].includes( cookie.name ) ) ).toBe( false );
143+
expect(
144+
await page.evaluate( () => window.basicRumInitConfig || null )
145+
).toBe( null );
146+
} );
147+
148+
test( 'a deny before any opt-in does not block a later allow with the real bundle', async ( { context, page } ) => {
149+
const harness = await createLoaderHarness( page );
150+
const gate = await installGatedRealBoomerang( page );
151+
152+
await harness.load( loaderPath );
153+
await injectInlineConfig( page );
154+
155+
// Fail-closed adapters report deny before the visitor decides.
156+
await page.evaluate( () => window.OPT_OUT_BASICRUM_LOADER_WRAPPER() );
157+
158+
// The visitor accepts moments later on the same page.
159+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
160+
await gate.boomerangRequestInFlight;
161+
gate.releaseBoomerang();
162+
await waitForRealBoomerang( page );
163+
164+
await expect.poll( () => gate.beaconRequests(), { timeout: 10000 } ).toBeGreaterThan( 0 );
165+
const cookies = await context.cookies( harness.pageUrl );
166+
expect( cookies.some( ( cookie ) => 'RT' === cookie.name ) ).toBe( true );
167+
} );
168+
} );
169+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
const path = require( 'node:path' );
2+
const { test, expect } = require( '@playwright/test' );
3+
const {
4+
BOOMERANG_URL,
5+
CONSENT_LOADERS,
6+
createLoaderHarness,
7+
} = require( './fixtures/browser' );
8+
9+
const BEACON_URL = 'https://collector.basicrum.test/beacon';
10+
11+
// Boomerang stub that mimics the bundled boomerang tail's init glue contract:
12+
// window.basicRumInitConfig=BOOMR.window.basicRumBoomerangConfig;
13+
// window.basicRumInitConfig&&BOOMR.init(window.basicRumInitConfig)
14+
// On execution it only "initializes" (records the init, recreates the RT
15+
// cookie, and fires a beacon) when the inline config object is still truthy,
16+
// exactly like the real bundle in assets/js/boomr/.
17+
const INIT_GLUE_BOOMERANG_STUB = `
18+
window.__boomrExecutionCount = ( window.__boomrExecutionCount || 0 ) + 1;
19+
window.__boomrInitCalls = window.__boomrInitCalls || 0;
20+
window.BOOMR = window.BOOMR || {};
21+
window.BOOMR.version = 'test';
22+
window.BOOMR.window = window;
23+
window.BOOMR.init = function( config ) {
24+
window.__boomrInitCalls += 1;
25+
document.cookie = 'RT=recreated-by-late-script; path=/; SameSite=Strict';
26+
fetch( '${ BEACON_URL }', { method: 'POST', body: 'beacon' } ).catch( function() {} );
27+
return window.BOOMR;
28+
};
29+
window.basicRumInitConfig = window.BOOMR.window.basicRumBoomerangConfig;
30+
window.basicRumInitConfig && window.BOOMR.init( window.basicRumInitConfig );
31+
`;
32+
33+
/**
34+
* Install routes that shadow the harness defaults for this spec: the beacon
35+
* endpoint is fulfilled (and counted) instead of blocked, and the Boomerang
36+
* script response is gated on an explicit promise so tests control exactly
37+
* when the "download" finishes. Routes registered after the harness route
38+
* take precedence, so the harness never sees these requests.
39+
*
40+
* @param {import('@playwright/test').Page} page Playwright page.
41+
* @return {Promise<object>} Gate controls and request counters.
42+
*/
43+
async function installGatedBoomerang( page ) {
44+
let releaseBoomerang;
45+
const boomerangReleased = new Promise( ( resolve ) => {
46+
releaseBoomerang = resolve;
47+
} );
48+
let boomerangRequested;
49+
const boomerangRequestInFlight = new Promise( ( resolve ) => {
50+
boomerangRequested = resolve;
51+
} );
52+
let boomerangRequests = 0;
53+
let beaconRequests = 0;
54+
55+
await page.route( BEACON_URL, async ( route ) => {
56+
beaconRequests += 1;
57+
await route.fulfill( {
58+
status: 204,
59+
headers: { 'access-control-allow-origin': '*' },
60+
body: '',
61+
} );
62+
} );
63+
64+
await page.route( BOOMERANG_URL, async ( route ) => {
65+
boomerangRequests += 1;
66+
boomerangRequested();
67+
await boomerangReleased;
68+
await route.fulfill( {
69+
status: 200,
70+
contentType: 'application/javascript; charset=utf-8',
71+
body: INIT_GLUE_BOOMERANG_STUB,
72+
} );
73+
} );
74+
75+
return {
76+
boomerangRequestInFlight,
77+
releaseBoomerang,
78+
boomerangRequests: () => boomerangRequests,
79+
beaconRequests: () => beaconRequests,
80+
};
81+
}
82+
83+
/**
84+
* Mirror the inline configuration that Assets.php prints before the loader.
85+
*
86+
* @param {import('@playwright/test').Page} page Playwright page.
87+
*/
88+
async function injectInlineConfig( page ) {
89+
await page.evaluate( ( beaconUrl ) => {
90+
window.basicRumBoomerangConfig = {
91+
beacon_url: beaconUrl,
92+
instrument_xhr: false,
93+
secure_cookie: false,
94+
same_site_cookie: 'Strict',
95+
};
96+
window.__boomrInitCalls = 0;
97+
}, BEACON_URL );
98+
}
99+
100+
for ( const loaderPath of CONSENT_LOADERS ) {
101+
test.describe( path.basename( loaderPath ), () => {
102+
test( 'initializes from the pending config when consent stays granted', async ( { context, page } ) => {
103+
const harness = await createLoaderHarness( page );
104+
const gate = await installGatedBoomerang( page );
105+
106+
await harness.load( loaderPath );
107+
await injectInlineConfig( page );
108+
109+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
110+
await gate.boomerangRequestInFlight;
111+
gate.releaseBoomerang();
112+
await harness.waitForExecutions( 1 );
113+
114+
// Sanity check for the stub's init-glue contract: with the config
115+
// still present the late script initializes, sets its cookie, and
116+
// beacons - proving the race assertions below are meaningful.
117+
expect( await page.evaluate( () => window.__boomrInitCalls ) ).toBe( 1 );
118+
const cookies = await context.cookies( harness.pageUrl );
119+
expect( cookies.some( ( cookie ) => 'RT' === cookie.name ) ).toBe( true );
120+
await expect.poll( () => gate.beaconRequests() ).toBe( 1 );
121+
expect( harness.unexpectedRequests() ).toEqual( [] );
122+
} );
123+
124+
test( 'a deny before any opt-in must not block a later allow on the same page', async ( { page } ) => {
125+
const harness = await createLoaderHarness( page );
126+
const gate = await installGatedBoomerang( page );
127+
128+
await harness.load( loaderPath );
129+
await injectInlineConfig( page );
130+
131+
// 1. The consent tool reports deny or no-decision first: every
132+
// packaged adapter fails closed and calls OPT_OUT before the
133+
// visitor has made a choice.
134+
await page.evaluate( () => window.OPT_OUT_BASICRUM_LOADER_WRAPPER() );
135+
136+
// 2. The visitor accepts moments later on the same page.
137+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
138+
await gate.boomerangRequestInFlight;
139+
gate.releaseBoomerang();
140+
await harness.waitForExecutions( 1 );
141+
142+
// The late grant must fully initialize monitoring: this is the
143+
// standard first-visit journey for every automatic adapter.
144+
expect( await page.evaluate( () => window.__boomrInitCalls ) ).toBe( 1 );
145+
await expect.poll( () => gate.beaconRequests() ).toBe( 1 );
146+
expect( harness.unexpectedRequests() ).toEqual( [] );
147+
} );
148+
149+
test( 'a Boomerang script that arrives after opt-out must not initialize, set cookies, or beacon', async ( { context, page } ) => {
150+
const harness = await createLoaderHarness( page );
151+
const gate = await installGatedBoomerang( page );
152+
153+
await harness.load( loaderPath );
154+
await injectInlineConfig( page );
155+
156+
// 1. Consent granted: the loader starts the Boomerang download.
157+
await page.evaluate( () => window.OPT_IN_BASICRUM_LOADER_WRAPPER() );
158+
await gate.boomerangRequestInFlight;
159+
160+
// 2. Consent withdrawn while boomerang.js is still downloading.
161+
await page.evaluate( () => window.OPT_OUT_BASICRUM_LOADER_WRAPPER() );
162+
163+
// 3. Only now does the script "download" finish and execute.
164+
gate.releaseBoomerang();
165+
await harness.waitForExecutions( 1 );
166+
167+
// Desired behavior: after the user opted out, the late-arriving
168+
// script must not initialize, recreate cookies, or send beacons.
169+
expect( await page.evaluate( () => window.__boomrInitCalls ) ).toBe( 0 );
170+
const cookies = await context.cookies( harness.pageUrl );
171+
expect( cookies.some( ( cookie ) => [ 'RT', 'BA' ].includes( cookie.name ) ) ).toBe( false );
172+
expect( gate.beaconRequests() ).toBe( 0 );
173+
expect( gate.boomerangRequests() ).toBe( 1 );
174+
expect( harness.unexpectedRequests() ).toEqual( [] );
175+
} );
176+
} );
177+
}

0 commit comments

Comments
 (0)