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;