· 6 years ago · Nov 18, 2019, 09:06 PM
1/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2/* vim: set sts=2 sw=2 et tw=80: */
3/* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6"use strict";
7
8var EXPORTED_SYMBOLS = ["ExtensionContent"];
9
10const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11const { XPCOMUtils } = ChromeUtils.import(
12 "resource://gre/modules/XPCOMUtils.jsm"
13);
14
15XPCOMUtils.defineLazyModuleGetters(this, {
16 ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
17 ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.jsm",
18 LanguageDetector: "resource:///modules/translation/LanguageDetector.jsm",
19 MessageChannel: "resource://gre/modules/MessageChannel.jsm",
20 Schemas: "resource://gre/modules/Schemas.jsm",
21 WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.jsm",
22});
23
24XPCOMUtils.defineLazyServiceGetter(
25 this,
26 "styleSheetService",
27 "@mozilla.org/content/style-sheet-service;1",
28 "nsIStyleSheetService"
29);
30
31const Timer = Components.Constructor(
32 "@mozilla.org/timer;1",
33 "nsITimer",
34 "initWithCallback"
35);
36
37const { ExtensionChild, ExtensionActivityLogChild } = ChromeUtils.import(
38 "resource://gre/modules/ExtensionChild.jsm"
39);
40const { ExtensionCommon } = ChromeUtils.import(
41 "resource://gre/modules/ExtensionCommon.jsm"
42);
43const { ExtensionUtils } = ChromeUtils.import(
44 "resource://gre/modules/ExtensionUtils.jsm"
45);
46
47XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
48
49const {
50 DefaultMap,
51 DefaultWeakMap,
52 getInnerWindowID,
53 getWinUtils,
54 promiseDocumentIdle,
55 promiseDocumentLoaded,
56 promiseDocumentReady,
57} = ExtensionUtils;
58
59const {
60 BaseContext,
61 CanOfAPIs,
62 SchemaAPIManager,
63 defineLazyGetter,
64 runSafeSyncWithoutClone,
65} = ExtensionCommon;
66
67const { BrowserExtensionContent, ChildAPIManager, Messenger } = ExtensionChild;
68
69XPCOMUtils.defineLazyGetter(this, "console", ExtensionCommon.getConsole);
70
71XPCOMUtils.defineLazyGetter(this, "isContentScriptProcess", () => {
72 return (
73 Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT ||
74 !WebExtensionPolicy.useRemoteWebExtensions
75 );
76});
77
78var DocumentManager;
79
80const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
81
82var apiManager = new (class extends SchemaAPIManager {
83 constructor() {
84 super("content", Schemas);
85 this.initialized = false;
86 }
87
88 lazyInit() {
89 if (!this.initialized) {
90 this.initialized = true;
91 this.initGlobal();
92 for (let { value } of Services.catMan.enumerateCategory(
93 CATEGORY_EXTENSION_SCRIPTS_CONTENT
94 )) {
95 this.loadScript(value);
96 }
97 }
98 }
99})();
100
101const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000;
102const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000;
103
104const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000;
105const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000;
106
107const scriptCaches = new WeakSet();
108const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet());
109
110class CacheMap extends DefaultMap {
111 constructor(timeout, getter, extension) {
112 super(getter);
113
114 this.expiryTimeout = timeout;
115
116 scriptCaches.add(this);
117
118 // This ensures that all the cached scripts and stylesheets are deleted
119 // from the cache and the xpi is no longer actively used.
120 // See Bug 1435100 for rationale.
121 extension.once("shutdown", () => {
122 this.clear(-1);
123 });
124 }
125
126 get(url) {
127 let promise = super.get(url);
128
129 promise.lastUsed = Date.now();
130 if (promise.timer) {
131 promise.timer.cancel();
132 }
133 promise.timer = Timer(
134 this.delete.bind(this, url),
135 this.expiryTimeout,
136 Ci.nsITimer.TYPE_ONE_SHOT
137 );
138
139 return promise;
140 }
141
142 delete(url) {
143 if (this.has(url)) {
144 super.get(url).timer.cancel();
145 }
146
147 super.delete(url);
148 }
149
150 clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) {
151 let now = Date.now();
152 for (let [url, promise] of this.entries()) {
153 // Delete the entry if expired or if clear has been called with timeout -1
154 // (which is used to force the cache to clear all the entries, e.g. when the
155 // extension is shutting down).
156 if (timeout === -1 || now - promise.lastUsed >= timeout) {
157 this.delete(url);
158 }
159 }
160 }
161}
162
163class ScriptCache extends CacheMap {
164 constructor(options, extension) {
165 super(SCRIPT_EXPIRY_TIMEOUT_MS, null, extension);
166 this.options = options;
167 }
168
169 defaultConstructor(url) {
170 let promise = ChromeUtils.compileScript(url, this.options);
171 promise.then(script => {
172 promise.script = script;
173 });
174 return promise;
175 }
176}
177
178/**
179 * Shared base class for the two specialized CSS caches:
180 * CSSCache (for the "url"-based stylesheets) and CSSCodeCache
181 * (for the stylesheet defined by plain CSS content as a string).
182 */
183class BaseCSSCache extends CacheMap {
184 constructor(expiryTimeout, defaultConstructor, extension) {
185 super(expiryTimeout, defaultConstructor, extension);
186 }
187
188 addDocument(key, document) {
189 sheetCacheDocuments.get(this.get(key)).add(document);
190 }
191
192 deleteDocument(key, document) {
193 sheetCacheDocuments.get(this.get(key)).delete(document);
194 }
195
196 delete(key) {
197 if (this.has(key)) {
198 let promise = this.get(key);
199
200 // Never remove a sheet from the cache if it's still being used by a
201 // document. Rule processors can be shared between documents with the
202 // same preloaded sheet, so we only lose by removing them while they're
203 // still in use.
204 let docs = ChromeUtils.nondeterministicGetWeakSetKeys(
205 sheetCacheDocuments.get(promise)
206 );
207 if (docs.length) {
208 return;
209 }
210 }
211
212 super.delete(key);
213 }
214}
215
216/**
217 * Cache of the preloaded stylesheet defined by url.
218 */
219class CSSCache extends BaseCSSCache {
220 constructor(sheetType, extension) {
221 super(
222 CSS_EXPIRY_TIMEOUT_MS,
223 url => {
224 let uri = Services.io.newURI(url);
225 return styleSheetService
226 .preloadSheetAsync(uri, sheetType)
227 .then(sheet => {
228 return { url, sheet };
229 });
230 },
231 extension
232 );
233 }
234}
235
236/**
237 * Cache of the preloaded stylesheet defined by plain CSS content as a string,
238 * the key of the cached stylesheet is the hash of its "CSSCode" string.
239 */
240class CSSCodeCache extends BaseCSSCache {
241 constructor(sheetType, extension) {
242 super(
243 CSSCODE_EXPIRY_TIMEOUT_MS,
244 hash => {
245 if (!this.has(hash)) {
246 // Do not allow the getter to be used to lazily create the cached stylesheet,
247 // the cached CSSCode stylesheet has to be explicitly set.
248 throw new Error(
249 "Unexistent cached cssCode stylesheet: " + Error().stack
250 );
251 }
252
253 return super.get(hash);
254 },
255 extension
256 );
257
258 // Store the preferred sheetType (used to preload the expected stylesheet type in
259 // the addCSSCode method).
260 this.sheetType = sheetType;
261 }
262
263 addCSSCode(hash, cssCode) {
264 if (this.has(hash)) {
265 // This cssCode have been already cached, no need to create it again.
266 return;
267 }
268 const uri = Services.io.newURI(
269 "data:text/css;charset=utf-8," + encodeURIComponent(cssCode)
270 );
271 const value = styleSheetService
272 .preloadSheetAsync(uri, this.sheetType)
273 .then(sheet => {
274 return { sheet, uri };
275 });
276
277 super.set(hash, value);
278 }
279}
280
281defineLazyGetter(
282 BrowserExtensionContent.prototype,
283 "staticScripts",
284 function() {
285 return new ScriptCache({ hasReturnValue: false }, this);
286 }
287);
288
289defineLazyGetter(
290 BrowserExtensionContent.prototype,
291 "dynamicScripts",
292 function() {
293 return new ScriptCache({ hasReturnValue: true }, this);
294 }
295);
296
297defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", function() {
298 return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this);
299});
300
301defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", function() {
302 return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
303});
304
305// These two caches are similar to the above but specialized to cache the cssCode
306// using an hash computed from the cssCode string as the key (instead of the generated data
307// URI which can be pretty long for bigger injected cssCode).
308defineLazyGetter(BrowserExtensionContent.prototype, "userCSSCode", function() {
309 return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this);
310});
311
312defineLazyGetter(
313 BrowserExtensionContent.prototype,
314 "authorCSSCode",
315 function() {
316 return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this);
317 }
318);
319
320// Represents a content script.
321class Script {
322 /**
323 * @param {BrowserExtensionContent} extension
324 * @param {WebExtensionContentScript|object} matcher
325 * An object with a "matchesWindow" method and content script execution
326 * details. This is usually a plain WebExtensionContentScript object,
327 * except when the script is run via `tabs.executeScript`. In this
328 * case, the object may have some extra properties:
329 * wantReturnValue, removeCSS, cssOrigin, jsCode
330 */
331 constructor(extension, matcher) {
332 this.scriptType = "content_script";
333 this.extension = extension;
334 this.matcher = matcher;
335
336 this.runAt = this.matcher.runAt;
337 this.js = this.matcher.jsPaths;
338 this.css = this.matcher.cssPaths.slice();
339 this.cssCodeHash = null;
340
341 this.removeCSS = this.matcher.removeCSS;
342 this.cssOrigin = this.matcher.cssOrigin;
343
344 this.cssCache =
345 extension[this.cssOrigin === "user" ? "userCSS" : "authorCSS"];
346 this.cssCodeCache =
347 extension[this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"];
348 this.scriptCache =
349 extension[matcher.wantReturnValue ? "dynamicScripts" : "staticScripts"];
350
351 if (matcher.wantReturnValue) {
352 this.compileScripts();
353 this.loadCSS();
354 }
355 }
356
357 get requiresCleanup() {
358 return !this.removeCSS && (this.css.length > 0 || this.cssCodeHash);
359 }
360
361 async addCSSCode(cssCode) {
362 if (!cssCode) {
363 return;
364 }
365
366 // Store the hash of the cssCode.
367 const buffer = await crypto.subtle.digest(
368 "SHA-1",
369 new TextEncoder().encode(cssCode)
370 );
371 this.cssCodeHash = String.fromCharCode(...new Uint16Array(buffer));
372
373 // Cache and preload the cssCode stylesheet.
374 this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode);
375 }
376
377 compileScripts() {
378 return this.js.map(url => this.scriptCache.get(url));
379 }
380
381 loadCSS() {
382 return this.css.map(url => this.cssCache.get(url));
383 }
384
385 preload() {
386 this.loadCSS();
387 this.compileScripts();
388 }
389
390 cleanup(window) {
391 if (this.requiresCleanup) {
392 if (window) {
393 let winUtils = getWinUtils(window);
394
395 let type =
396 this.cssOrigin === "user"
397 ? winUtils.USER_SHEET
398 : winUtils.AUTHOR_SHEET;
399
400 for (let url of this.css) {
401 this.cssCache.deleteDocument(url, window.document);
402 runSafeSyncWithoutClone(
403 winUtils.removeSheetUsingURIString,
404 url,
405 type
406 );
407 }
408
409 const { cssCodeHash } = this;
410
411 if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
412 this.cssCodeCache.get(cssCodeHash).then(({ uri }) => {
413 runSafeSyncWithoutClone(winUtils.removeSheet, uri, type);
414 });
415 this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
416 }
417 }
418
419 // Clear any sheets that were kept alive past their timeout as
420 // a result of living in this document.
421 this.cssCodeCache.clear(CSSCODE_EXPIRY_TIMEOUT_MS);
422 this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS);
423 }
424 }
425
426 matchesWindow(window) {
427 return this.matcher.matchesWindow(window);
428 }
429
430 async injectInto(window) {
431 if (!isContentScriptProcess) {
432 return;
433 }
434
435 let context = this.extension.getContext(window);
436 for (let script of this.matcher.jsPaths) {
437 context.logActivity(this.scriptType, script, {
438 url: window.location.href,
439 });
440 }
441
442 try {
443 if (this.runAt === "document_end") {
444 await promiseDocumentReady(window.document);
445 } else if (this.runAt === "document_idle") {
446 await Promise.race([
447 promiseDocumentIdle(window),
448 promiseDocumentLoaded(window.document),
449 ]);
450 }
451
452 return this.inject(context);
453 } catch (e) {
454 return Promise.reject(context.normalizeError(e));
455 }
456 }
457
458 /**
459 * Tries to inject this script into the given window and sandbox, if
460 * there are pending operations for the window's current load state.
461 *
462 * @param {BaseContext} context
463 * The content script context into which to inject the scripts.
464 * @returns {Promise<any>}
465 * Resolves to the last value in the evaluated script, when
466 * execution is complete.
467 */
468 async inject(context) {
469 DocumentManager.lazyInit();
470 if (this.requiresCleanup) {
471 context.addScript(this);
472 }
473
474 const { cssCodeHash } = this;
475
476 let cssPromise;
477 if (this.css.length || cssCodeHash) {
478 let window = context.contentWindow;
479 let winUtils = getWinUtils(window);
480
481 let type =
482 this.cssOrigin === "user" ? winUtils.USER_SHEET : winUtils.AUTHOR_SHEET;
483
484 if (this.removeCSS) {
485 for (let url of this.css) {
486 this.cssCache.deleteDocument(url, window.document);
487
488 runSafeSyncWithoutClone(
489 winUtils.removeSheetUsingURIString,
490 url,
491 type
492 );
493 }
494
495 if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) {
496 const { uri } = await this.cssCodeCache.get(cssCodeHash);
497 this.cssCodeCache.deleteDocument(cssCodeHash, window.document);
498
499 runSafeSyncWithoutClone(winUtils.removeSheet, uri, type);
500 }
501 } else {
502 cssPromise = Promise.all(this.loadCSS()).then(sheets => {
503 let window = context.contentWindow;
504 if (!window) {
505 return;
506 }
507
508 for (let { url, sheet } of sheets) {
509 this.cssCache.addDocument(url, window.document);
510
511 runSafeSyncWithoutClone(winUtils.addSheet, sheet, type);
512 }
513 });
514
515 if (cssCodeHash) {
516 cssPromise = cssPromise.then(async () => {
517 const { sheet } = await this.cssCodeCache.get(cssCodeHash);
518 this.cssCodeCache.addDocument(cssCodeHash, window.document);
519
520 runSafeSyncWithoutClone(winUtils.addSheet, sheet, type);
521 });
522 }
523
524 // We're loading stylesheets via the stylesheet service, which means
525 // that the normal mechanism for blocking layout and onload for pending
526 // stylesheets aren't in effect (since there's no document to block). So
527 // we need to do something custom here, similar to what we do for
528 // scripts. Blocking parsing is overkill, since we really just want to
529 // block layout and onload. But we have an API to do the former and not
530 // the latter, so we do it that way. This hopefully isn't a performance
531 // problem since there are no network loads involved, and since we cache
532 // the stylesheets on first load. We should fix this up if it does becomes
533 // a problem.
534 if (this.css.length > 0) {
535 context.contentWindow.document.blockParsing(cssPromise, {
536 blockScriptCreated: false,
537 });
538 }
539 }
540 }
541
542 let scripts = this.getCompiledScripts(context);
543 if (scripts instanceof Promise) {
544 scripts = await scripts;
545 }
546
547 let result;
548
549 const { extension } = context;
550
551 // The evaluations below may throw, in which case the promise will be
552 // automatically rejected.
553 ExtensionTelemetry.contentScriptInjection.stopwatchStart(
554 extension,
555 context
556 );
557 try {
558 for (let script of scripts) {
559 result = script.executeInGlobal(context.cloneScope);
560 }
561
562 if (this.matcher.jsCode) {
563 result = Cu.evalInSandbox(
564 this.matcher.jsCode,
565 context.cloneScope,
566 "latest"
567 );
568 }
569 } finally {
570 ExtensionTelemetry.contentScriptInjection.stopwatchFinish(
571 extension,
572 context
573 );
574 }
575
576 await cssPromise;
577 return result;
578 }
579
580 /**
581 * Get the compiled scripts (if they are already precompiled and cached) or a promise which resolves
582 * to the precompiled scripts (once they have been compiled and cached).
583 *
584 * @param {BaseContext} context
585 * The document to block the parsing on, if the scripts are not yet precompiled and cached.
586 *
587 * @returns {Array<PreloadedScript> | Promise<Array<PreloadedScript>>}
588 * Returns an array of preloaded scripts if they are already available, or a promise which
589 * resolves to the array of the preloaded scripts once they are precompiled and cached.
590 */
591 getCompiledScripts(context) {
592 let scriptPromises = this.compileScripts();
593 let scripts = scriptPromises.map(promise => promise.script);
594
595 // If not all scripts are already available in the cache, block
596 // parsing and wait all promises to resolve.
597 if (!scripts.every(script => script)) {
598 let promise = Promise.all(scriptPromises);
599
600 // If we're supposed to inject at the start of the document load,
601 // and we haven't already missed that point, block further parsing
602 // until the scripts have been loaded.
603 const { document } = context.contentWindow;
604 if (
605 this.runAt === "document_start" &&
606 document.readyState !== "complete"
607 ) {
608 document.blockParsing(promise, { blockScriptCreated: false });
609 }
610
611 return promise;
612 }
613
614 return scripts;
615 }
616}
617
618// Represents a user script.
619class UserScript extends Script {
620 /**
621 * @param {BrowserExtensionContent} extension
622 * @param {WebExtensionContentScript|object} matcher
623 * An object with a "matchesWindow" method and content script execution
624 * details.
625 */
626 constructor(extension, matcher) {
627 super(extension, matcher);
628 this.scriptType = "user_script";
629
630 // This is an opaque object that the extension provides, it is associated to
631 // the particular userScript and it is passed as a parameter to the custom
632 // userScripts APIs defined by the extension.
633 this.scriptMetadata = matcher.userScriptOptions.scriptMetadata;
634 this.apiScriptURL =
635 extension.manifest.user_scripts &&
636 extension.manifest.user_scripts.api_script;
637
638 // Add the apiScript to the js scripts to compile.
639 if (this.apiScriptURL) {
640 this.js = [this.apiScriptURL].concat(this.js);
641 }
642
643 // WeakMap<ContentScriptContextChild, Sandbox>
644 this.sandboxes = new DefaultWeakMap(context => {
645 return this.createSandbox(context);
646 });
647 }
648
649 async inject(context) {
650 const { extension } = context;
651
652 DocumentManager.lazyInit();
653
654 let scripts = this.getCompiledScripts(context);
655 if (scripts instanceof Promise) {
656 scripts = await scripts;
657 }
658
659 let apiScript, sandboxScripts;
660
661 if (this.apiScriptURL) {
662 [apiScript, ...sandboxScripts] = scripts;
663 } else {
664 sandboxScripts = scripts;
665 }
666
667 // Load and execute the API script once per context.
668 if (apiScript) {
669 context.executeAPIScript(apiScript);
670 }
671
672 // The evaluations below may throw, in which case the promise will be
673 // automatically rejected.
674 ExtensionTelemetry.userScriptInjection.stopwatchStart(extension, context);
675 try {
676 let userScriptSandbox = this.sandboxes.get(context);
677
678 context.callOnClose({
679 close: () => {
680 // Destroy the userScript sandbox when the related ContentScriptContextChild instance
681 // is being closed.
682 this.sandboxes.delete(context);
683 Cu.nukeSandbox(userScriptSandbox);
684 },
685 });
686
687 // Notify listeners subscribed to the userScripts.onBeforeScript API event,
688 // to allow extension API script to provide its custom APIs to the userScript.
689 if (apiScript) {
690 context.userScriptsEvents.emit(
691 "on-before-script",
692 this.scriptMetadata,
693 userScriptSandbox
694 );
695 }
696
697 for (let script of sandboxScripts) {
698 script.executeInGlobal(userScriptSandbox);
699 }
700 } finally {
701 ExtensionTelemetry.userScriptInjection.stopwatchFinish(
702 extension,
703 context
704 );
705 }
706 }
707
708 createSandbox(context) {
709 const { contentWindow } = context;
710 const contentPrincipal = contentWindow.document.nodePrincipal;
711 const ssm = Services.scriptSecurityManager;
712
713 let principal;
714 if (contentPrincipal.isSystemPrincipal) {
715 principal = ssm.createNullPrincipal(contentPrincipal.originAttributes);
716 } else {
717 principal = [contentPrincipal];
718 }
719
720 const sandbox = Cu.Sandbox(principal, {
721 sandboxName: `User Script registered by ${
722 this.extension.policy.debugName
723 }`,
724 sandboxPrototype: contentWindow,
725 sameZoneAs: contentWindow,
726 wantXrays: true,
727 wantGlobalProperties: ["XMLHttpRequest", "fetch"],
728 originAttributes: contentPrincipal.originAttributes,
729 metadata: {
730 "inner-window-id": context.innerWindowID,
731 addonId: this.extension.policy.id,
732 },
733 });
734
735 return sandbox;
736 }
737}
738
739var contentScripts = new DefaultWeakMap(matcher => {
740 const extension = ExtensionProcessScript.extensions.get(matcher.extension);
741
742 if ("userScriptOptions" in matcher) {
743 return new UserScript(extension, matcher);
744 }
745
746 return new Script(extension, matcher);
747});
748
749/**
750 * An execution context for semi-privileged extension content scripts.
751 *
752 * This is the child side of the ContentScriptContextParent class
753 * defined in ExtensionParent.jsm.
754 */
755class ContentScriptContextChild extends BaseContext {
756 constructor(extension, contentWindow) {
757 super("content_child", extension);
758
759 this.setContentWindow(contentWindow);
760
761 let frameId = WebNavigationFrames.getFrameId(contentWindow);
762 this.frameId = frameId;
763
764 this.browsingContextId = contentWindow.docShell.browsingContext.id;
765
766 this.scripts = [];
767
768 let contentPrincipal = contentWindow.document.nodePrincipal;
769 let ssm = Services.scriptSecurityManager;
770
771 // Copy origin attributes from the content window origin attributes to
772 // preserve the user context id.
773 let attrs = contentPrincipal.originAttributes;
774 let extensionPrincipal = ssm.createContentPrincipal(
775 this.extension.baseURI,
776 attrs
777 );
778
779 this.isExtensionPage = contentPrincipal.equals(extensionPrincipal);
780
781 if (this.isExtensionPage) {
782 // This is an iframe with content script API enabled and its principal
783 // should be the contentWindow itself. We create a sandbox with the
784 // contentWindow as principal and with X-rays disabled because it
785 // enables us to create the APIs object in this sandbox object and then
786 // copying it into the iframe's window. See bug 1214658.
787 this.sandbox = Cu.Sandbox(contentWindow, {
788 sandboxName: `Web-Accessible Extension Page ${
789 extension.policy.debugName
790 }`,
791 sandboxPrototype: contentWindow,
792 sameZoneAs: contentWindow,
793 wantXrays: false,
794 isWebExtensionContentScript: true,
795 });
796 } else {
797 let principal;
798 if (contentPrincipal.isSystemPrincipal) {
799 // Make sure we don't hand out the system principal by accident.
800 // Also make sure that the null principal has the right origin attributes.
801 principal = ssm.createNullPrincipal(attrs);
802 } else {
803 principal = [contentPrincipal, extensionPrincipal];
804 }
805 // This metadata is required by the Developer Tools, in order for
806 // the content script to be associated with both the extension and
807 // the tab holding the content page.
808 let metadata = {
809 "inner-window-id": this.innerWindowID,
810 addonId: extensionPrincipal.addonId,
811 };
812
813 this.sandbox = Cu.Sandbox(principal, {
814 metadata,
815 sandboxName: `Content Script ${extension.policy.debugName}`,
816 sandboxPrototype: contentWindow,
817 sameZoneAs: contentWindow,
818 wantXrays: true,
819 isWebExtensionContentScript: true,
820 wantExportHelpers: true,
821 wantGlobalProperties: ["XMLHttpRequest", "fetch"],
822 originAttributes: attrs,
823 });
824
825 // Preserve a copy of the original Error and Promise globals from the sandbox object,
826 // which are used in the WebExtensions internals (before any content script code had
827 // any chance to redefine them).
828 this.cloneScopePromise = this.sandbox.Promise;
829 this.cloneScopeError = this.sandbox.Error;
830
831 // Preserve a copy of the original window's XMLHttpRequest and fetch
832 // in a content object (fetch is manually binded to the window
833 // to prevent it from raising a TypeError because content object is not
834 // a real window).
835 Cu.evalInSandbox(
836 `
837 this.content = {
838 XMLHttpRequest: window.XMLHttpRequest,
839 fetch: window.fetch.bind(window),
840 };
841
842 window.JSON = JSON;
843 window.XMLHttpRequest = XMLHttpRequest;
844 window.fetch = fetch;
845 `,
846 this.sandbox
847 );
848 }
849
850 Object.defineProperty(this, "principal", {
851 value: Cu.getObjectPrincipal(this.sandbox),
852 enumerable: true,
853 configurable: true,
854 });
855
856 this.url = contentWindow.location.href;
857
858 defineLazyGetter(this, "chromeObj", () => {
859 let chromeObj = Cu.createObjectIn(this.sandbox);
860
861 this.childManager.inject(chromeObj);
862 return chromeObj;
863 });
864
865 Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
866 Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
867
868 // Keep track if the userScript API script has been already executed in this context
869 // (e.g. because there are more then one UserScripts that match the related webpage
870 // and so the UserScript apiScript has already been executed).
871 this.hasUserScriptAPIs = false;
872
873 // A lazy created EventEmitter related to userScripts-specific events.
874 defineLazyGetter(this, "userScriptsEvents", () => {
875 return new ExtensionCommon.EventEmitter();
876 });
877 }
878
879 injectAPI() {
880 if (!this.isExtensionPage) {
881 throw new Error("Cannot inject extension API into non-extension window");
882 }
883
884 // This is an iframe with content script API enabled (See Bug 1214658)
885 Schemas.exportLazyGetter(
886 this.contentWindow,
887 "browser",
888 () => this.chromeObj
889 );
890 Schemas.exportLazyGetter(
891 this.contentWindow,
892 "chrome",
893 () => this.chromeObj
894 );
895 }
896
897 async logActivity(type, name, data) {
898 ExtensionActivityLogChild.log(this, type, name, data);
899 }
900
901 get cloneScope() {
902 return this.sandbox;
903 }
904
905 async executeAPIScript(apiScript) {
906 // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts
907 // match the same webpage and the apiScript has already been executed).
908 if (apiScript && !this.hasUserScriptAPIs) {
909 this.hasUserScriptAPIs = true;
910 apiScript.executeInGlobal(this.cloneScope);
911 }
912 }
913
914 addScript(script) {
915 if (script.requiresCleanup) {
916 this.scripts.push(script);
917 }
918 }
919
920 close() {
921 super.unload();
922
923 // Cleanup the scripts even if the contentWindow have been destroyed.
924 for (let script of this.scripts) {
925 script.cleanup(this.contentWindow);
926 }
927
928 if (this.contentWindow) {
929 // Overwrite the content script APIs with an empty object if the APIs objects are still
930 // defined in the content window (See Bug 1214658).
931 if (this.isExtensionPage) {
932 Cu.createObjectIn(this.contentWindow, { defineAs: "browser" });
933 Cu.createObjectIn(this.contentWindow, { defineAs: "chrome" });
934 }
935 }
936 Cu.nukeSandbox(this.sandbox);
937
938 this.sandbox = null;
939 }
940}
941
942defineLazyGetter(ContentScriptContextChild.prototype, "messenger", function() {
943 // The |sender| parameter is passed directly to the extension.
944 let sender = { id: this.extension.id, frameId: this.frameId, url: this.url };
945 let filter = { extensionId: this.extension.id };
946 let optionalFilter = { frameId: this.frameId };
947
948 return new Messenger(
949 this,
950 [this.messageManager],
951 sender,
952 filter,
953 optionalFilter
954 );
955});
956
957defineLazyGetter(
958 ContentScriptContextChild.prototype,
959 "childManager",
960 function() {
961 apiManager.lazyInit();
962
963 let localApis = {};
964 let can = new CanOfAPIs(this, apiManager, localApis);
965
966 let childManager = new ChildAPIManager(this, this.messageManager, can, {
967 envType: "content_parent",
968 url: this.url,
969 });
970
971 this.callOnClose(childManager);
972
973 return childManager;
974 }
975);
976
977// Responsible for creating ExtensionContexts and injecting content
978// scripts into them when new documents are created.
979DocumentManager = {
980 // Map[windowId -> Map[ExtensionChild -> ContentScriptContextChild]]
981 contexts: new Map(),
982
983 initialized: false,
984
985 lazyInit() {
986 if (this.initialized) {
987 return;
988 }
989 this.initialized = true;
990
991 Services.obs.addObserver(this, "inner-window-destroyed");
992 Services.obs.addObserver(this, "memory-pressure");
993 },
994
995 uninit() {
996 Services.obs.removeObserver(this, "inner-window-destroyed");
997 Services.obs.removeObserver(this, "memory-pressure");
998 },
999
1000 observers: {
1001 "inner-window-destroyed"(subject, topic, data) {
1002 let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
1003
1004 MessageChannel.abortResponses({ innerWindowID: windowId });
1005
1006 // Close any existent content-script context for the destroyed window.
1007 if (this.contexts.has(windowId)) {
1008 let extensions = this.contexts.get(windowId);
1009 for (let context of extensions.values()) {
1010 context.close();
1011 }
1012
1013 this.contexts.delete(windowId);
1014 }
1015 },
1016 "memory-pressure"(subject, topic, data) {
1017 let timeout = data === "heap-minimize" ? 0 : undefined;
1018
1019 for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys(
1020 scriptCaches
1021 )) {
1022 cache.clear(timeout);
1023 }
1024 },
1025 },
1026
1027 observe(subject, topic, data) {
1028 this.observers[topic].call(this, subject, topic, data);
1029 },
1030
1031 shutdownExtension(extension) {
1032 for (let extensions of this.contexts.values()) {
1033 let context = extensions.get(extension);
1034 if (context) {
1035 context.close();
1036 extensions.delete(extension);
1037 }
1038 }
1039 },
1040
1041 getContexts(window) {
1042 let winId = getInnerWindowID(window);
1043
1044 let extensions = this.contexts.get(winId);
1045 if (!extensions) {
1046 extensions = new Map();
1047 this.contexts.set(winId, extensions);
1048 }
1049
1050 return extensions;
1051 },
1052
1053 // For test use only.
1054 getContext(extensionId, window) {
1055 for (let [extension, context] of this.getContexts(window)) {
1056 if (extension.id === extensionId) {
1057 return context;
1058 }
1059 }
1060 },
1061
1062 getContentScriptGlobals(window) {
1063 let extensions = this.contexts.get(getInnerWindowID(window));
1064
1065 if (extensions) {
1066 return Array.from(extensions.values(), ctx => ctx.sandbox);
1067 }
1068
1069 return [];
1070 },
1071
1072 initExtensionContext(extension, window) {
1073 extension.getContext(window).injectAPI();
1074 },
1075};
1076
1077var ExtensionContent = {
1078 BrowserExtensionContent,
1079
1080 contentScripts,
1081
1082 shutdownExtension(extension) {
1083 DocumentManager.shutdownExtension(extension);
1084 },
1085
1086 // This helper is exported to be integrated in the devtools RDP actors,
1087 // that can use it to retrieve the existent WebExtensions ContentScripts
1088 // of a target window and be able to show the ContentScripts source in the
1089 // DevTools Debugger panel.
1090 getContentScriptGlobals(window) {
1091 return DocumentManager.getContentScriptGlobals(window);
1092 },
1093
1094 initExtensionContext(extension, window) {
1095 DocumentManager.initExtensionContext(extension, window);
1096 },
1097
1098 getContext(extension, window) {
1099 let extensions = DocumentManager.getContexts(window);
1100
1101 let context = extensions.get(extension);
1102 if (!context) {
1103 context = new ContentScriptContextChild(extension, window);
1104 extensions.set(extension, context);
1105 }
1106 return context;
1107 },
1108
1109 handleExtensionCapture(global, width, height, options) {
1110 let win = global.content;
1111
1112 const XHTML_NS = "http://www.w3.org/1999/xhtml";
1113 let canvas = win.document.createElementNS(XHTML_NS, "canvas");
1114 canvas.width = width;
1115 canvas.height = height;
1116 canvas.mozOpaque = true;
1117
1118 let ctx = canvas.getContext("2d");
1119
1120 // We need to scale the image to the visible size of the browser,
1121 // in order for the result to appear as the user sees it when
1122 // settings like full zoom come into play.
1123 ctx.scale(canvas.width / win.innerWidth, canvas.height / win.innerHeight);
1124
1125 ctx.drawWindow(
1126 win,
1127 win.scrollX,
1128 win.scrollY,
1129 win.innerWidth,
1130 win.innerHeight,
1131 "#fff"
1132 );
1133
1134 return canvas.toDataURL(`image/${options.format}`, options.quality / 100);
1135 },
1136
1137 handleDetectLanguage(global, target) {
1138 let doc = target.content.document;
1139
1140 return promiseDocumentReady(doc).then(() => {
1141 let elem = doc.documentElement;
1142
1143 let language =
1144 elem.getAttribute("xml:lang") ||
1145 elem.getAttribute("lang") ||
1146 doc.contentLanguage ||
1147 null;
1148
1149 // We only want the last element of the TLD here.
1150 // Only country codes have any effect on the results, but other
1151 // values cause no harm.
1152 let tld = doc.location.hostname.match(/[a-z]*$/)[0];
1153
1154 // The CLD2 library used by the language detector is capable of
1155 // analyzing raw HTML. Unfortunately, that takes much more memory,
1156 // and since it's hosted by emscripten, and therefore can't shrink
1157 // its heap after it's grown, it has a performance cost.
1158 // So we send plain text instead.
1159 let encoder = Cu.createDocumentEncoder("text/plain");
1160 encoder.init(
1161 doc,
1162 "text/plain",
1163 Ci.nsIDocumentEncoder.SkipInvisibleContent
1164 );
1165 let text = encoder.encodeToStringWithMaxLength(60 * 1024);
1166
1167 let encoding = doc.characterSet;
1168
1169 return LanguageDetector.detectLanguage({
1170 language,
1171 tld,
1172 text,
1173 encoding,
1174 }).then(result => (result.language === "un" ? "und" : result.language));
1175 });
1176 },
1177
1178 // Used to executeScript, insertCSS and removeCSS.
1179 async handleExtensionExecute(global, target, options, script) {
1180 let executeInWin = window => {
1181 if (script.matchesWindow(window)) {
1182 return script.injectInto(window);
1183 }
1184 return null;
1185 };
1186
1187 let promises;
1188 try {
1189 promises = Array.from(
1190 this.enumerateWindows(global.docShell),
1191 executeInWin
1192 ).filter(promise => promise);
1193 } catch (e) {
1194 Cu.reportError(e);
1195 return Promise.reject({ message: "An unexpected error occurred" });
1196 }
1197
1198 if (!promises.length) {
1199 if (options.frameID) {
1200 return Promise.reject({
1201 message: `Frame not found, or missing host permission`,
1202 });
1203 }
1204
1205 let frames = options.allFrames ? ", and any iframes" : "";
1206 return Promise.reject({
1207 message: `Missing host permission for the tab${frames}`,
1208 });
1209 }
1210 if (!options.allFrames && promises.length > 1) {
1211 return Promise.reject({
1212 message: `Internal error: Script matched multiple windows`,
1213 });
1214 }
1215
1216 let result = await Promise.all(promises);
1217
1218 try {
1219 // Make sure we can structured-clone the result value before
1220 // we try to send it back over the message manager.
1221 Cu.cloneInto(result, target);
1222 } catch (e) {
1223 const { jsPaths } = options;
1224 const fileName = jsPaths.length
1225 ? jsPaths[jsPaths.length - 1]
1226 : "<anonymous code>";
1227 const message = `Script '${fileName}' result is non-structured-clonable data`;
1228 return Promise.reject({ message, fileName });
1229 }
1230
1231 return result;
1232 },
1233
1234 handleWebNavigationGetFrame(global, { frameId }) {
1235 return WebNavigationFrames.getFrame(global.docShell, frameId);
1236 },
1237
1238 handleWebNavigationGetAllFrames(global) {
1239 return WebNavigationFrames.getAllFrames(global.docShell);
1240 },
1241
1242 async receiveMessage(global, name, target, data, recipient) {
1243 switch (name) {
1244 case "Extension:Capture":
1245 return this.handleExtensionCapture(
1246 global,
1247 data.width,
1248 data.height,
1249 data.options
1250 );
1251 case "Extension:DetectLanguage":
1252 return this.handleDetectLanguage(global, target);
1253 case "Extension:Execute":
1254 let policy = WebExtensionPolicy.getByID(recipient.extensionId);
1255
1256 let matcher = new WebExtensionContentScript(policy, data.options);
1257
1258 Object.assign(matcher, {
1259 wantReturnValue: data.options.wantReturnValue,
1260 removeCSS: data.options.removeCSS,
1261 cssOrigin: data.options.cssOrigin,
1262 jsCode: data.options.jsCode,
1263 });
1264
1265 let script = contentScripts.get(matcher);
1266
1267 // Add the cssCode to the script, so that it can be converted into a cached URL.
1268 await script.addCSSCode(data.options.cssCode);
1269 delete data.options.cssCode;
1270
1271 return this.handleExtensionExecute(
1272 global,
1273 target,
1274 data.options,
1275 script
1276 );
1277 case "WebNavigation:GetFrame":
1278 return this.handleWebNavigationGetFrame(global, data.options);
1279 case "WebNavigation:GetAllFrames":
1280 return this.handleWebNavigationGetAllFrames(global);
1281 }
1282 return null;
1283 },
1284
1285 // Helpers
1286
1287 *enumerateWindows(docShell) {
1288 let enum_ = docShell.getDocShellEnumerator(
1289 docShell.typeContent,
1290 docShell.ENUMERATE_FORWARDS
1291 );
1292
1293 for (let docShell of enum_) {
1294 try {
1295 yield docShell.domWindow;
1296 } catch (e) {
1297 // This can fail if the docShell is being destroyed, so just
1298 // ignore the error.
1299 }
1300 }
1301 },
1302};