Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
/* Any copyright is dedicated to the Public Domain.
"use strict";
// triggering principal, so a cross-site frame which navigated the top level named
// itself and RecordStatefulBounces exempted it. It is now the site the context is
// leaving, so these cover each way a navigation can be initiated.
//
// The bounces deliberately return to the site they started from, so finalHost
// cannot mask a missing fix.
let bounceTrackingProtection = Cc[
"@mozilla.org/bounce-tracking-protection;1"
].getService(Ci.nsIBounceTrackingProtection);
registerCleanupFunction(() => {
// Clear the state after all the tasks.
bounceTrackingProtection.clearAll();
});
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
[
"privacy.bounceTrackingProtection.mode",
Ci.nsIBounceTrackingProtection.MODE_ENABLED,
],
// browser.toml's default 1h grace period would make these tests pass for
// the wrong reason.
["privacy.bounceTrackingProtection.bounceTrackingGracePeriodSec", 0],
],
});
});
function getStartURL(origin) {
return getBaseUrl(origin) + "file_start.html";
}
/**
* Destination used to end an extended navigation with a user activated
* navigation.
*
* Deliberately same-site with initialHost. Ending elsewhere starts a second
* possibly before finalHost is set, classifying the site we ended on.
*/
function getEndURL() {
return new URL(getStartURL(ORIGIN_A) + "?end");
}
/**
* Runs a bounce which is initiated from a frame and returns to the top level
* site, then ends the extended navigation.
*
* @param {object} options - Test options.
* @param {string} options.frameOrigin - Origin to load the initiating frame from.
* @param {('server'|'client')} options.bounceType - Redirect type for the bounce.
* @param {string} [options.bounceOrigin=ORIGIN_TRACKER] - Origin which performs
* the bounce.
* @param {Function} options.trigger - Called with the frame's BrowsingContext and
* the bounce URL to start the top level navigation.
* @returns {Promise} Resolves once the extended navigation has ended.
*/
async function runFrameInitiatedBounce({
frameOrigin,
bounceType,
bounceOrigin = ORIGIN_TRACKER,
trigger,
}) {
bounceTrackingProtection.clearAll();
Assert.equal(
bounceTrackingProtection.testGetBounceTrackerCandidateHosts({}).length,
0,
"No bounce tracker hosts initially."
);
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(frameOrigin)
);
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({ bounceType, bounceOrigin, targetURL });
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await trigger(frameBC, bounceURL);
await targetLoaded;
// End the extended navigation with a user activated top level navigation,
// which triggers RecordStatefulBounces. Waiting for count 0 resolves on the
// run whatever it classified, so the assertions afterwards see a settled
// state and a missing fix fails on the assertion rather than on a timeout.
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
}
// Callers must have awaited waitForRecordBounces first, so this only reads a
// settled state.
function assertTrackerClassified() {
Assert.ok(
bounceTrackingProtection
.testGetBounceTrackerCandidateHosts({})
.some(entry => entry.siteHost == SITE_TRACKER),
`${SITE_TRACKER} should be classified as a bounce tracker.`
);
}
function assertNothingClassified() {
Assert.deepEqual(
bounceTrackingProtection
.testGetBounceTrackerCandidateHosts({})
.map(entry => entry.siteHost),
[],
"Should not classify any bounce trackers."
);
}
// A cross-site frame which navigates the top level via <a target="_top"> must
// not exempt itself. This is the reported scenario.
add_task(async function test_frame_link_top_target_server_bounce() {
await runFrameInitiatedBounce({
frameOrigin: ORIGIN_TRACKER,
bounceType: "server",
trigger: (frameBC, bounceURL) =>
navigateLinkClick(frameBC, bounceURL, { linkTarget: "_top" }),
});
assertTrackerClassified();
let purgedHosts = await bounceTrackingProtection.testRunPurgeBounceTrackers();
Assert.ok(
purgedHosts.includes(SITE_TRACKER),
`Should purge ${SITE_TRACKER}. Got: ${JSON.stringify(purgedHosts)}`
);
// Assert the mechanism, not just the outcome. initialHost is SITE_TRACKER on
// an unfixed tree, which is what let the tracker skip classification.
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(
purgeLog.length,
1,
`Should have one tracker in purge log. Got: ${JSON.stringify(
purgeLog.map(entry => ({
siteHost: entry.siteHost,
initialHost: entry.bounceTrackingRecord?.initialHost,
finalHost: entry.bounceTrackingRecord?.finalHost,
bounceHosts: entry.bounceTrackingRecord?.bounceHosts,
}))
)}`
);
let { bounceTrackingRecord } = purgeLog[0];
Assert.equal(
bounceTrackingRecord.initialHost,
SITE_A,
"initialHost should be the top level site, not the initiating frame's site."
);
Assert.equal(
bounceTrackingRecord.finalHost,
SITE_A,
"finalHost should be the top level site."
);
Assert.ok(
bounceTrackingRecord.bounceHosts.includes(SITE_TRACKER),
`Bounce hosts should include '${SITE_TRACKER}'.`
);
// Pins the other half of the escape hatch: the click happened in the tracker's
// frame, but activation is attributed to the top level site, which is why
// fixing initialHost alone is sufficient.
let userActivationHosts = bounceTrackingProtection
.testGetUserActivationHosts({})
.map(entry => entry.siteHost);
Assert.ok(
userActivationHosts.includes(SITE_A),
`User activation should be recorded for ${SITE_A}.`
);
Assert.ok(
!userActivationHosts.includes(SITE_TRACKER),
`User activation should not be recorded for ${SITE_TRACKER}.`
);
});
// Same, but a client side bounce, which reaches the bounce set through
// OnStartNavigation's AddBounceHost path as well as the redirect chain.
add_task(async function test_frame_link_top_target_client_bounce() {
await runFrameInitiatedBounce({
frameOrigin: ORIGIN_TRACKER,
bounceType: "client",
trigger: (frameBC, bounceURL) =>
navigateLinkClick(frameBC, bounceURL, { linkTarget: "_top" }),
});
assertTrackerClassified();
});
// window.top.location assignment goes through BrowsingContext::Navigate rather
// than the link click path.
add_task(async function test_frame_top_location_server_bounce() {
await runFrameInitiatedBounce({
frameOrigin: ORIGIN_TRACKER,
bounceType: "server",
trigger: (frameBC, bounceURL) => navigateTopFromFrame(frameBC, bounceURL),
});
assertTrackerClassified();
});
// The initiating frame going away while the navigation is in flight must not give
// it its own exemption back. Which code path supplies the initial host depends on
// whether the frame's WindowGlobalParent is still resolvable when the parent
// starts the load, so this asserts the outcome rather than the path: either way
// the initial host has to be the top level site and the tracker has to be
// classified.
add_task(async function test_frame_removed_after_triggering() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateLinkClick(frameBC, bounceURL, { linkTarget: "_top" });
// Drop the frame straight after it started the navigation.
await SpecialPowers.spawn(browser, [], () => {
content.document.querySelector("iframe").remove();
});
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"initialHost should still be the top level site with the frame gone."
);
});
// In every other case here the initiator sits inside the context being navigated,
// so "the site being left" and "the initiator's site" are the same answer. A
// navigation driven from another tab is the only shape where they differ, which
// makes this the test that pins down which of the two we use.
add_task(async function test_cross_tab_initiator_uses_navigated_context() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
// Open a second tab from A which loads a real document, so it has a committed
// top level site of its own, and keep a reference to it in A.
let popupURL = new URL(getStartURL(ORIGIN_B));
let popupPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
popupURL.href,
true
);
await SpecialPowers.spawn(browser, [popupURL.href], url => {
SpecialPowers.wrap(content.document).notifyUserGestureActivation();
content.document.userInteractionForTesting();
let script = content.document.createElement("script");
script.textContent = `window.__popup = window.open(${JSON.stringify(
url
)}, "b");`;
content.document.body.appendChild(script);
});
let popupTab = await popupPromise;
let popupBrowser = popupTab.linkedBrowser;
let targetURL = new URL(getStartURL(ORIGIN_B));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
// Navigate the second tab from the first tab's document. The gesture is what
// makes this start a new extended navigation rather than extend the popup's
// existing one.
let bounced = BrowserTestUtils.browserLoaded(
popupBrowser,
false,
targetURL.href
);
await SpecialPowers.spawn(browser, [bounceURL.href], url => {
SpecialPowers.wrap(content.document).notifyUserGestureActivation();
content.document.userInteractionForTesting();
let script = content.document.createElement("script");
script.textContent = `window.__popup.location.href = ${JSON.stringify(
url
)};`;
content.document.body.appendChild(script);
});
await bounced;
// End on a site same-site with the expected initial host, see getEndURL.
let endURL = new URL(getStartURL(ORIGIN_B) + "?end");
let endLoaded = BrowserTestUtils.browserLoaded(
popupBrowser,
false,
endURL.href
);
let recordedBounces = waitForRecordBounces(popupBrowser, 0);
await navigateLinkClick(popupBrowser, endURL);
await endLoaded;
await recordedBounces;
await BrowserTestUtils.removeTab(popupTab);
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_B,
"initialHost should be the site the navigated tab is leaving."
);
});
// A navigation started by the browser rather than by content still leaves a site,
// so the departing site is the initial host. The derivation deliberately does not
// depend on the initiator, which a browser initiated navigation does not have.
add_task(async function test_browser_initiated_load_uses_departing_site() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateSystemPrincipalLoad(browser, bounceURL);
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"A browser initiated navigation should use the site being left."
);
});
// A cross-site frame can framebust without transient activation when it is allowed
// to by other means, here sandbox="allow-top-navigation". The gesture is what
// normally ends the extended navigation and starts a new one, so without it the
// derivation is only reached when no record is live, which is why this clears
// between the frame load and the navigation.
add_task(async function test_sandboxed_frame_gesture_free_top_navigation() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER),
{ sandbox: "allow-scripts allow-same-origin allow-top-navigation" }
);
// Drop the record created by the load of A above, so the gesture-free
// navigation creates one and derives an initial host rather than only adding
// a bounce candidate to the existing record.
bounceTrackingProtection.clearAll();
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateTopFromFrame(frameBC, bounceURL, { withGesture: false });
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"initialHost should be the top level site, not the framebusting frame's."
);
});
// A frame nested inside another cross-site frame must resolve all the way to the
// top level document.
add_task(async function test_nested_frame_link_top_target() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let outerFrameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_B)
);
let innerFrameBC = await insertIframeAndWaitForLoad(
outerFrameBC,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateLinkClick(innerFrameBC, bounceURL, { linkTarget: "_top" });
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertTrackerClassified();
// Pins that resolution walked past the intermediate ORIGIN_B frame rather than
// stopping at it.
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"initialHost should be the top level site, not the intermediate frame's."
);
});
// Negative control: a same-site frame bouncing the top level through its own
// site must stay exempt, because initialHost and the bouncing site are the same
// site. Guards against over-classification.
add_task(async function test_same_site_frame_not_classified() {
await runFrameInitiatedBounce({
frameOrigin: ORIGIN_A,
bounceType: "server",
bounceOrigin: ORIGIN_A,
trigger: (frameBC, bounceURL) =>
navigateLinkClick(frameBC, bounceURL, { linkTarget: "_top" }),
});
assertNothingClassified();
});
// Negative control: the frame busts the top level straight to a third site
// without hopping through its own origin, so it is not in the bounce set at all.
add_task(async function test_frame_navigates_to_third_site_not_classified() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_B));
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateLinkClick(frameBC, targetURL, { linkTarget: "_top" });
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertNothingClassified();
});
// Negative control: the frame navigates the top level to its own site and the
// user stays there, so the tracker is the extended navigation's finalHost and
// remains exempt.
add_task(async function test_frame_navigates_to_own_site_user_stays() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_TRACKER));
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateLinkClick(frameBC, targetURL, { linkTarget: "_top" });
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertNothingClassified();
});
// A frame cannot name itself by injecting the navigation into an empty popup it
// opened. The popup's initial about:blank inherits the frame's principal, so
// anything reading the popup's own document would resolve to the tracker.
add_task(async function test_frame_cannot_self_nominate_via_popup_write() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let popupPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
targetURL.href,
true
);
await SpecialPowers.spawn(frameBC, [bounceURL.href], url => {
SpecialPowers.wrap(content.document).notifyUserGestureActivation();
content.document.userInteractionForTesting();
let script = content.document.createElement("script");
script.textContent = `
let w = window.open();
w.document.write('<script>location.href = ${JSON.stringify(url)};<\\/script>');
`;
content.document.body.appendChild(script);
});
let popupTab = await popupPromise;
let endURL = new URL(getStartURL(ORIGIN_A) + "?end");
let endLoaded = BrowserTestUtils.browserLoaded(
popupTab.linkedBrowser,
false,
endURL.href
);
let recordedBounces = waitForRecordBounces(popupTab.linkedBrowser, 0);
await navigateLinkClick(popupTab.linkedBrowser, endURL);
await endLoaded;
await recordedBounces;
await BrowserTestUtils.removeTab(popupTab);
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"initialHost should be the embedder, not the frame which wrote the popup."
);
});
// Severing the opener must not buy an exemption. A popup can clear window.opener
// before navigating, which leaves no document and no opener to attribute, so the
// initial host stays empty and exempts nothing.
add_task(async function test_frame_cannot_self_nominate_by_severing_opener() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let frameBC = await insertIframeAndWaitForLoad(
browser,
getStartURL(ORIGIN_TRACKER)
);
let targetURL = new URL(getStartURL(ORIGIN_A));
let bounceURL = getBounceURL({
bounceType: "server",
bounceOrigin: ORIGIN_TRACKER,
targetURL,
});
let popupPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
targetURL.href,
true
);
await SpecialPowers.spawn(frameBC, [bounceURL.href], url => {
SpecialPowers.wrap(content.document).notifyUserGestureActivation();
content.document.userInteractionForTesting();
let script = content.document.createElement("script");
script.textContent = `
let w = window.open();
w.document.write('<script>window.opener = null; location.href = ${JSON.stringify(
url
)};<\\/script>');
`;
content.document.body.appendChild(script);
});
let popupTab = await popupPromise;
let endURL = new URL(getStartURL(ORIGIN_A) + "?end");
let endLoaded = BrowserTestUtils.browserLoaded(
popupTab.linkedBrowser,
false,
endURL.href
);
let recordedBounces = waitForRecordBounces(popupTab.linkedBrowser, 0);
await navigateLinkClick(popupTab.linkedBrowser, endURL);
await endLoaded;
await recordedBounces;
await BrowserTestUtils.removeTab(popupTab);
});
// The tracker must still be classified: an empty initial host exempts nobody.
assertTrackerClassified();
});
// Regression guard: a top level link click keeps attributing initialHost to the
// page which was navigated away from, so a bounce from it is still classified
// and the top level site itself is still exempt.
add_task(async function test_top_level_link_click_unchanged() {
bounceTrackingProtection.clearAll();
await BrowserTestUtils.withNewTab(getStartURL(ORIGIN_A), async browser => {
let targetURL = new URL(getStartURL(ORIGIN_B));
let bounceURL = getBounceURL({ bounceType: "server", targetURL });
let targetLoaded = BrowserTestUtils.browserLoaded(
browser,
false,
targetURL.href
);
await navigateLinkClick(browser, bounceURL);
await targetLoaded;
let recordedBounces = waitForRecordBounces(browser, 0);
await navigateLinkClick(browser, getEndURL());
await recordedBounces;
});
assertTrackerClassified();
await bounceTrackingProtection.testRunPurgeBounceTrackers();
let purgeLog = bounceTrackingProtection.testGetRecentlyPurgedTrackers({});
Assert.equal(purgeLog.length, 1, "Should have one tracker in purge log.");
Assert.equal(
purgeLog[0].bounceTrackingRecord.initialHost,
SITE_A,
"initialHost for a top level initiated navigation is unchanged."
);
});