Browse Source

feat: Add content script world isolation (#17032)

* Execute content script in isolated world

* Inject script into newly created extension worlds

* Create new content_script_bundle for extension scripts

* Initialize chrome API in content script bundle

* Define Chrome extension isolated world ID range

1 << 20 was chosen as it provides a sufficiently large range of IDs for extensions, but also provides a large enough buffer for any user worlds in [1000, 1 << 20).

Ultimately this range can be changed if any user application raises it as an issue.

* Insert content script CSS into document

This now avoids a script wrapper to inject the style sheet. This closely matches the code used by chromium in `ScriptInjection::InjectCss`.

* Pass extension ID to isolated world via v8 private
Samuel Maddock 6 years ago
parent
commit
f943db7ad5

+ 33 - 0
BUILD.gn

@@ -137,6 +137,37 @@ npm_action("atom_browserify_isolated") {
   ]
 }
 
+npm_action("atom_browserify_content_script") {
+  script = "browserify"
+  deps = [
+    ":build_electron_definitions",
+  ]
+
+  inputs = [
+    "lib/content_script/init.js",
+    "tsconfig.electron.json",
+    "tsconfig.json",
+  ]
+
+  outputs = [
+    "$target_gen_dir/js2c/content_script_bundle.js",
+  ]
+
+  args = [
+    "lib/content_script/init.js",
+    "-t",
+    "aliasify",
+    "-p",
+    "[",
+    "tsify",
+    "-p",
+    "tsconfig.electron.json",
+    "]",
+    "-o",
+    rebase_path(outputs[0]),
+  ]
+}
+
 copy("atom_js2c_copy") {
   sources = [
     "lib/common/asar.js",
@@ -149,12 +180,14 @@ copy("atom_js2c_copy") {
 
 action("atom_js2c") {
   deps = [
+    ":atom_browserify_content_script",
     ":atom_browserify_isolated",
     ":atom_browserify_sandbox",
     ":atom_js2c_copy",
   ]
 
   browserify_sources = [
+    "$target_gen_dir/js2c/content_script_bundle.js",
     "$target_gen_dir/js2c/isolated_bundle.js",
     "$target_gen_dir/js2c/preload_bundle.js",
   ]

+ 6 - 0
atom/renderer/atom_render_frame_observer.cc

@@ -113,6 +113,12 @@ void AtomRenderFrameObserver::DidCreateScriptContext(
     CreateIsolatedWorldContext();
     renderer_client_->SetupMainWorldOverrides(context, render_frame_);
   }
+
+  if (world_id >= World::ISOLATED_WORLD_EXTENSIONS &&
+      world_id <= World::ISOLATED_WORLD_EXTENSIONS_END) {
+    renderer_client_->SetupExtensionWorldOverrides(context, render_frame_,
+                                                   world_id);
+  }
 }
 
 void AtomRenderFrameObserver::DraggableRegionsChanged() {

+ 12 - 1
atom/renderer/atom_render_frame_observer.h

@@ -11,6 +11,7 @@
 #include "base/strings/string16.h"
 #include "content/public/renderer/render_frame_observer.h"
 #include "ipc/ipc_platform_file.h"
+#include "third_party/blink/public/platform/web_isolated_world_ids.h"
 #include "third_party/blink/public/web/web_local_frame.h"
 
 namespace base {
@@ -21,9 +22,19 @@ namespace atom {
 
 enum World {
   MAIN_WORLD = 0,
+
   // Use a high number far away from 0 to not collide with any other world
   // IDs created internally by Chrome.
-  ISOLATED_WORLD = 999
+  ISOLATED_WORLD = 999,
+
+  // Numbers for isolated worlds for extensions are set in
+  // lib/renderer/content-script-injector.ts, and are greater than or equal to
+  // this number, up to ISOLATED_WORLD_EXTENSIONS_END.
+  ISOLATED_WORLD_EXTENSIONS = 1 << 20,
+
+  // Last valid isolated world ID.
+  ISOLATED_WORLD_EXTENSIONS_END =
+      blink::IsolatedWorldId::kEmbedderWorldIdLimit - 1
 };
 
 // Helper class to forward the messages to the client.

+ 21 - 0
atom/renderer/atom_renderer_client.cc

@@ -210,6 +210,27 @@ void AtomRendererClient::SetupMainWorldOverrides(
       &isolated_bundle_args, nullptr);
 }
 
+void AtomRendererClient::SetupExtensionWorldOverrides(
+    v8::Handle<v8::Context> context,
+    content::RenderFrame* render_frame,
+    int world_id) {
+  auto* isolate = context->GetIsolate();
+
+  std::vector<v8::Local<v8::String>> isolated_bundle_params = {
+      node::FIXED_ONE_BYTE_STRING(isolate, "nodeProcess"),
+      node::FIXED_ONE_BYTE_STRING(isolate, "isolatedWorld"),
+      node::FIXED_ONE_BYTE_STRING(isolate, "worldId")};
+
+  std::vector<v8::Local<v8::Value>> isolated_bundle_args = {
+      GetEnvironment(render_frame)->process_object(),
+      GetContext(render_frame->GetWebFrame(), isolate)->Global(),
+      v8::Integer::New(isolate, world_id)};
+
+  node::per_process::native_module_loader.CompileAndCall(
+      context, "electron/js2c/content_script_bundle", &isolated_bundle_params,
+      &isolated_bundle_args, nullptr);
+}
+
 node::Environment* AtomRendererClient::GetEnvironment(
     content::RenderFrame* render_frame) const {
   if (injected_frames_.find(render_frame) == injected_frames_.end())

+ 3 - 0
atom/renderer/atom_renderer_client.h

@@ -33,6 +33,9 @@ class AtomRendererClient : public RendererClientBase {
                                 content::RenderFrame* render_frame) override;
   void SetupMainWorldOverrides(v8::Handle<v8::Context> context,
                                content::RenderFrame* render_frame) override;
+  void SetupExtensionWorldOverrides(v8::Handle<v8::Context> context,
+                                    content::RenderFrame* render_frame,
+                                    int world_id) override;
 
  private:
   // content::ContentRendererClient:

+ 24 - 0
atom/renderer/atom_sandboxed_renderer_client.cc

@@ -270,6 +270,30 @@ void AtomSandboxedRendererClient::SetupMainWorldOverrides(
       &isolated_bundle_args, nullptr);
 }
 
+void AtomSandboxedRendererClient::SetupExtensionWorldOverrides(
+    v8::Handle<v8::Context> context,
+    content::RenderFrame* render_frame,
+    int world_id) {
+  auto* isolate = context->GetIsolate();
+
+  mate::Dictionary process = mate::Dictionary::CreateEmpty(isolate);
+  process.SetMethod("binding", GetBinding);
+
+  std::vector<v8::Local<v8::String>> isolated_bundle_params = {
+      node::FIXED_ONE_BYTE_STRING(isolate, "nodeProcess"),
+      node::FIXED_ONE_BYTE_STRING(isolate, "isolatedWorld"),
+      node::FIXED_ONE_BYTE_STRING(isolate, "worldId")};
+
+  std::vector<v8::Local<v8::Value>> isolated_bundle_args = {
+      process.GetHandle(),
+      GetContext(render_frame->GetWebFrame(), isolate)->Global(),
+      v8::Integer::New(isolate, world_id)};
+
+  node::per_process::native_module_loader.CompileAndCall(
+      context, "electron/js2c/content_script_bundle", &isolated_bundle_params,
+      &isolated_bundle_args, nullptr);
+}
+
 void AtomSandboxedRendererClient::WillReleaseScriptContext(
     v8::Handle<v8::Context> context,
     content::RenderFrame* render_frame) {

+ 3 - 0
atom/renderer/atom_sandboxed_renderer_client.h

@@ -32,6 +32,9 @@ class AtomSandboxedRendererClient : public RendererClientBase {
                                 content::RenderFrame* render_frame) override;
   void SetupMainWorldOverrides(v8::Handle<v8::Context> context,
                                content::RenderFrame* render_frame) override;
+  void SetupExtensionWorldOverrides(v8::Handle<v8::Context> context,
+                                    content::RenderFrame* render_frame,
+                                    int world_id) override;
   // content::ContentRendererClient:
   void RenderFrameCreated(content::RenderFrame*) override;
   void RenderViewCreated(content::RenderView*) override;

+ 3 - 0
atom/renderer/renderer_client_base.h

@@ -34,6 +34,9 @@ class RendererClientBase : public content::ContentRendererClient {
   virtual void DidClearWindowObject(content::RenderFrame* render_frame);
   virtual void SetupMainWorldOverrides(v8::Handle<v8::Context> context,
                                        content::RenderFrame* render_frame) = 0;
+  virtual void SetupExtensionWorldOverrides(v8::Handle<v8::Context> context,
+                                            content::RenderFrame* render_frame,
+                                            int world_id) = 0;
 
   bool isolated_world() const { return isolated_world_; }
 

+ 11 - 5
docs/api/web-frame.md

@@ -95,6 +95,12 @@ webFrame.setSpellCheckProvider('en-US', {
 })
 ```
 
+### `webFrame.insertCSS(css)`
+
+* `css` String - CSS source code.
+
+Inserts `css` as a style sheet in the document.
+
 ### `webFrame.insertText(text)`
 
 * `text` String
@@ -119,7 +125,7 @@ this limitation.
 
 ### `webFrame.executeJavaScriptInIsolatedWorld(worldId, scripts[, userGesture, callback])`
 
-* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature.  You can provide any integer here.
+* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature. Chrome extensions reserve the range of IDs in `[1 << 20, 1 << 29)`. You can provide any integer here.
 * `scripts` [WebSource[]](structures/web-source.md)
 * `userGesture` Boolean (optional) - Default is `false`.
 * `callback` Function (optional) - Called after script has been executed.
@@ -129,27 +135,27 @@ Work like `executeJavaScript` but evaluates `scripts` in an isolated context.
 
 ### `webFrame.setIsolatedWorldContentSecurityPolicy(worldId, csp)` _(Deprecated)_
 
-* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature.  You can provide any integer here.
+* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature. Chrome extensions reserve the range of IDs in `[1 << 20, 1 << 29)`. You can provide any integer here.
 * `csp` String
 
 Set the content security policy of the isolated world.
 
 ### `webFrame.setIsolatedWorldHumanReadableName(worldId, name)` _(Deprecated)_
 
-* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature.  You can provide any integer here.
+* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature. Chrome extensions reserve the range of IDs in `[1 << 20, 1 << 29)`. You can provide any integer here.
 * `name` String
 
 Set the name of the isolated world. Useful in devtools.
 
 ### `webFrame.setIsolatedWorldSecurityOrigin(worldId, securityOrigin)` _(Deprecated)_
 
-* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature.  You can provide any integer here.
+* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature. Chrome extensions reserve the range of IDs in `[1 << 20, 1 << 29)`. You can provide any integer here.
 * `securityOrigin` String
 
 Set the security origin of the isolated world.
 
 ### `webFrame.setIsolatedWorldInfo(worldId, info)`
-* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature.  You can provide any integer here.
+* `worldId` Integer - The ID of the world to run the javascript in, `0` is the default world, `999` is the world used by Electrons `contextIsolation` feature. Chrome extensions reserve the range of IDs in `[1 << 20, 1 << 29)`. You can provide any integer here.
 * `info` Object
   * `securityOrigin` String (optional) - Security origin for the isolated world.
   * `csp` String (optional) - Content Security Policy for the isolated world.

+ 35 - 0
lib/content_script/init.js

@@ -0,0 +1,35 @@
+'use strict'
+
+/* global nodeProcess, isolatedWorld, worldId */
+
+const { EventEmitter } = require('events')
+
+process.atomBinding = require('@electron/internal/common/atom-binding-setup').atomBindingSetup(nodeProcess.binding, 'renderer')
+
+const v8Util = process.atomBinding('v8_util')
+// The `lib/renderer/ipc-renderer-internal.js` module looks for the ipc object in the
+// "ipc-internal" hidden value
+v8Util.setHiddenValue(global, 'ipc-internal', new EventEmitter())
+// The process object created by browserify is not an event emitter, fix it so
+// the API is more compatible with non-sandboxed renderers.
+for (const prop of Object.keys(EventEmitter.prototype)) {
+  if (process.hasOwnProperty(prop)) {
+    delete process[prop]
+  }
+}
+Object.setPrototypeOf(process, EventEmitter.prototype)
+
+const isolatedWorldArgs = v8Util.getHiddenValue(isolatedWorld, 'isolated-world-args')
+
+if (isolatedWorldArgs) {
+  const { ipcRendererInternal, guestInstanceId, isHiddenPage, openerId, usesNativeWindowOpen } = isolatedWorldArgs
+  const { windowSetup } = require('@electron/internal/renderer/window-setup')
+  windowSetup(ipcRendererInternal, guestInstanceId, openerId, isHiddenPage, usesNativeWindowOpen)
+}
+
+const extensionId = v8Util.getHiddenValue(isolatedWorld, `extension-${worldId}`)
+
+if (extensionId) {
+  const chromeAPI = require('@electron/internal/renderer/chrome-api')
+  chromeAPI.injectTo(extensionId, false, window)
+}

+ 36 - 38
lib/renderer/content-scripts-injector.ts

@@ -1,5 +1,24 @@
 import { ipcRendererInternal } from '@electron/internal/renderer/ipc-renderer-internal'
-import { runInThisContext } from 'vm'
+import { webFrame } from 'electron'
+
+const v8Util = process.atomBinding('v8_util')
+
+const IsolatedWorldIDs = {
+  /**
+   * Start of extension isolated world IDs, as defined in
+   * atom_render_frame_observer.h
+   */
+  ISOLATED_WORLD_EXTENSIONS: 1 << 20
+}
+
+let isolatedWorldIds = IsolatedWorldIDs.ISOLATED_WORLD_EXTENSIONS
+const extensionWorldId: {[key: string]: number | undefined} = {}
+
+// https://cs.chromium.org/chromium/src/extensions/renderer/script_injection.cc?type=cs&sq=package:chromium&g=0&l=52
+const getIsolatedWorldIdForInstance = () => {
+  // TODO(samuelmaddock): allocate and cleanup IDs
+  return isolatedWorldIds++
+}
 
 // Check whether pattern matches.
 // https://developer.chrome.com/extensions/match_patterns
@@ -12,21 +31,21 @@ const matchesPattern = function (pattern: string) {
 
 // Run the code with chrome API integrated.
 const runContentScript = function (this: any, extensionId: string, url: string, code: string) {
-  const context: { chrome?: any } = {}
-  require('@electron/internal/renderer/chrome-api').injectTo(extensionId, false, context)
-  const wrapper = `((chrome) => {\n  ${code}\n  })`
-  try {
-    const compiledWrapper = runInThisContext(wrapper, {
-      filename: url,
-      lineOffset: 1,
-      displayErrors: true
-    })
-    return compiledWrapper.call(this, context.chrome)
-  } catch (error) {
-    // TODO(samuelmaddock): Run scripts in isolated world, see chromium script_injection.cc
-    console.error(`Error running content script JavaScript for '${extensionId}'`)
-    console.error(error)
-  }
+  // Assign unique world ID to each extension
+  const worldId = extensionWorldId[extensionId] ||
+    (extensionWorldId[extensionId] = getIsolatedWorldIdForInstance())
+
+  // store extension ID for content script to read in isolated world
+  v8Util.setHiddenValue(global, `extension-${worldId}`, extensionId)
+
+  webFrame.setIsolatedWorldInfo(worldId, {
+    name: `${extensionId} [${worldId}]`
+    // TODO(samuelmaddock): read `content_security_policy` from extension manifest
+    // csp: manifest.content_security_policy,
+  })
+
+  const sources = [{ code, url }]
+  webFrame.executeJavaScriptInIsolatedWorld(worldId, sources)
 }
 
 const runAllContentScript = function (scripts: Array<Electron.InjectionBase>, extensionId: string) {
@@ -36,28 +55,7 @@ const runAllContentScript = function (scripts: Array<Electron.InjectionBase>, ex
 }
 
 const runStylesheet = function (this: any, url: string, code: string) {
-  const wrapper = `((code) => {
-    function init() {
-      const styleElement = document.createElement('style');
-      styleElement.textContent = code;
-      document.head.append(styleElement);
-    }
-    document.addEventListener('DOMContentLoaded', init);
-  })`
-
-  try {
-    const compiledWrapper = runInThisContext(wrapper, {
-      filename: url,
-      lineOffset: 1,
-      displayErrors: true
-    })
-
-    return compiledWrapper.call(this, code)
-  } catch (error) {
-    // TODO(samuelmaddock): Insert stylesheet directly into document, see chromium script_injection.cc
-    console.error(`Error inserting content script stylesheet ${url}`)
-    console.error(error)
-  }
+  webFrame.insertCSS(code)
 }
 
 const runAllStylesheet = function (css: Array<Electron.InjectionBase>) {