tor-browser

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

webextension-inspected-window.js (22352B)


      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
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const { Actor } = require("resource://devtools/shared/protocol.js");
      8 const {
      9  webExtensionInspectedWindowSpec,
     10 } = require("resource://devtools/shared/specs/addon/webextension-inspected-window.js");
     11 
     12 ChromeUtils.defineESModuleGetters(
     13  this,
     14  {
     15    ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
     16  },
     17  { global: "contextual" }
     18 );
     19 
     20 const {
     21  DevToolsServer,
     22 } = require("resource://devtools/server/devtools-server.js");
     23 
     24 loader.lazyGetter(
     25  this,
     26  "NodeActor",
     27  () =>
     28    require("resource://devtools/server/actors/inspector/node.js").NodeActor,
     29  true
     30 );
     31 
     32 // A weak set of the documents for which a warning message has been
     33 // already logged (so that we don't keep emitting the same warning if an
     34 // extension keeps calling the devtools.inspectedWindow.eval API method
     35 // when it fails to retrieve a result, but we do log the warning message
     36 // if the user reloads the window):
     37 //
     38 // WeakSet<Document>
     39 const deniedWarningDocuments = new WeakSet();
     40 
     41 function isSystemPrincipalWindow(window) {
     42  return window.document.nodePrincipal.isSystemPrincipal;
     43 }
     44 
     45 // Create the exceptionInfo property in the format expected by a
     46 // WebExtension inspectedWindow.eval API calls.
     47 function createExceptionInfoResult(props) {
     48  return {
     49    exceptionInfo: {
     50      isError: true,
     51      code: "E_PROTOCOLERROR",
     52      description: "Unknown Inspector protocol error",
     53 
     54      // Apply the passed properties.
     55      ...props,
     56    },
     57  };
     58 }
     59 
     60 // Show a warning message in the webconsole when an extension
     61 // eval request has been denied, so that the user knows about it
     62 // even if the extension doesn't report the error itself.
     63 function logAccessDeniedWarning(window, callerInfo, extensionPolicy) {
     64  // Do not log the same warning multiple times for the same document.
     65  if (deniedWarningDocuments.has(window.document)) {
     66    return;
     67  }
     68 
     69  deniedWarningDocuments.add(window.document);
     70 
     71  const { name } = extensionPolicy;
     72 
     73  // System principals have a null nodePrincipal.URI and so we use
     74  // the url from window.location.href.
     75  const reportedURIorPrincipal = isSystemPrincipalWindow(window)
     76    ? Services.io.newURI(window.location.href)
     77    : window.document.nodePrincipal;
     78 
     79  const error = Cc["@mozilla.org/scripterror;1"].createInstance(
     80    Ci.nsIScriptError
     81  );
     82 
     83  const msg = `The extension "${name}" is not allowed to access ${reportedURIorPrincipal.spec}`;
     84 
     85  const innerWindowId = window.windowGlobalChild.innerWindowId;
     86 
     87  const errorFlag = 0;
     88 
     89  let { url, lineNumber } = callerInfo;
     90 
     91  const callerURI = callerInfo.url && Services.io.newURI(callerInfo.url);
     92 
     93  // callerInfo.url is not the full path to the file that called the WebExtensions
     94  // API yet (Bug 1448878), and so we associate the error to the url of the extension
     95  // manifest.json file as a fallback.
     96  if (callerURI.filePath === "/") {
     97    url = extensionPolicy.getURL("/manifest.json");
     98    lineNumber = null;
     99  }
    100 
    101  error.initWithWindowID(
    102    msg,
    103    url,
    104    lineNumber,
    105    0,
    106    0,
    107    errorFlag,
    108    "webExtensions",
    109    innerWindowId
    110  );
    111  Services.console.logMessage(error);
    112 }
    113 
    114 /**
    115 * @param {WebExtensionPolicy} extensionPolicy
    116 * @param {nsIPrincipal} principal
    117 * @param {Location} location
    118 * @returns {boolean} Whether the extension is allowed to run code in execution
    119 *   contexts with the given principal.
    120 */
    121 function extensionAllowedToInspectPrincipal(
    122  extensionPolicy,
    123  principal,
    124  location
    125 ) {
    126  if (principal.isNullPrincipal) {
    127    if (location.protocol === "view-source:") {
    128      // Don't fall back to the precursor, we never want extensions to be able
    129      // to run code in view-source:-documents.
    130      return false;
    131    }
    132    // data: and sandboxed documents.
    133    //
    134    // Rather than returning true unconditionally, we go through additional
    135    // checks to prevent execution in sandboxed documents created by principals
    136    // that extensions cannot access otherwise.
    137    principal = principal.precursorPrincipal;
    138    if (!principal) {
    139      // Top-level about:blank, etc.
    140      return true;
    141    }
    142  }
    143  if (!principal.isContentPrincipal) {
    144    return false;
    145  }
    146  const principalURI = principal.URI;
    147  if (principalURI.schemeIs("https") || principalURI.schemeIs("http")) {
    148    if (WebExtensionPolicy.isRestrictedURI(principalURI)) {
    149      return false;
    150    }
    151    if (extensionPolicy.quarantinedFromURI(principalURI)) {
    152      return false;
    153    }
    154    // Common case: http(s) allowed.
    155    return true;
    156  }
    157 
    158  if (ExtensionUtils.isExtensionUrl(principalURI)) {
    159    // Ordinarily, we don't allow extensions to execute arbitrary code in
    160    // their own context. The devtools.inspectedWindow.eval API is a special
    161    // case - this can only be used through the devtools_page feature, which
    162    // requires the user to open the developer tools first. If an extension
    163    // really wants to debug itself, we let it do so.
    164    return extensionPolicy.id === principal.addonId;
    165  }
    166 
    167  if (principalURI.schemeIs("file")) {
    168    return true;
    169  }
    170 
    171  return false;
    172 }
    173 
    174 class CustomizedReload {
    175  constructor(params) {
    176    this.docShell = params.targetActor.window.docShell;
    177    this.docShell.QueryInterface(Ci.nsIWebProgress);
    178 
    179    this.inspectedWindowEval = params.inspectedWindowEval;
    180    this.callerInfo = params.callerInfo;
    181 
    182    this.ignoreCache = params.ignoreCache;
    183    this.injectedScript = params.injectedScript;
    184 
    185    this.customizedReloadWindows = new WeakSet();
    186  }
    187 
    188  QueryInterface = ChromeUtils.generateQI([
    189    "nsIWebProgressListener",
    190    "nsISupportsWeakReference",
    191  ]);
    192 
    193  get window() {
    194    return this.docShell.DOMWindow;
    195  }
    196 
    197  get webNavigation() {
    198    return this.docShell
    199      .QueryInterface(Ci.nsIInterfaceRequestor)
    200      .getInterface(Ci.nsIWebNavigation);
    201  }
    202 
    203  get browsingContext() {
    204    return this.docShell.browsingContext;
    205  }
    206 
    207  start() {
    208    if (!this.waitForReloadCompleted) {
    209      this.waitForReloadCompleted = new Promise((resolve, reject) => {
    210        this.resolveReloadCompleted = resolve;
    211        this.rejectReloadCompleted = reject;
    212 
    213        let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
    214 
    215        if (this.ignoreCache) {
    216          reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
    217        }
    218 
    219        try {
    220          if (this.injectedScript) {
    221            // Listen to the newly created document elements only if there is an
    222            // injectedScript to evaluate.
    223            Services.obs.addObserver(this, "initial-document-element-inserted");
    224          }
    225 
    226          // Watch the loading progress and clear the current CustomizedReload once the
    227          // page has been reloaded (or if its reloading has been interrupted).
    228          this.docShell.addProgressListener(
    229            this,
    230            Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
    231          );
    232 
    233          this.webNavigation.reload(reloadFlags);
    234        } catch (err) {
    235          // Cancel the injected script listener if the reload fails
    236          // (which will also report the error by rejecting the promise).
    237          this.stop(err);
    238        }
    239      });
    240    }
    241 
    242    return this.waitForReloadCompleted;
    243  }
    244 
    245  observe(subject, topic) {
    246    if (topic !== "initial-document-element-inserted") {
    247      return;
    248    }
    249 
    250    const document = subject;
    251    const window = document?.defaultView;
    252 
    253    // Filter out non interesting documents.
    254    if (!document || !document.location || !window) {
    255      return;
    256    }
    257 
    258    const subjectDocShell = window.docShell;
    259 
    260    // Keep track of the set of window objects where we are going to inject
    261    // the injectedScript: the top level window and all its descendant
    262    // that are still of type content (filtering out loaded XUL pages, if any).
    263    if (window == this.window) {
    264      this.customizedReloadWindows.add(window);
    265    } else if (subjectDocShell.sameTypeParent) {
    266      const parentWindow = subjectDocShell.sameTypeParent.domWindow;
    267      if (parentWindow && this.customizedReloadWindows.has(parentWindow)) {
    268        this.customizedReloadWindows.add(window);
    269      }
    270    }
    271 
    272    if (this.customizedReloadWindows.has(window)) {
    273      const { apiErrorResult } = this.inspectedWindowEval(
    274        this.callerInfo,
    275        this.injectedScript,
    276        {},
    277        window
    278      );
    279 
    280      // Log only apiErrorResult, because no one is waiting for the
    281      // injectedScript result, and any exception is going to be logged
    282      // in the inspectedWindow webconsole.
    283      if (apiErrorResult) {
    284        console.error(
    285          "Unexpected Error in injectedScript during inspectedWindow.reload for",
    286          `${this.callerInfo.url}:${this.callerInfo.lineNumber}`,
    287          apiErrorResult
    288        );
    289      }
    290    }
    291  }
    292 
    293  onStateChange(webProgress, request, state, status) {
    294    if (webProgress.DOMWindow !== this.window) {
    295      return;
    296    }
    297 
    298    if (state & Ci.nsIWebProgressListener.STATE_STOP) {
    299      if (status == Cr.NS_BINDING_ABORTED) {
    300        // The customized reload has been interrupted and we can clear
    301        // the CustomizedReload and reject the promise.
    302        const url = this.window.location.href;
    303        this.stop(
    304          new Error(
    305            `devtools.inspectedWindow.reload on ${url} has been interrupted`
    306          )
    307        );
    308      } else {
    309        // Once the top level frame has been loaded, we can clear the customized reload
    310        // and resolve the promise.
    311        this.stop();
    312      }
    313    }
    314  }
    315 
    316  stop(error) {
    317    if (this.stopped) {
    318      return;
    319    }
    320 
    321    this.docShell.removeProgressListener(this);
    322 
    323    if (this.injectedScript) {
    324      Services.obs.removeObserver(this, "initial-document-element-inserted");
    325    }
    326 
    327    if (error) {
    328      this.rejectReloadCompleted(error);
    329    } else {
    330      this.resolveReloadCompleted();
    331    }
    332 
    333    this.stopped = true;
    334  }
    335 }
    336 
    337 class WebExtensionInspectedWindowActor extends Actor {
    338  /**
    339   * Created the WebExtension InspectedWindow actor
    340   */
    341  constructor(conn, targetActor) {
    342    super(conn, webExtensionInspectedWindowSpec);
    343    this.targetActor = targetActor;
    344  }
    345 
    346  destroy() {
    347    super.destroy();
    348 
    349    if (this.customizedReload) {
    350      this.customizedReload.stop(
    351        new Error("WebExtensionInspectedWindowActor destroyed")
    352      );
    353      delete this.customizedReload;
    354    }
    355 
    356    if (this._dbg) {
    357      this._dbg.disable();
    358      delete this._dbg;
    359    }
    360  }
    361 
    362  get dbg() {
    363    if (this._dbg) {
    364      return this._dbg;
    365    }
    366 
    367    this._dbg = this.targetActor.makeDebugger();
    368    return this._dbg;
    369  }
    370 
    371  get window() {
    372    return this.targetActor.window;
    373  }
    374 
    375  get webNavigation() {
    376    return this.targetActor.webNavigation;
    377  }
    378 
    379  createEvalBindings(dbgWindow, options) {
    380    const bindings = Object.create(null);
    381 
    382    let selectedDOMNode;
    383 
    384    if (options.toolboxSelectedNodeActorID) {
    385      const actor = DevToolsServer.searchAllConnectionsForActor(
    386        options.toolboxSelectedNodeActorID
    387      );
    388      if (actor && actor instanceof NodeActor) {
    389        selectedDOMNode = actor.rawNode;
    390      }
    391    }
    392 
    393    Object.defineProperty(bindings, "$0", {
    394      enumerable: true,
    395      configurable: true,
    396      get: () => {
    397        if (selectedDOMNode && !Cu.isDeadWrapper(selectedDOMNode)) {
    398          return dbgWindow.makeDebuggeeValue(selectedDOMNode);
    399        }
    400 
    401        return undefined;
    402      },
    403    });
    404 
    405    // This function is used by 'eval' and 'reload' requests, but only 'eval'
    406    // passes 'toolboxConsoleActor' from the client side in order to set
    407    // the 'inspect' binding.
    408    Object.defineProperty(bindings, "inspect", {
    409      enumerable: true,
    410      configurable: true,
    411      value: dbgWindow.makeDebuggeeValue(object => {
    412        const consoleActor = DevToolsServer.searchAllConnectionsForActor(
    413          options.toolboxConsoleActorID
    414        );
    415        if (consoleActor) {
    416          const dbgObj = consoleActor.makeDebuggeeValue(object);
    417          consoleActor.inspectObject(
    418            dbgObj,
    419            "webextension-devtools-inspectedWindow-eval"
    420          );
    421        } else {
    422          // TODO(rpl): evaluate if it would be better to raise an exception
    423          // to the caller code instead.
    424          console.error("Toolbox Console RDP Actor not found");
    425        }
    426      }),
    427    });
    428 
    429    return bindings;
    430  }
    431 
    432  /**
    433   * Reload the target tab, optionally bypass cache, customize the userAgent and/or
    434   * inject a script in targeted document or any of its sub-frame.
    435   *
    436   * @param {webExtensionCallerInfo} callerInfo
    437   *   the addonId and the url (the addon base url or the url of the actual caller
    438   *   filename and lineNumber) used to log useful debugging information in the
    439   *   produced error logs and eval stack trace.
    440   *
    441   * @param {webExtensionReloadOptions} options
    442   *   used to optionally enable the reload customizations.
    443   * @param {boolean|undefined}       options.ignoreCache
    444   *   enable/disable the cache bypass headers.
    445   * @param {string|undefined}        options.userAgent
    446   *   customize the userAgent during the page reload.
    447   * @param {string|undefined}        options.injectedScript
    448   *   evaluate the provided javascript code in the top level and every sub-frame
    449   *   created during the page reload, before any other script in the page has been
    450   *   executed.
    451   */
    452  async reload(callerInfo, { ignoreCache, userAgent, injectedScript }) {
    453    if (isSystemPrincipalWindow(this.window)) {
    454      console.error(
    455        "Ignored inspectedWindow.reload on system principal target for " +
    456          `${callerInfo.url}:${callerInfo.lineNumber}`
    457      );
    458      return {};
    459    }
    460 
    461    await new Promise(resolve => {
    462      const delayedReload = () => {
    463        // This won't work while the browser is shutting down and we don't really
    464        // care.
    465        if (Services.startup.shuttingDown) {
    466          return;
    467        }
    468 
    469        if (injectedScript || userAgent) {
    470          if (this.customizedReload) {
    471            // TODO(rpl): check what chrome does, and evaluate if queue the new reload
    472            // after the current one has been completed.
    473            console.error(
    474              "Reload already in progress. Ignored inspectedWindow.reload for " +
    475                `${callerInfo.url}:${callerInfo.lineNumber}`
    476            );
    477            return;
    478          }
    479 
    480          try {
    481            this.customizedReload = new CustomizedReload({
    482              targetActor: this.targetActor,
    483              inspectedWindowEval: this.eval.bind(this),
    484              callerInfo,
    485              injectedScript,
    486              ignoreCache,
    487            });
    488 
    489            this.customizedReload
    490              .start()
    491              .catch(err => {
    492                console.error(err);
    493              })
    494              .then(() => {
    495                delete this.customizedReload;
    496                resolve();
    497              });
    498          } catch (err) {
    499            // Cancel the customized reload (if any) on exception during the
    500            // reload setup.
    501            if (this.customizedReload) {
    502              this.customizedReload.stop(err);
    503            }
    504            throw err;
    505          }
    506        } else {
    507          // If there is no custom user agent and/or injected script, then
    508          // we can reload the target without subscribing any observer/listener.
    509          let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
    510          if (ignoreCache) {
    511            reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
    512          }
    513          this.webNavigation.reload(reloadFlags);
    514          resolve();
    515        }
    516      };
    517 
    518      // Execute the reload in a dispatched runnable, so that we can
    519      // return the reply to the caller before the reload is actually
    520      // started.
    521      Services.tm.dispatchToMainThread(delayedReload);
    522    });
    523 
    524    return {};
    525  }
    526 
    527  /**
    528   * Evaluate the provided javascript code in a target window (that is always the
    529   * targetActor window when called through RDP protocol, or the passed
    530   * customTargetWindow when called directly from the CustomizedReload instances).
    531   *
    532   * @param {webExtensionCallerInfo} callerInfo
    533   *   the addonId and the url (the addon base url or the url of the actual caller
    534   *   filename and lineNumber) used to log useful debugging information in the
    535   *   produced error logs and eval stack trace.
    536   *
    537   * @param {string} expression
    538   *   the javascript code to be evaluated in the target window
    539   *
    540   * @param {webExtensionEvalOptions} evalOptions
    541   *   used to optionally enable the eval customizations.
    542   *   NOTE: none of the eval options is currently implemented, they will be already
    543   *   reported as unsupported by the WebExtensions schema validation wrappers, but
    544   *   an additional level of error reporting is going to be applied here, so that
    545   *   if the server and the client have different ideas of which option is supported
    546   *   the eval call result will contain detailed informations (in the format usually
    547   *   expected for errors not raised in the evaluated javascript code).
    548   *
    549   * @param {DOMWindow|undefined} customTargetWindow
    550   *   Used in the CustomizedReload instances to evaluate the `injectedScript`
    551   *   javascript code in every sub-frame of the target window during the tab reload.
    552   *   NOTE: this parameter is not part of the RDP protocol exposed by this actor, when
    553   *   it is called over the remote debugging protocol the target window is always
    554   *   `targetActor.window`.
    555   */
    556  // eslint-disable-next-line complexity
    557  eval(callerInfo, expression, options, customTargetWindow) {
    558    const window = customTargetWindow || this.window;
    559    options = options || {};
    560 
    561    const extensionPolicy = WebExtensionPolicy.getByID(callerInfo.addonId);
    562 
    563    if (!extensionPolicy) {
    564      return createExceptionInfoResult({
    565        description: "Inspector protocol error: %s %s",
    566        details: ["Caller extension not found for", callerInfo.url],
    567      });
    568    }
    569 
    570    if (!window) {
    571      return createExceptionInfoResult({
    572        description: "Inspector protocol error: %s",
    573        details: [
    574          "The target window is not defined. inspectedWindow.eval not executed.",
    575        ],
    576      });
    577    }
    578 
    579    if (
    580      !extensionAllowedToInspectPrincipal(
    581        extensionPolicy,
    582        window.document.nodePrincipal,
    583        window.location
    584      )
    585    ) {
    586      // Log the error for the user to know that the extension request has been
    587      // denied (the extension may not warn the user at all).
    588      logAccessDeniedWarning(window, callerInfo, extensionPolicy);
    589 
    590      // The error message is generic here. If access is disallowed, we do not
    591      // expose the URL either.
    592      return createExceptionInfoResult({
    593        description: "Inspector protocol error: %s",
    594        details: [
    595          "This extension is not allowed on the current inspected window origin",
    596        ],
    597      });
    598    }
    599 
    600    // Raise an error on the unsupported options.
    601    if (
    602      options.frameURL ||
    603      options.contextSecurityOrigin ||
    604      options.useContentScriptContext
    605    ) {
    606      return createExceptionInfoResult({
    607        description: "Inspector protocol error: %s",
    608        details: [
    609          "The inspectedWindow.eval options are currently not supported",
    610        ],
    611      });
    612    }
    613 
    614    const dbgWindow = this.dbg.makeGlobalObjectReference(window);
    615 
    616    let evalCalledFrom = callerInfo.url;
    617    if (callerInfo.lineNumber) {
    618      evalCalledFrom += `:${callerInfo.lineNumber}`;
    619    }
    620 
    621    const bindings = this.createEvalBindings(dbgWindow, options);
    622 
    623    const result = dbgWindow.executeInGlobalWithBindings(expression, bindings, {
    624      url: `debugger eval called from ${evalCalledFrom} - eval code`,
    625    });
    626 
    627    let evalResult;
    628 
    629    if (result) {
    630      if ("return" in result) {
    631        evalResult = result.return;
    632      } else if ("yield" in result) {
    633        evalResult = result.yield;
    634      } else if ("throw" in result) {
    635        const throwErr = result.throw;
    636 
    637        // XXXworkers: Calling unsafeDereference() returns an object with no
    638        // toString method in workers. See Bug 1215120.
    639        const unsafeDereference =
    640          throwErr &&
    641          typeof throwErr === "object" &&
    642          throwErr.unsafeDereference();
    643        const message = unsafeDereference?.toString
    644          ? unsafeDereference.toString()
    645          : String(throwErr);
    646        const stack = unsafeDereference?.stack ? unsafeDereference.stack : null;
    647 
    648        return {
    649          exceptionInfo: {
    650            isException: true,
    651            value: `${message}\n\t${stack}`,
    652          },
    653        };
    654      }
    655    } else {
    656      // TODO(rpl): can the result of executeInGlobalWithBinding be null or
    657      // undefined? (which means that it is not a return, a yield or a throw).
    658      console.error(
    659        "Unexpected empty inspectedWindow.eval result for",
    660        `${callerInfo.url}:${callerInfo.lineNumber}`
    661      );
    662    }
    663 
    664    if (evalResult) {
    665      try {
    666        // Return the evalResult as a grip (used by the WebExtensions
    667        // devtools inspector's sidebar.setExpression API method).
    668        if (options.evalResultAsGrip) {
    669          if (!options.toolboxConsoleActorID) {
    670            return createExceptionInfoResult({
    671              description: "Inspector protocol error: %s - %s",
    672              details: [
    673                "Unexpected invalid sidebar panel expression request",
    674                "missing toolboxConsoleActorID",
    675              ],
    676            });
    677          }
    678 
    679          const consoleActor = DevToolsServer.searchAllConnectionsForActor(
    680            options.toolboxConsoleActorID
    681          );
    682 
    683          return { valueGrip: consoleActor.createValueGrip(evalResult) };
    684        }
    685 
    686        if (evalResult && typeof evalResult === "object") {
    687          evalResult = evalResult.unsafeDereference();
    688        }
    689        evalResult = JSON.parse(JSON.stringify(evalResult));
    690      } catch (err) {
    691        // The evaluation result cannot be sent over the RDP Protocol,
    692        // report it as with the same data format used in the corresponding
    693        // chrome API method.
    694        return createExceptionInfoResult({
    695          description: "Inspector protocol error: %s",
    696          details: [String(err)],
    697        });
    698      }
    699    }
    700 
    701    return { value: evalResult };
    702  }
    703 }
    704 
    705 exports.WebExtensionInspectedWindowActor = WebExtensionInspectedWindowActor;