tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

navigate.sys.mjs (14664B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
      9  EventDispatcher:
     10    "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
     11  getTimeoutMultiplier: "chrome://remote/content/shared/AppInfo.sys.mjs",
     12  Log: "chrome://remote/content/shared/Log.sys.mjs",
     13  MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs",
     14  PageLoadStrategy:
     15    "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
     16  ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs",
     17  TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs",
     18  truncate: "chrome://remote/content/shared/Format.sys.mjs",
     19 });
     20 
     21 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
     22  lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
     23 );
     24 
     25 // Timeout used to wait for the page to be unloaded.
     26 const TIMEOUT_UNLOAD_EVENT = 5000;
     27 
     28 /** @namespace */
     29 export const navigate = {};
     30 
     31 /**
     32 * Checks the value of readyState for the current page
     33 * load activity, and resolves the command if the load
     34 * has been finished. It also takes care of the selected
     35 * page load strategy.
     36 *
     37 * @param {PageLoadStrategy} pageLoadStrategy
     38 *     Strategy when navigation is considered as finished.
     39 * @param {object} eventData
     40 * @param {string} eventData.documentURI
     41 *     Current document URI of the document.
     42 * @param {string} eventData.readyState
     43 *     Current ready state of the document.
     44 *
     45 * @returns {boolean}
     46 *     True if the page load has been finished.
     47 */
     48 function checkReadyState(pageLoadStrategy, eventData = {}) {
     49  const { documentURI, readyState, isUncommittedInitialDocument } = eventData;
     50 
     51  const result = { error: null, finished: false };
     52 
     53  switch (readyState) {
     54    case "interactive":
     55      if (documentURI.startsWith("about:certerror")) {
     56        result.error = new lazy.error.InsecureCertificateError();
     57        result.finished = true;
     58      } else if (/about:.*(error)\?/.exec(documentURI)) {
     59        result.error = new lazy.error.UnknownError(
     60          `Reached error page: ${documentURI}`
     61        );
     62        result.finished = true;
     63 
     64        // Return early with a page load strategy of eager, and also
     65        // special-case about:blocked pages which should be treated as
     66        // non-error pages but do not raise a pageshow event. about:blank
     67        // is also treaded specifically here, because it gets temporary
     68        // loaded for new content processes, and we only want to rely on
     69        // complete loads for it.
     70      } else if (
     71        (pageLoadStrategy === lazy.PageLoadStrategy.Eager &&
     72          documentURI != "about:blank") ||
     73        /about:blocked\?/.exec(documentURI)
     74      ) {
     75        result.finished = true;
     76      }
     77      break;
     78 
     79    case "complete":
     80      if (!isUncommittedInitialDocument) {
     81        result.finished = true;
     82      }
     83      break;
     84  }
     85 
     86  return result;
     87 }
     88 
     89 /**
     90 * Determines if we expect to get a DOM load event (DOMContentLoaded)
     91 * on navigating to the <code>future</code> URL.
     92 *
     93 * @param {URL} current
     94 *     URL the browser is currently visiting.
     95 * @param {object} options
     96 * @param {BrowsingContext=} options.browsingContext
     97 *     The current browsing context. Needed for targets of _parent and _top.
     98 * @param {URL=} options.future
     99 *     Destination URL, if known.
    100 * @param {target=} options.target
    101 *     Link target, if known.
    102 *
    103 * @returns {boolean}
    104 *     Full page load would be expected if future is followed.
    105 *
    106 * @throws TypeError
    107 *     If <code>current</code> is not defined, or any of
    108 *     <code>current</code> or <code>future</code>  are invalid URLs.
    109 */
    110 navigate.isLoadEventExpected = function (current, options = {}) {
    111  const { browsingContext, future, target } = options;
    112 
    113  if (typeof current == "undefined") {
    114    throw new TypeError("Expected at least one URL");
    115  }
    116 
    117  if (["_parent", "_top"].includes(target) && !browsingContext) {
    118    throw new TypeError(
    119      "Expected browsingContext when target is _parent or _top"
    120    );
    121  }
    122 
    123  // Don't wait if the navigation happens in a different browsing context
    124  if (
    125    target === "_blank" ||
    126    (target === "_parent" && browsingContext.parent) ||
    127    (target === "_top" && browsingContext.top != browsingContext)
    128  ) {
    129    return false;
    130  }
    131 
    132  // Assume we will go somewhere exciting
    133  if (typeof future == "undefined") {
    134    return true;
    135  }
    136 
    137  // Assume javascript:<whatever> will modify the current document
    138  // but this is not an entirely safe assumption to make,
    139  // considering it could be used to set window.location
    140  if (future.protocol == "javascript:") {
    141    return false;
    142  }
    143 
    144  // If hashes are present and identical
    145  if (
    146    current.href.includes("#") &&
    147    future.href.includes("#") &&
    148    current.hash === future.hash
    149  ) {
    150    return false;
    151  }
    152 
    153  return true;
    154 };
    155 
    156 /**
    157 * Load the given URL in the specified browsing context.
    158 *
    159 * @param {CanonicalBrowsingContext} browsingContext
    160 *     Browsing context to load the URL into.
    161 * @param {string} url
    162 *     URL to navigate to.
    163 */
    164 navigate.navigateTo = async function (browsingContext, url) {
    165  const opts = {
    166    loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK,
    167    // Fake user activation.
    168    hasValidUserGestureActivation: true,
    169    // Prevent HTTPS-First upgrades.
    170    schemelessInput: Ci.nsILoadInfo.SchemelessInputTypeSchemeful,
    171    triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    172  };
    173  browsingContext.fixupAndLoadURIString(url, opts);
    174 };
    175 
    176 /**
    177 * Reload the page.
    178 *
    179 * @param {CanonicalBrowsingContext} browsingContext
    180 *     Browsing context to refresh.
    181 */
    182 navigate.refresh = async function (browsingContext) {
    183  const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
    184  browsingContext.reload(flags);
    185 };
    186 
    187 /**
    188 * Execute a callback and wait for a possible navigation to complete
    189 *
    190 * @param {GeckoDriver} driver
    191 *     Reference to driver instance.
    192 * @param {Function} callback
    193 *     Callback to execute that might trigger a navigation.
    194 * @param {object} options
    195 * @param {BrowsingContext=} options.browsingContext
    196 *     Browsing context to observe. Defaults to the current browsing context.
    197 * @param {boolean=} options.loadEventExpected
    198 *     If false, return immediately and don't wait for
    199 *     the navigation to be completed. Defaults to true.
    200 * @param {boolean=} options.requireBeforeUnload
    201 *     If false and no beforeunload event is fired, abort waiting
    202 *     for the navigation. Defaults to true.
    203 */
    204 navigate.waitForNavigationCompleted = async function waitForNavigationCompleted(
    205  driver,
    206  callback,
    207  options = {}
    208 ) {
    209  const {
    210    browsingContextFn = driver.getBrowsingContext.bind(driver),
    211    loadEventExpected = true,
    212    requireBeforeUnload = true,
    213  } = options;
    214 
    215  const browsingContext = browsingContextFn();
    216  const chromeWindow = browsingContext.topChromeWindow;
    217  const pageLoadStrategy = driver.currentSession.pageLoadStrategy;
    218 
    219  // Return immediately if no load event is expected
    220  if (!loadEventExpected) {
    221    await callback();
    222    return Promise.resolve();
    223  }
    224 
    225  // When not waiting for page load events, do not return until the navigation has actually started.
    226  if (pageLoadStrategy === lazy.PageLoadStrategy.None) {
    227    const listener = new lazy.ProgressListener(browsingContext.webProgress, {
    228      resolveWhenStarted: true,
    229      waitForExplicitStart: true,
    230    });
    231    const navigated = listener.start();
    232    navigated.finally(() => {
    233      if (listener.isStarted) {
    234        listener.stop();
    235      }
    236      listener.destroy();
    237    });
    238 
    239    await callback();
    240    await navigated;
    241 
    242    return Promise.resolve();
    243  }
    244 
    245  let rejectNavigation;
    246  let resolveNavigation;
    247 
    248  let browsingContextChanged = false;
    249  let seenBeforeUnload = false;
    250  let seenUnload = false;
    251 
    252  let unloadTimer;
    253 
    254  const checkDone = ({ finished, error }) => {
    255    if (finished) {
    256      if (error) {
    257        rejectNavigation(error);
    258      } else {
    259        resolveNavigation();
    260      }
    261    }
    262  };
    263 
    264  const onPromptClosed = (_, data) => {
    265    if (data.detail.promptType === "beforeunload" && !data.detail.accepted) {
    266      // If a beforeunload prompt is dismissed there will be no navigation.
    267      lazy.logger.trace(
    268        `Canceled page load listener because a beforeunload prompt was dismissed`
    269      );
    270      checkDone({ finished: true });
    271    }
    272  };
    273 
    274  const onPromptOpened = (_, data) => {
    275    if (data.prompt.promptType === "beforeunload") {
    276      // WebDriver HTTP basically doesn't know anything about beforeunload
    277      // prompts. As such we always ignore the prompt opened event.
    278      return;
    279    }
    280 
    281    lazy.logger.trace(
    282      `Canceled page load listener because a ${data.prompt.promptType} prompt opened`
    283    );
    284    checkDone({ finished: true });
    285  };
    286 
    287  const onTimer = () => {
    288    // For the command "Element Click" we want to detect a potential navigation
    289    // as early as possible. The `beforeunload` event is an indication for that
    290    // but could still cause the navigation to get aborted by the user. As such
    291    // wait a bit longer for the `unload` event to happen (only when the page
    292    // load strategy is `none`), which usually will occur pretty soon after
    293    // `beforeunload`.
    294    //
    295    // Note that with WebDriver BiDi enabled the `beforeunload` prompts might
    296    // not get implicitly accepted, so lets keep the timer around until we know
    297    // that it is really not required.
    298    if (seenBeforeUnload) {
    299      seenBeforeUnload = false;
    300      unloadTimer.initWithCallback(
    301        onTimer,
    302        TIMEOUT_UNLOAD_EVENT,
    303        Ci.nsITimer.TYPE_ONE_SHOT
    304      );
    305 
    306      // If no page unload has been detected, ensure to properly stop
    307      // the load listener, and return from the currently active command.
    308    } else if (!seenUnload) {
    309      lazy.logger.trace(
    310        "Canceled page load listener because no navigation " +
    311          "has been detected"
    312      );
    313      checkDone({ finished: true });
    314    }
    315  };
    316 
    317  const onNavigation = (eventName, data) => {
    318    const browsingContext = browsingContextFn();
    319 
    320    // Ignore events from other browsing contexts than the selected one.
    321    if (data.browsingContext != browsingContext) {
    322      return;
    323    }
    324 
    325    lazy.logger.trace(
    326      lazy.truncate`[${data.browsingContext.id}] Received event ${data.type} for ${data.documentURI}`
    327    );
    328 
    329    switch (data.type) {
    330      case "beforeunload":
    331        seenBeforeUnload = true;
    332        break;
    333 
    334      case "pagehide":
    335        seenUnload = true;
    336        break;
    337 
    338      case "hashchange":
    339      case "popstate":
    340        checkDone({ finished: true });
    341        break;
    342 
    343      case "DOMContentLoaded":
    344      case "pageshow": {
    345        // Don't require an unload event when a top-level browsing context
    346        // change occurred.
    347        // The initial about:blank load has no previous page to unload.
    348        if (!seenUnload && !browsingContextChanged && !data.isInitialDocument) {
    349          return;
    350        }
    351        const result = checkReadyState(pageLoadStrategy, data);
    352        checkDone(result);
    353        break;
    354      }
    355    }
    356  };
    357 
    358  // In the case when the currently selected frame is closed,
    359  // there will be no further load events. Stop listening immediately.
    360  const onBrowsingContextDiscarded = (subject, topic, why) => {
    361    // If the BrowsingContext is being discarded to be replaced by another
    362    // context, we don't want to stop waiting for the pageload to complete, as
    363    // we will continue listening to the newly created context.
    364    if (subject == browsingContextFn() && why != "replace") {
    365      lazy.logger.trace(
    366        "Canceled page load listener " +
    367          `because browsing context with id ${subject.id} has been removed`
    368      );
    369      checkDone({ finished: true });
    370    }
    371  };
    372 
    373  // Detect changes to the top-level browsing context to not
    374  // necessarily require an unload event.
    375  const onBrowsingContextChanged = event => {
    376    if (event.target === driver.curBrowser.contentBrowser) {
    377      browsingContextChanged = true;
    378    }
    379  };
    380 
    381  const onUnload = () => {
    382    lazy.logger.trace(
    383      "Canceled page load listener " +
    384        "because the top-browsing context has been closed"
    385    );
    386    checkDone({ finished: true });
    387  };
    388 
    389  chromeWindow.addEventListener("TabClose", onUnload);
    390  chromeWindow.addEventListener("unload", onUnload);
    391  driver.curBrowser.tabBrowser?.addEventListener(
    392    "XULFrameLoaderCreated",
    393    onBrowsingContextChanged
    394  );
    395  driver.promptListener.on("closed", onPromptClosed);
    396  driver.promptListener.on("opened", onPromptOpened);
    397  Services.obs.addObserver(
    398    onBrowsingContextDiscarded,
    399    "browsing-context-discarded"
    400  );
    401 
    402  lazy.EventDispatcher.on("page-load", onNavigation);
    403 
    404  return new lazy.TimedPromise(
    405    async (resolve, reject) => {
    406      rejectNavigation = reject;
    407      resolveNavigation = resolve;
    408 
    409      try {
    410        await callback();
    411 
    412        // Certain commands like clickElement can cause a navigation. Setup a timer
    413        // to check if a "beforeunload" event has been emitted within the given
    414        // time frame. If not resolve the Promise.
    415        if (
    416          !requireBeforeUnload &&
    417          lazy.MarionettePrefs.navigateAfterClickEnabled
    418        ) {
    419          unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    420          unloadTimer.initWithCallback(
    421            onTimer,
    422            lazy.MarionettePrefs.navigateAfterClickTimeout *
    423              lazy.getTimeoutMultiplier(),
    424            Ci.nsITimer.TYPE_ONE_SHOT
    425          );
    426        }
    427      } catch (e) {
    428        // Executing the callback above could destroy the actor pair before the
    429        // command returns. Such an error has to be ignored.
    430        if (e.name !== "AbortError") {
    431          checkDone({ finished: true, error: e });
    432        }
    433      }
    434    },
    435    {
    436      errorMessage: "Navigation timed out",
    437      timeout: driver.currentSession.timeouts.pageLoad,
    438    }
    439  ).finally(() => {
    440    // Clean-up all registered listeners and timers
    441    Services.obs.removeObserver(
    442      onBrowsingContextDiscarded,
    443      "browsing-context-discarded"
    444    );
    445    chromeWindow.removeEventListener("TabClose", onUnload);
    446    chromeWindow.removeEventListener("unload", onUnload);
    447    driver.curBrowser.tabBrowser?.removeEventListener(
    448      "XULFrameLoaderCreated",
    449      onBrowsingContextChanged
    450    );
    451    driver.promptListener?.off("closed", onPromptClosed);
    452    driver.promptListener?.off("opened", onPromptOpened);
    453    unloadTimer?.cancel();
    454 
    455    lazy.EventDispatcher.off("page-load", onNavigation);
    456  });
    457 };