/* Any copyright is dedicated to the Public Domain. https://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { PageExtractorParent } = ChromeUtils.importESModule( "resource://gre/actors/PageExtractorParent.sys.mjs" ); const { HttpServer } = ChromeUtils.importESModule( "resource://testing-common/httpd.sys.mjs" ); const SIMPLE_PAGE = ` t
stripped page body
`; /** * Spin up a one-shot HttpServer that records the headers of every request it * sees. Returns the served URL, the captured-request log, helpers to install * per-test side effects (cookies, observers), and a `cleanup` function the * caller must invoke when done. * * @param {string} [body] */ function setupHeadlessExtractionTest(body) { const server = new HttpServer(); const requests = []; const cleanupTasks = []; const channelInfo = { loadFlags: null, referrerPolicy: null }; server.registerPathHandler("/page.html", (request, response) => { const captured = {}; for (const name of ["Cookie", "Authorization"]) { captured[name] = request.hasHeader(name) ? request.getHeader(name) : null; } requests.push(captured); response.setHeader("Content-Type", "text/html; charset=utf-8"); response.setStatusLine(request.httpVersion, 200); response.write(body); }); server.start(-1); const { primaryHost, primaryPort } = server.identity; // eslint-disable-next-line @microsoft/sdl/no-insecure-url const url = `http://${primaryHost}:${primaryPort}/page.html`; const uri = Services.io.newURI(url); const channelObserver = { observe(subject, topic) { if (topic !== "http-on-modify-request") { return; } const channel = subject.QueryInterface(Ci.nsIHttpChannel); if (channel.URI.spec !== url) { return; } channelInfo.loadFlags = channel.loadFlags; channelInfo.referrerPolicy = channel.referrerInfo?.referrerPolicy ?? null; }, }; Services.obs.addObserver(channelObserver, "http-on-modify-request"); cleanupTasks.push(() => Services.obs.removeObserver(channelObserver, "http-on-modify-request") ); function addCookie(name, value) { const expiry = Date.now() + 60 * 60 * 1000; Services.cookies.add( uri.host, "/", name, value, false /* secure */, false /* httpOnly */, true /* session */, expiry, {}, Ci.nsICookie.SAMESITE_UNSET, Ci.nsICookie.SCHEME_HTTP ); cleanupTasks.push(() => Services.cookies.remove(uri.host, name, "/", {})); } async function cleanup() { while (cleanupTasks.length) { cleanupTasks.pop()(); } await new Promise(resolve => server.stop(resolve)); } return { url, requests, channelInfo, addCookie, cleanup }; } function assertAnonymousFetch(requests, channelInfo) { is(requests.length, 1, "server should see exactly one request"); const req = requests[0]; is(req.Cookie, null, `anonymous fetch should not send Cookie header.`); is( req.Authorization, null, `anonymous fetch should not send Authorization header.` ); Assert.notStrictEqual( channelInfo.loadFlags, null, "channel for anonymous fetch should be observed" ); ok( channelInfo.loadFlags & Ci.nsIRequest.LOAD_ANONYMOUS, `anonymous fetch should set LOAD_ANONYMOUS.` ); ok( channelInfo.loadFlags & Ci.nsIRequest.INHIBIT_CACHING, `anonymous fetch should set INHIBIT_CACHING.` ); ok( channelInfo.loadFlags & Ci.nsIRequest.INHIBIT_PERSISTENT_CACHING, `anonymous fetch should set INHIBIT_PERSISTENT_CACHING.` ); is( channelInfo.referrerPolicy, Ci.nsIReferrerInfo.NO_REFERRER, `anonymous fetch should use NO_REFERRER referrer policy.` ); } // Baseline: without `anonymousFetch`, cookies normally flow — the contrast // the next test deviates from. add_task(async function test_default_fetch_sends_cookies_baseline() { const { url, requests, addCookie, cleanup } = setupHeadlessExtractionTest(SIMPLE_PAGE); try { addCookie("test", "baseline"); await PageExtractorParent.getHeadlessExtractor({ urlString: url, callback: async pageExtractor => pageExtractor.getText(), }); is(requests.length, 1, "server should see exactly one request"); ok( requests[0].Cookie?.includes("test=baseline"), `default fetch should send Cookie header.` ); } finally { await cleanup(); } }); add_task(async function test_anonymous_fetch_channel_config() { const { url, requests, channelInfo, cleanup } = setupHeadlessExtractionTest(SIMPLE_PAGE); try { await PageExtractorParent.getHeadlessExtractor({ urlString: url, callback: async pageExtractor => pageExtractor.getText(), anonymousFetch: true, }); assertAnonymousFetch(requests, channelInfo); } finally { await cleanup(); } }); add_task(async function test_anonymous_fetch_rejects_non_loopback_http() { await Assert.rejects( PageExtractorParent.getHeadlessExtractor({ // eslint-disable-next-line @microsoft/sdl/no-insecure-url urlString: "http://example.com/page.html", callback: async pageExtractor => pageExtractor.getText(), anonymousFetch: true, }), /Only https: URLs are supported for anonymous fetches/, "anonymous fetch with non-loopback http: URL should be rejected" ); }); // Browser Context fields are set before navigation, but a remoteness change can re-fire // SetEmbedderElement and reset them — confirm they survive to the loaded Browsing Context. add_task(async function test_anonymous_fetch_browser_context_fields_persist() { const { url, requests, channelInfo, cleanup } = setupHeadlessExtractionTest(SIMPLE_PAGE); try { // From nsSandboxFlags.h. const SANDBOXED_AUXILIARY_NAVIGATION = 0x2; const SANDBOXED_TOPLEVEL_NAVIGATION = 0x4; const SANDBOXED_FORMS = 0x20; const SANDBOXED_POINTER_LOCK = 0x40; const SANDBOXED_AUTOMATIC_FEATURES = 0x100; const SANDBOXED_MODALS = 0x800; const SANDBOXED_ORIENTATION_LOCK = 0x2000; const SANDBOXED_PRESENTATION = 0x4000; const SANDBOXED_STORAGE_ACCESS = 0x8000; const SANDBOXED_DOWNLOADS = 0x10000; let browsingContextInfo = null; await PageExtractorParent.getHeadlessExtractor({ urlString: url, callback: async pageExtractor => { const browsingContext = pageExtractor.browsingContext; const browserEl = browsingContext.top.embedderElement; browsingContextInfo = { useTrackingProtection: browsingContext.useTrackingProtection, useGlobalHistory: browsingContext.useGlobalHistory, sandboxFlags: browsingContext.sandboxFlags, disableGlobalHistoryAttr: browserEl?.getAttribute("disableglobalhistory") ?? null, audioMuted: browserEl?.audioMuted ?? null, }; return pageExtractor.getText(); }, anonymousFetch: true, }); assertAnonymousFetch(requests, channelInfo); ok( browsingContextInfo, "should capture Browser Context fields during the actor callback" ); ok( browsingContextInfo.useTrackingProtection, `useTrackingProtection should still be set on loaded browsingContext, got: ${browsingContextInfo.useTrackingProtection}` ); is( browsingContextInfo.useGlobalHistory, false, `useGlobalHistory should be false on loaded browsingContext, got: ${browsingContextInfo.useGlobalHistory}` ); is( browsingContextInfo.disableGlobalHistoryAttr, "true", `disableglobalhistory attribute should still be set on the element, got: ${browsingContextInfo.disableGlobalHistoryAttr}` ); is( browsingContextInfo.audioMuted, true, ` element should be muted for anonymous fetch, got: ${browsingContextInfo.audioMuted}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_AUXILIARY_NAVIGATION, `SANDBOXED_AUXILIARY_NAVIGATION should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_TOPLEVEL_NAVIGATION, `SANDBOXED_TOPLEVEL_NAVIGATION should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_FORMS, `SANDBOXED_FORMS should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_POINTER_LOCK, `SANDBOXED_POINTER_LOCK should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_AUTOMATIC_FEATURES, `SANDBOXED_AUTOMATIC_FEATURES should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_MODALS, `SANDBOXED_MODALS should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_ORIENTATION_LOCK, `SANDBOXED_ORIENTATION_LOCK should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_PRESENTATION, `SANDBOXED_PRESENTATION should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_STORAGE_ACCESS, `SANDBOXED_STORAGE_ACCESS should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); ok( browsingContextInfo.sandboxFlags & SANDBOXED_DOWNLOADS, `SANDBOXED_DOWNLOADS should be set, got sandboxFlags=${browsingContextInfo.sandboxFlags}` ); } finally { await cleanup(); } }); add_task(async function test_anonymous_fetch_no_persisted_side_effects() { const { url, requests, channelInfo, cleanup } = setupHeadlessExtractionTest(SIMPLE_PAGE); try { await PageExtractorParent.getHeadlessExtractor({ urlString: url, callback: async pageExtractor => pageExtractor.getText(), anonymousFetch: true, }); assertAnonymousFetch(requests, channelInfo); const after = await PlacesUtils.history.fetch(url); is( after, null, `should be no Places entry after anonymous headless fetch.` ); const uri = Services.io.newURI(url); const partitions = [ ["default", Services.loadContextInfo.default], ["anonymous", Services.loadContextInfo.anonymous], ]; for (const [name, loadContextInfo] of partitions) { const storage = Services.cache2.diskCacheStorage(loadContextInfo); const hasCacheEntry = await new Promise(resolve => { storage.asyncOpenURI(uri, "", Ci.nsICacheStorage.OPEN_READONLY, { onCacheEntryAvailable(entry) { resolve(entry !== null); }, }); }); is( hasCacheEntry, false, `should be no cache entry in ${name} partition after fetch.` ); } } finally { await cleanup(); } });