Bug 1403536 - Protect all protocol request methods against unsafe objects. r=ochameau a=gchang
☠☠ backed out by e827926678c2 ☠ ☠
authorOriol Brufau <oriol-bugzilla@hotmail.com>
Mon, 11 Dec 2017 14:40:00 +0200
changeset 445344 cff7ced6ee40545e2b49e23480666276028c768e
parent 445343 4233a4056350e831fdbfca672429dd8f7d24b086
child 445345 e827926678c2bce27e5116555cf781cf215990a7
push id1618
push userCallek@gmail.com
push dateThu, 11 Jan 2018 17:45:48 +0000
treeherdermozilla-release@882ca853e05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersochameau, gchang
bugs1403536
milestone58.0
Bug 1403536 - Protect all protocol request methods against unsafe objects. r=ochameau a=gchang
devtools/client/webconsole/new-console-output/test/mochitest/browser_console.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_console_dead_objects.js
devtools/client/webconsole/test/browser_console.js
devtools/client/webconsole/test/browser_console_dead_objects.js
devtools/server/actors/object.js
devtools/server/tests/unit/test_objectgrips-12.js
devtools/server/tests/unit/test_objectgrips-17.js
devtools/server/tests/unit/test_objectgrips-21.js
devtools/server/tests/unit/xpcshell.ini
devtools/shared/DevToolsUtils.js
devtools/shared/webconsole/test/test_consoleapi.html
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser_console.js
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_console.js
@@ -199,14 +199,48 @@ function* testCPOWInspection(hud) {
     "Looks like a valid response");
 
   // The CPOW is in the _contentWindow property.
   let cpow = prototypeAndProperties.ownProperties._contentWindow.value;
 
   // But it's only a CPOW in e10s.
   let e10sCheck = yield hud.jsterm.requestEvaluation(
     "Cu.isCrossProcessWrapper(gBrowser.selectedBrowser._contentWindow)");
-  if (e10sCheck.result) {
-    is(cpow.class, "CPOW: Window", "The CPOW grip has the right class.");
-  } else {
+  if (!e10sCheck.result) {
     is(cpow.class, "Window", "The object is not a CPOW.");
+    return;
   }
+
+  is(cpow.class, "CPOW: Window", "The CPOW grip has the right class.");
+
+  // Check that various protocol request methods work for the CPOW.
+  let response, slice;
+  let objClient = new ObjectClient(hud.jsterm.hud.proxy.client, cpow);
+
+  response = yield objClient.getPrototypeAndProperties();
+  is(Reflect.ownKeys(response.ownProperties).length, 0, "No property was retrieved.");
+  is(response.ownSymbols.length, 0, "No symbol property was retrieved.");
+  is(response.prototype.type, "null", "The prototype is null.");
+
+  response = yield objClient.enumProperties({ignoreIndexedProperties: true});
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(Reflect.ownKeys(slice.ownProperties).length, 0, "No property was retrieved.");
+
+  response = yield objClient.enumProperties({});
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(Reflect.ownKeys(slice.ownProperties).length, 0, "No property was retrieved.");
+
+  response = yield objClient.getOwnPropertyNames();
+  is(response.ownPropertyNames.length, 0, "No property was retrieved.");
+
+  response = yield objClient.getProperty("x");
+  is(response.descriptor, undefined, "The property does not exist.");
+
+  response = yield objClient.enumSymbols();
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(slice.ownSymbols.length, 0, "No symbol property was retrieved.");
+
+  response = yield objClient.getPrototype();
+  is(response.prototype.type, "null", "The prototype is null.");
+
+  response = yield objClient.getDisplayString();
+  is(response.displayString, "<cpow>", "The CPOW stringifies to <cpow>");
 }
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser_console_dead_objects.js
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_console_dead_objects.js
@@ -54,34 +54,34 @@ function test() {
 
     yield TestUtils.topicObserved("outer-window-nuked", (subject, data) => {
       let id = subject.QueryInterface(Components.interfaces.nsISupportsPRUint64).data;
       return id == winID;
     });
 
     let msg = yield jsterm.execute("foobarzTezt");
 
-    isnot(hud.outputNode.textContent.indexOf("[object DeadObject]"), -1,
+    isnot(hud.outputNode.textContent.indexOf("DeadObject"), -1,
           "dead object found");
 
     jsterm.setInputValue("foobarzTezt");
 
     for (let c of ".hello") {
       EventUtils.synthesizeKey(c, {}, hud.iframeWindow);
     }
 
     yield jsterm.execute();
 
     isnot(hud.outputNode.textContent.indexOf("can't access dead object"), -1,
           "'cannot access dead object' message found");
 
     // Click the second execute output.
     let clickable = msg.querySelector("a");
     ok(clickable, "clickable object found");
-    isnot(clickable.textContent.indexOf("[object DeadObject]"), -1,
+    isnot(clickable.textContent.indexOf("DeadObject"), -1,
           "message text check");
 
     msg.scrollIntoView();
 
     executeSoon(() => {
       EventUtils.synthesizeMouseAtCenter(clickable, {}, hud.iframeWindow);
     });
 
--- a/devtools/client/webconsole/test/browser_console.js
+++ b/devtools/client/webconsole/test/browser_console.js
@@ -199,14 +199,48 @@ function* testCPOWInspection(hud) {
     "Looks like a valid response");
 
   // The CPOW is in the _contentWindow property.
   let cpow = prototypeAndProperties.ownProperties._contentWindow.value;
 
   // But it's only a CPOW in e10s.
   let e10sCheck = yield hud.jsterm.requestEvaluation(
     "Cu.isCrossProcessWrapper(gBrowser.selectedBrowser._contentWindow)");
-  if (e10sCheck.result) {
-    is(cpow.class, "CPOW: Window", "The CPOW grip has the right class.");
-  } else {
+  if (!e10sCheck.result) {
     is(cpow.class, "Window", "The object is not a CPOW.");
+    return;
   }
+
+  is(cpow.class, "CPOW: Window", "The CPOW grip has the right class.");
+
+  // Check that various protocol request methods work for the CPOW.
+  let response, slice;
+  let objClient = new ObjectClient(hud.jsterm.hud.proxy.client, cpow);
+
+  response = yield objClient.getPrototypeAndProperties();
+  is(Reflect.ownKeys(response.ownProperties).length, 0, "No property was retrieved.");
+  is(response.ownSymbols.length, 0, "No symbol property was retrieved.");
+  is(response.prototype.type, "null", "The prototype is null.");
+
+  response = yield objClient.enumProperties({ignoreIndexedProperties: true});
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(Reflect.ownKeys(slice.ownProperties).length, 0, "No property was retrieved.");
+
+  response = yield objClient.enumProperties({});
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(Reflect.ownKeys(slice.ownProperties).length, 0, "No property was retrieved.");
+
+  response = yield objClient.getOwnPropertyNames();
+  is(response.ownPropertyNames.length, 0, "No property was retrieved.");
+
+  response = yield objClient.getProperty("x");
+  is(response.descriptor, undefined, "The property does not exist.");
+
+  response = yield objClient.enumSymbols();
+  slice = yield response.iterator.slice(0, response.iterator.count);
+  is(slice.ownSymbols.length, 0, "No symbol property was retrieved.");
+
+  response = yield objClient.getPrototype();
+  is(response.prototype.type, "null", "The prototype is null.");
+
+  response = yield objClient.getDisplayString();
+  is(response.displayString, "<cpow>", "The CPOW stringifies to <cpow>");
 }
--- a/devtools/client/webconsole/test/browser_console_dead_objects.js
+++ b/devtools/client/webconsole/test/browser_console_dead_objects.js
@@ -54,34 +54,34 @@ function test() {
 
     yield TestUtils.topicObserved("outer-window-nuked", (subject, data) => {
       let id = subject.QueryInterface(Components.interfaces.nsISupportsPRUint64).data;
       return id == winID;
     });
 
     let msg = yield jsterm.execute("foobarzTezt");
 
-    isnot(hud.outputNode.textContent.indexOf("[object DeadObject]"), -1,
+    isnot(hud.outputNode.textContent.indexOf("DeadObject"), -1,
           "dead object found");
 
     jsterm.setInputValue("foobarzTezt");
 
     for (let c of ".hello") {
       EventUtils.synthesizeKey(c, {}, hud.iframeWindow);
     }
 
     yield jsterm.execute();
 
     isnot(hud.outputNode.textContent.indexOf("can't access dead object"), -1,
           "'cannot access dead object' message found");
 
     // Click the second execute output.
     let clickable = msg.querySelector("a");
     ok(clickable, "clickable object found");
-    isnot(clickable.textContent.indexOf("[object DeadObject]"), -1,
+    isnot(clickable.textContent.indexOf("DeadObject"), -1,
           "message text check");
 
     msg.scrollIntoView();
 
     executeSoon(() => {
       EventUtils.synthesizeMouseAtCenter(clickable, {}, hud.iframeWindow);
     });
 
--- a/devtools/server/actors/object.js
+++ b/devtools/server/actors/object.js
@@ -5,17 +5,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { Cu, Ci } = require("chrome");
 const { GeneratedLocation } = require("devtools/server/actors/common");
 const { DebuggerServer } = require("devtools/server/main");
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
-const { assert, dumpn } = DevToolsUtils;
+const { assert } = DevToolsUtils;
 
 loader.lazyRequireGetter(this, "ChromeUtils");
 
 // Number of items to preview in objects, arrays, maps, sets, lists,
 // collections, etc.
 const OBJECT_PREVIEW_MAX_ITEMS = 10;
 
 /**
@@ -70,77 +70,86 @@ ObjectActor.prototype = {
   actorPrefix: "obj",
 
   /**
    * Returns a grip for this actor for returning in a protocol message.
    */
   grip: function () {
     let g = {
       "type": "object",
-      "actor": this.actorID
+      "actor": this.actorID,
+      "class": this.obj.class,
     };
 
-    // Check if the object has a wrapper which denies access. It may be a CPOW or a
-    // security wrapper. Change the class so that this will be visible in the UI.
     let unwrapped = DevToolsUtils.unwrap(this.obj);
-    if (!unwrapped) {
+
+    // Unsafe objects must be treated carefully.
+    if (!DevToolsUtils.isSafeDebuggerObject(this.obj)) {
       if (DevToolsUtils.isCPOW(this.obj)) {
-        g.class = "CPOW: " + this.obj.class;
-      } else {
-        g.class = "Inaccessible";
+        // Cross-process object wrappers can't be accessed.
+        g.class = "CPOW: " + g.class;
+      } else if (unwrapped === undefined) {
+        // Objects belonging to an invisible-to-debugger compartment might be proxies,
+        // so just in case they shouldn't be accessed.
+        g.class = "InvisibleToDebugger: " + g.class;
+      } else if (unwrapped.isProxy) {
+        // Proxy objects can run traps when accessed, so just create a preview with
+        // the target and the handler.
+        g.class = "Proxy";
+        this.hooks.incrementGripDepth();
+        DebuggerServer.ObjectActorPreviewers.Proxy[0](this, g, null);
+        this.hooks.decrementGripDepth();
       }
       return g;
     }
 
-    // Dead objects also deny access.
-    if (this.obj.class == "DeadObject") {
-      g.class = "DeadObject";
-      return g;
+    // If the debuggee does not subsume the object's compartment, most properties won't
+    // be accessible. Cross-orgin Window and Location objects might expose some, though.
+    // Change the displayed class, but when creating the preview use the original one.
+    if (unwrapped === null) {
+      g.class = "Restricted";
     }
 
-    // Otherwise, increment grip depth and attempt to create a preview.
     this.hooks.incrementGripDepth();
 
-    // The `isProxy` getter is called on `unwrapped` instead of `this.obj` in order
-    // to detect proxies behind transparent wrappers, and thus avoid running traps.
-    if (unwrapped.isProxy) {
-      g.class = "Proxy";
-    } else {
-      g.class = this.obj.class;
-      g.extensible = this.obj.isExtensible();
-      g.frozen = this.obj.isFrozen();
-      g.sealed = this.obj.isSealed();
-    }
+    g.extensible = this.obj.isExtensible();
+    g.frozen = this.obj.isFrozen();
+    g.sealed = this.obj.isSealed();
 
     if (g.class == "Promise") {
       g.promiseState = this._createPromiseState();
     }
 
     // FF40+: Allow to know how many properties an object has to lazily display them
     // when there is a bunch.
     if (isTypedArray(g)) {
       // Bug 1348761: getOwnPropertyNames is unnecessary slow on TypedArrays
       g.ownPropertyLength = getArrayLength(this.obj);
-    } else if (g.class != "Proxy") {
-      g.ownPropertyLength = this.obj.getOwnPropertyNames().length;
+    } else {
+      try {
+        g.ownPropertyLength = this.obj.getOwnPropertyNames().length;
+      } catch (err) {
+        // The above can throw when the debuggee does not subsume the object's
+        // compartment, or for some WrappedNatives like Cu.Sandbox.
+      }
     }
 
     let raw = this.obj.unsafeDereference();
 
     // If Cu is not defined, we are running on a worker thread, where xrays
     // don't exist.
     if (Cu) {
       raw = Cu.unwaiveXrays(raw);
     }
 
     if (!DevToolsUtils.isSafeJSObject(raw)) {
       raw = null;
     }
 
-    let previewers = DebuggerServer.ObjectActorPreviewers[g.class] ||
+    let previewers = DebuggerServer.ObjectActorPreviewers[this.obj.class] ||
                      DebuggerServer.ObjectActorPreviewers.Object;
     for (let fn of previewers) {
       try {
         if (fn(this, g, raw)) {
           break;
         }
       } catch (e) {
         let msg = "ObjectActor.prototype.grip previewer function";
@@ -221,18 +230,26 @@ ObjectActor.prototype = {
     });
   },
 
   /**
    * Handle a protocol request to provide the names of the properties defined on
    * the object and not its prototype.
    */
   onOwnPropertyNames: function () {
-    return { from: this.actorID,
-             ownPropertyNames: this.obj.getOwnPropertyNames() };
+    let props = [];
+    if (DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+      try {
+        props = this.obj.getOwnPropertyNames();
+      } catch (err) {
+        // The above can throw when the debuggee does not subsume the object's
+        // compartment, or for some WrappedNatives like Cu.Sandbox.
+      }
+    }
+    return { from: this.actorID, ownPropertyNames: props };
   },
 
   /**
    * Creates an actor to iterate over an object property names and values.
    * See PropertyIteratorActor constructor for more info about options param.
    *
    * @param request object
    *        The protocol request object.
@@ -277,45 +294,46 @@ ObjectActor.prototype = {
    *          - {Array} ownSymbols: An array containing all descriptors of this.obj's
    *                    ownSymbols. Here we have an array, and not an object like for
    *                    ownProperties, because we can have multiple symbols with the same
    *                    name in this.obj, e.g. `{[Symbol()]: "a", [Symbol()]: "b"}`.
    *          - {Object} safeGetterValues: an object that maps this.obj's property names
    *                     with safe getters descriptors.
    */
   onPrototypeAndProperties: function () {
+    let proto = null;
+    let names = [];
+    let symbols = [];
+    if (DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+      try {
+        proto = this.obj.proto;
+        names = this.obj.getOwnPropertyNames();
+        symbols = this.obj.getOwnPropertySymbols();
+      } catch (err) {
+        // The above can throw when the debuggee does not subsume the object's
+        // compartment, or for some WrappedNatives like Cu.Sandbox.
+      }
+    }
+
     let ownProperties = Object.create(null);
     let ownSymbols = [];
 
-    // Inaccessible, proxy and dead objects should not be accessed.
-    let unwrapped = DevToolsUtils.unwrap(this.obj);
-    if (!unwrapped || unwrapped.isProxy || this.obj.class == "DeadObject") {
-      return { from: this.actorID,
-               prototype: this.hooks.createValueGrip(null),
-               ownProperties,
-               ownSymbols,
-               safeGetterValues: Object.create(null) };
-    }
-
-    let names = this.obj.getOwnPropertyNames();
-    let symbols = this.obj.getOwnPropertySymbols();
-
     for (let name of names) {
       ownProperties[name] = this._propertyDescriptor(name);
     }
 
     for (let sym of symbols) {
       ownSymbols.push({
         name: sym.toString(),
         descriptor: this._propertyDescriptor(sym)
       });
     }
 
     return { from: this.actorID,
-             prototype: this.hooks.createValueGrip(this.obj.proto),
+             prototype: this.hooks.createValueGrip(proto),
              ownProperties,
              ownSymbols,
              safeGetterValues: this._findSafeGetterValues(names) };
   },
 
   /**
    * Find the safe getter values for the current Debugger.Object, |this.obj|.
    *
@@ -329,38 +347,31 @@ ObjectActor.prototype = {
    *         An object that maps property names to safe getter descriptors as
    *         defined by the remote debugging protocol.
    */
   _findSafeGetterValues: function (ownProperties, limit = 0) {
     let safeGetterValues = Object.create(null);
     let obj = this.obj;
     let level = 0, i = 0;
 
-    // Do not search safe getters in inaccessible nor proxy objects.
-    let unwrapped = DevToolsUtils.unwrap(obj);
-    if (!unwrapped || unwrapped.isProxy) {
+    // Do not search safe getters in unsafe objects.
+    if (!DevToolsUtils.isSafeDebuggerObject(obj)) {
       return safeGetterValues;
     }
 
     // Most objects don't have any safe getters but inherit some from their
     // prototype. Avoid calling getOwnPropertyNames on objects that may have
     // many properties like Array, strings or js objects. That to avoid
     // freezing firefox when doing so.
     if (isArray(this.obj) || ["Object", "String"].includes(this.obj.class)) {
       obj = obj.proto;
       level++;
     }
 
-    while (obj) {
-      // Stop iterating when an inaccessible or a proxy object is found.
-      unwrapped = DevToolsUtils.unwrap(obj);
-      if (!unwrapped || unwrapped.isProxy) {
-        break;
-      }
-
+    while (obj && DevToolsUtils.isSafeDebuggerObject(obj)) {
       let getters = this._findSafeGetters(obj);
       for (let name of getters) {
         // Avoid overwriting properties from prototypes closer to this.obj. Also
         // avoid providing safeGetterValues from prototypes if property |name|
         // is already defined as an own property.
         if (name in safeGetterValues ||
             (obj != this.obj && ownProperties.indexOf(name) !== -1)) {
           continue;
@@ -429,16 +440,22 @@ ObjectActor.prototype = {
    *         Debugger.Object.
    */
   _findSafeGetters: function (object) {
     if (object._safeGetters) {
       return object._safeGetters;
     }
 
     let getters = new Set();
+
+    if (!DevToolsUtils.isSafeDebuggerObject(object)) {
+      object._safeGetters = getters;
+      return getters;
+    }
+
     let names = [];
     try {
       names = object.getOwnPropertyNames();
     } catch (ex) {
       // Calling getOwnPropertyNames() on some wrapped native prototypes is not
       // allowed: "cannot modify properties of a WrappedNative". See bug 952093.
     }
 
@@ -462,18 +479,22 @@ ObjectActor.prototype = {
     object._safeGetters = getters;
     return getters;
   },
 
   /**
    * Handle a protocol request to provide the prototype of the object.
    */
   onPrototype: function () {
+    let proto = null;
+    if (DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+      proto = this.obj.proto;
+    }
     return { from: this.actorID,
-             prototype: this.hooks.createValueGrip(this.obj.proto) };
+             prototype: this.hooks.createValueGrip(proto) };
   },
 
   /**
    * Handle a protocol request to provide the property descriptor of the
    * object's specified property.
    *
    * @param request object
    *        The protocol request object.
@@ -507,16 +528,20 @@ ObjectActor.prototype = {
    * @param boolean [onlyEnumerable]
    *        Optional: true if you want a descriptor only for an enumerable
    *        property, false otherwise.
    * @return object|undefined
    *         The property descriptor, or undefined if this is not an enumerable
    *         property and onlyEnumerable=true.
    */
   _propertyDescriptor: function (name, onlyEnumerable) {
+    if (!DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+      return undefined;
+    }
+
     let desc;
     try {
       desc = this.obj.getOwnPropertyDescriptor(name);
     } catch (e) {
       // Calling getOwnPropertyDescriptor on wrapped native prototypes is not
       // allowed (bug 560072). Inform the user with a bogus, but hopefully
       // explanatory, descriptor.
       return {
@@ -796,17 +821,23 @@ ObjectActor.prototype.requestTypes = {
  *          before dispatching them.
  *        - query String
  *          If non-empty, will filter the properties by names and values
  *          containing this query string. The match is not case-sensitive.
  *          Regarding value filtering it just compare to the stringification
  *          of the property value.
  */
 function PropertyIteratorActor(objectActor, options) {
-  if (options.enumEntries) {
+  if (!DevToolsUtils.isSafeDebuggerObject(objectActor.obj)) {
+    this.iterator = {
+      size: 0,
+      propertyName: index => undefined,
+      propertyDescription: index => undefined,
+    };
+  } else if (options.enumEntries) {
     let cls = objectActor.obj.class;
     if (cls == "Map") {
       this.iterator = enumMapEntries(objectActor);
     } else if (cls == "WeakMap") {
       this.iterator = enumWeakMapEntries(objectActor);
     } else if (cls == "Set") {
       this.iterator = enumSetEntries(objectActor);
     } else if (cls == "WeakSet") {
@@ -1156,17 +1187,25 @@ function enumWeakSetEntries(objectActor)
 
 /**
  * Creates an actor to iterate over an object's symbols.
  *
  * @param objectActor ObjectActor
  *        The object actor.
  */
 function SymbolIteratorActor(objectActor) {
-  const symbols =  objectActor.obj.getOwnPropertySymbols();
+  let symbols = [];
+  if (DevToolsUtils.isSafeDebuggerObject(objectActor.obj)) {
+    try {
+      symbols = objectActor.obj.getOwnPropertySymbols();
+    } catch (err) {
+      // The above can throw when the debuggee does not subsume the object's
+      // compartment, or for some WrappedNatives like Cu.Sandbox.
+    }
+  }
 
   this.iterator = {
     size: symbols.length,
     symbolDescription(index) {
       const symbol = symbols[index];
       return {
         name: symbol.toString(),
         descriptor: objectActor._propertyDescriptor(symbol, true)
@@ -1253,19 +1292,18 @@ DebuggerServer.ObjectActorPreviewers = {
     }
 
     // Check if the developer has added a de-facto standard displayName
     // property for us to use.
     let userDisplayName;
     try {
       userDisplayName = obj.getOwnPropertyDescriptor("displayName");
     } catch (e) {
-      // Calling getOwnPropertyDescriptor with displayName might throw
-      // with "permission denied" errors for some functions.
-      dumpn(e);
+      // The above can throw "permission denied" errors when the debuggee
+      // does not subsume the function's compartment.
     }
 
     if (userDisplayName && typeof userDisplayName.value == "string" &&
         userDisplayName.value) {
       grip.userDisplayName = hooks.createValueGrip(userDisplayName.value);
     }
 
     let dbgGlobal = hooks.getGlobalDebugObject();
@@ -1931,17 +1969,24 @@ DebuggerServer.ObjectActorPreviewers.Obj
     };
 
     return true;
   },
 
   function PseudoArray({obj, hooks}, grip, rawObj) {
     let length;
 
-    let keys = obj.getOwnPropertyNames();
+    let keys;
+    try {
+      keys = obj.getOwnPropertyNames();
+    } catch (err) {
+      // The above can throw when the debuggee does not subsume the object's
+      // compartment, or for some WrappedNatives like Cu.Sandbox.
+      return false;
+    }
     if (keys.length == 0) {
       return false;
     }
 
     // If no item is going to be displayed in preview, better display as sparse object.
     // The first key should contain the smallest integer index (if any).
     if (keys[0] >= OBJECT_PREVIEW_MAX_ITEMS) {
       return false;
@@ -2048,17 +2093,26 @@ function isObject(value) {
  * builtin type.
  *
  * @param Function ctor
  *        The builtin class constructor.
  * @return Function
  *         The stringifier for the class.
  */
 function createBuiltinStringifier(ctor) {
-  return obj => ctor.prototype.toString.call(obj.unsafeDereference());
+  return obj => {
+    try {
+      return ctor.prototype.toString.call(obj.unsafeDereference());
+    } catch (err) {
+      // The debuggee will see a "Function" class if the object is callable and
+      // its compartment is not subsumed. The above will throw if it's not really
+      // a function, e.g. if it's a callable proxy.
+      return "[object " + obj.class + "]";
+    }
+  };
 }
 
 /**
  * Stringify a Debugger.Object-wrapped Error instance.
  *
  * @param Debugger.Object obj
  *        The object to stringify.
  * @return String
@@ -2087,19 +2141,30 @@ function errorStringify(obj) {
  * Stringify a Debugger.Object based on its class.
  *
  * @param Debugger.Object obj
  *        The object to stringify.
  * @return String
  *         The stringification for the object.
  */
 function stringify(obj) {
-  if (obj.class == "DeadObject") {
-    const error = new Error("Dead object encountered.");
-    DevToolsUtils.reportException("stringify", error);
+  if (!DevToolsUtils.isSafeDebuggerObject(obj)) {
+    if (DevToolsUtils.isCPOW(obj)) {
+      return "<cpow>";
+    }
+    let unwrapped = DevToolsUtils.unwrap(obj);
+    if (unwrapped === undefined) {
+      return "<invisibleToDebugger>";
+    } else if (unwrapped.isProxy) {
+      return "<proxy>";
+    }
+    // The following line should not be reached. It's there just in case somebody
+    // modifies isSafeDebuggerObject to return false for additional kinds of objects.
+    return "[object " + obj.class + "]";
+  } else if (obj.class == "DeadObject") {
     return "<dead object>";
   }
 
   const stringifier = stringifiers[obj.class] || stringifiers.Object;
 
   try {
     return stringifier(obj);
   } catch (e) {
@@ -2132,35 +2197,31 @@ var stringifiers = {
     if (topLevel) {
       seen = new Set();
     } else if (seen.has(obj)) {
       return "";
     }
 
     seen.add(obj);
 
-    const len = DevToolsUtils.getProperty(obj, "length");
+    const len = getArrayLength(obj);
     let string = "";
 
-    // The following check is only required because the debuggee could possibly
-    // be a Proxy and return any value. For normal objects, array.length is
-    // always a non-negative integer.
-    if (typeof len == "number" && len > 0) {
-      for (let i = 0; i < len; i++) {
-        const desc = obj.getOwnPropertyDescriptor(i);
-        if (desc) {
-          const { value } = desc;
-          if (value != null) {
-            string += isObject(value) ? stringify(value) : value;
-          }
+    // Array.length is always a non-negative safe integer.
+    for (let i = 0; i < len; i++) {
+      const desc = obj.getOwnPropertyDescriptor(i);
+      if (desc) {
+        const { value } = desc;
+        if (value != null) {
+          string += isObject(value) ? stringify(value) : value;
         }
+      }
 
-        if (i < len - 1) {
-          string += ",";
-        }
+      if (i < len - 1) {
+        string += ",";
       }
     }
 
     if (topLevel) {
       seen = null;
     }
 
     return string;
--- a/devtools/server/tests/unit/test_objectgrips-12.js
+++ b/devtools/server/tests/unit/test_objectgrips-12.js
@@ -117,17 +117,17 @@ function test_display_string() {
       output: Array + ""
     },
     {
       input: "/foo[bar]/g",
       output: "/foo[bar]/g"
     },
     {
       input: "new Proxy({}, {})",
-      output: "[object Object]"
+      output: "<proxy>"
     },
     {
       input: "Promise.resolve(5)",
       output: "Promise (fulfilled: 5)"
     },
     {
       // This rejection is left uncaught, see expectUncaughtRejection below.
       input: "Promise.reject(new Error())",
--- a/devtools/server/tests/unit/test_objectgrips-17.js
+++ b/devtools/server/tests/unit/test_objectgrips-17.js
@@ -167,28 +167,33 @@ function check_proxy_grip(grip) {
     let target = preview.ownProperties["<target>"].value;
     strictEqual(target, grip.proxyTarget, "<target> contains the [[ProxyTarget]].");
     let handler = preview.ownProperties["<handler>"].value;
     strictEqual(handler, grip.proxyHandler, "<handler> contains the [[ProxyHandler]].");
   } else if (gIsOpaque) {
     // The proxy has opaque security wrappers.
     strictEqual(grip.class, "Opaque", "The grip has an Opaque class.");
     strictEqual(grip.ownPropertyLength, 0, "The grip has no properties.");
-  } else if (gSubsumes && !gGlobalIsInvisible) {
+  } else if (!gSubsumes) {
+    // The proxy belongs to compartment not subsumed by the debuggee.
+    strictEqual(grip.class, "Restricted", "The grip has an Restricted class.");
+    ok(!("ownPropertyLength" in grip), "The grip doesn't know the number of properties.");
+  } else if (gGlobalIsInvisible) {
+    // The proxy belongs to an invisible-to-debugger compartment.
+    strictEqual(grip.class, "InvisibleToDebugger: Object",
+                "The grip has an InvisibleToDebugger class.");
+    ok(!("ownPropertyLength" in grip), "The grip doesn't know the number of properties.");
+  } else {
     // The proxy has non-opaque security wrappers.
     strictEqual(grip.class, "Proxy", "The grip has a Proxy class.");
     ok(!("proxyTarget" in grip), "There is no [[ProxyTarget]] grip.");
     ok(!("proxyHandler" in grip), "There is no [[ProxyHandler]] grip.");
     strictEqual(preview.ownPropertiesLength, 0, "The preview has no properties.");
     ok(!("<target>" in preview), "The preview has no <target> property.");
     ok(!("<handler>" in preview), "The preview has no <handler> property.");
-  } else {
-    // The debuggee is not allowed to remove the security wrappers.
-    strictEqual(grip.class, "Inaccessible", "The grip has an Inaccessible class.");
-    ok(!("ownPropertyLength" in grip), "The grip doesn't know the number of properties.");
   }
 }
 
 function check_properties(props, isProxy, createdInDebuggee) {
   let ownPropertiesLength = Reflect.ownKeys(props).length;
 
   if (createdInDebuggee || !isProxy && gSubsumes && !gGlobalIsInvisible) {
     // The debuggee can access the properties.
@@ -203,17 +208,18 @@ function check_properties(props, isProxy
 function check_prototype(proto, isProxy, createdInDebuggee) {
   if (gIsOpaque && !gGlobalIsInvisible && !createdInDebuggee) {
     // The object is or inherits from a proxy with opaque security wrappers.
     // The debuggee sees `Object.prototype` when retrieving the prototype.
     strictEqual(proto.class, "Object", "The prototype has a Object class.");
   } else if (isProxy && gIsOpaque && gGlobalIsInvisible) {
     // The object is a proxy with opaque security wrappers in an invisible global.
     // The debuggee sees an inaccessible `Object.prototype` when retrieving the prototype.
-    strictEqual(proto.class, "Inaccessible", "The prototype has an Inaccessible class.");
+    strictEqual(proto.class, "InvisibleToDebugger: Object",
+                "The prototype has an InvisibleToDebugger class.");
   } else if (createdInDebuggee || !isProxy && gSubsumes && !gGlobalIsInvisible) {
     // The object inherits from a proxy and has no security wrappers or non-opaque ones.
     // The debuggee sees the proxy when retrieving the prototype.
     check_proxy_grip(proto);
   } else {
     // The debuggee is not allowed to access the object. It sees a null prototype.
     strictEqual(proto.type, "null", "The prototype is null.");
   }
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-21.js
@@ -0,0 +1,388 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+var gDebuggee;
+var gThreadClient;
+
+function run_test() {
+  run_test_with_server(DebuggerServer, function () {
+    run_test_with_server(WorkerDebuggerServer, do_test_finished);
+  });
+  do_test_pending();
+}
+
+async function run_test_with_server(server, callback) {
+  initTestDebuggerServer(server);
+  let principals = [
+    ["test-grips-system-principal", systemPrincipal, systemPrincipalTests],
+    ["test-grips-null-principal", null, nullPrincipalTests],
+  ];
+  for (let [title, principal, tests] of principals) {
+    gDebuggee = Cu.Sandbox(principal);
+    gDebuggee.__name = title;
+    server.addTestGlobal(gDebuggee);
+    gDebuggee.eval(function stopMe(arg1, arg2) {
+      debugger;
+    }.toString());
+    let client = new DebuggerClient(server.connectPipe());
+    await client.connect();
+    const [,, threadClient] = await attachTestTabAndResume(client, title);
+    gThreadClient = threadClient;
+    await test_unsafe_grips(principal, tests);
+    await client.close();
+  }
+  callback();
+}
+
+// The following tests work like this:
+// - The specified code is evaluated in a system principal.
+//   `Cu`, `systemPrincipal` and `Services` are provided as global variables.
+// - The resulting object is debugged in a system or null principal debuggee,
+//   depending on in which list the test is placed.
+//   It is tested according to the specified test parameters.
+// - An ordinary object that inherits from the resulting one is also debugged.
+//   This is just to check that it can be normally debugged even with an unsafe
+//   object in the prototype. The specified test parameters do not apply.
+
+// The following tests are defined via properties with the following defaults.
+let defaults = {
+  // The class of the grip.
+  class: "Restricted",
+
+  // The stringification of the object
+  string: "",
+
+  // Whether the object (not its grip) has class "Function".
+  isFunction: false,
+
+  // Whether the grip has a preview property.
+  hasPreview: true,
+
+  // Code that assigns the object to be tested into the obj variable.
+  code: "var obj = {}",
+
+  // The type of the grip of the prototype.
+  protoType: "null",
+
+  // Whether the object has some own string properties.
+  hasOwnPropertyNames: false,
+
+  // Whether the object has some own symbol properties.
+  hasOwnPropertySymbols: false,
+
+  // The descriptor obtained when retrieving property "x" or Symbol("x").
+  property: undefined,
+
+  // Code evaluated after the test, whose result is expected to be true.
+  afterTest: "true == true",
+};
+
+// Obtaining a CPOW here does not seem possible, so the CPOW test is in
+// devtools/client/webconsole/test/browser_console.js
+
+// The following tests use a system principal debuggee.
+let systemPrincipalTests = [{
+  // Dead objects throw a TypeError when accessing properties.
+  class: "DeadObject",
+  string: "<dead object>",
+  code: `
+    var obj = Cu.Sandbox(null);
+    Cu.nukeSandbox(obj);
+  `,
+  property: descriptor({value: "TypeError"}),
+}, {
+  // This proxy checks that no trap runs (using a second proxy as the handler
+  // there is no need to maintain a list of all possible traps).
+  class: "Proxy",
+  string: "<proxy>",
+  code: `
+    var trapDidRun = false;
+    var obj = new Proxy({}, new Proxy({}, {get: (_, trap) => {
+      trapDidRun = true;
+      throw new Error("proxy trap '" + trap + "' was called.");
+    }}));
+  `,
+  afterTest: "trapDidRun === false",
+}, {
+  // Like the previous test, but now the proxy has a Function class.
+  class: "Proxy",
+  string: "<proxy>",
+  isFunction: true,
+  code: `
+    var trapDidRun = false;
+    var obj = new Proxy(function(){}, new Proxy({}, {get: (_, trap) => {
+      trapDidRun = true;
+      throw new Error("proxy trap '" + trap + "' was called.");
+    }}));
+  `,
+  afterTest: "trapDidRun === false",
+}, {
+  // Invisisible-to-debugger objects can't be unwrapped, so we don't know if
+  // they are proxies. Thus they shouldn't be accessed.
+  class: "InvisibleToDebugger: Array",
+  string: "<invisibleToDebugger>",
+  hasPreview: false,
+  code: `
+    var s = Cu.Sandbox(systemPrincipal, {invisibleToDebugger: true});
+    var obj = s.eval("[1, 2, 3]");
+  `,
+}, {
+  // Like the previous test, but now the object has a Function class.
+  class: "InvisibleToDebugger: Function",
+  string: "<invisibleToDebugger>",
+  isFunction: true,
+  hasPreview: false,
+  code: `
+    var s = Cu.Sandbox(systemPrincipal, {invisibleToDebugger: true});
+    var obj = s.eval("(function func(arg){})");
+  `,
+}, {
+  // Cu.Sandbox is a WrappedNative that throws when accessing properties.
+  class: "nsXPCComponents_utils_Sandbox",
+  string: "[object nsXPCComponents_utils_Sandbox]",
+  code: `var obj = Cu.Sandbox;`,
+  protoType: "object",
+}];
+
+// The following tests run code in a system principal, but the resulting object
+// is debugged in a null principal.
+let nullPrincipalTests = [{
+  // The null principal gets undefined when attempting to access properties.
+  string: "[object Object]",
+  code: `var obj = {x: -1};`,
+}, {
+  // For arrays it's an error instead of undefined.
+  string: "[object Object]",
+  code: `var obj = [1, 2, 3];`,
+  property: descriptor({value: "Error"}),
+}, {
+  // For functions it's also an error.
+  string: "function func(arg){}",
+  isFunction: true,
+  hasPreview: false,
+  code: `var obj = function func(arg){};`,
+  property: descriptor({value: "Error"}),
+}, {
+  // Check that no proxy trap runs.
+  string: "[object Object]",
+  code: `
+    var trapDidRun = false;
+    var obj = new Proxy([], new Proxy({}, {get: (_, trap) => {
+      trapDidRun = true;
+      throw new Error("proxy trap '" + trap + "' was called.");
+    }}));
+  `,
+  property: descriptor({value: "Error"}),
+  afterTest: `trapDidRun === false`,
+}, {
+  // Like the previous test, but now the object has a Function class.
+  string: "[object Function]",
+  isFunction: true,
+  hasPreview: false,
+  code: `
+    var trapDidRun = false;
+    var obj = new Proxy(function(){}, new Proxy({}, {get: (_, trap) => {
+      trapDidRun = true;
+      throw new Error("proxy trap '" + trap + "' was called.");
+    }}));
+  `,
+  property: descriptor({value: "Error"}),
+  afterTest: `trapDidRun === false`,
+}, {
+  // Cross-origin Window objects do expose some properties and have a preview.
+  string: "[object Object]",
+  code: `var obj = Services.appShell.createWindowlessBrowser().document.defaultView;`,
+  hasOwnPropertyNames: true,
+  hasOwnPropertySymbols: true,
+  property: descriptor({value: "SecurityError"}),
+}, {
+  // Cross-origin Location objects do expose some properties and have a preview.
+  string: "[object Object]",
+  code: `var obj = Services.appShell.createWindowlessBrowser().document.defaultView
+                   .location;`,
+  hasOwnPropertyNames: true,
+  hasOwnPropertySymbols: true,
+  property: descriptor({value: "SecurityError"}),
+}];
+
+function descriptor(descr) {
+  return Object.assign({
+    configurable: false,
+    writable: false,
+    enumerable: false,
+    value: undefined
+  }, descr);
+}
+
+async function test_unsafe_grips(principal, tests) {
+  for (let data of tests) {
+    await new Promise(function (resolve) {
+      gThreadClient.addOneTimeListener("paused", async function (event, packet) {
+        let [objGrip, inheritsGrip] = packet.frame.arguments;
+        for (let grip of [objGrip, inheritsGrip]) {
+          let isUnsafe = grip === objGrip;
+          // If `isUnsafe` is true, the parameters in `data` will be used to assert
+          // against `objGrip`, the grip of the object `obj` created by the test.
+          // Otherwise, the grip will refer to `inherits`, an ordinary object which
+          // inherits from `obj`. Then all checks are hardcoded because in every test
+          // all methods are expected to work the same on `inheritsGrip`.
+
+          check_grip(grip, data, isUnsafe);
+
+          let objClient = gThreadClient.pauseGrip(grip);
+          let response, slice;
+
+          response = await objClient.getPrototypeAndProperties();
+          check_properties(response.ownProperties, data, isUnsafe);
+          check_symbols(response.ownSymbols, data, isUnsafe);
+          check_prototype(response.prototype, data, isUnsafe);
+
+          response = await objClient.enumProperties({ignoreIndexedProperties: true});
+          slice = await response.iterator.slice(0, response.iterator.count);
+          check_properties(slice.ownProperties, data, isUnsafe);
+
+          response = await objClient.enumProperties({});
+          slice = await response.iterator.slice(0, response.iterator.count);
+          check_properties(slice.ownProperties, data, isUnsafe);
+
+          response = await objClient.getOwnPropertyNames();
+          check_property_names(response.ownPropertyNames, data, isUnsafe);
+
+          response = await objClient.getProperty("x");
+          check_property(response.descriptor, data, isUnsafe);
+
+          response = await objClient.enumSymbols();
+          slice = await response.iterator.slice(0, response.iterator.count);
+          check_symbol_names(slice.ownSymbols, data, isUnsafe);
+
+          response = await objClient.getProperty(Symbol.for("x"));
+          check_symbol(response.descriptor, data, isUnsafe);
+
+          response = await objClient.getPrototype();
+          check_prototype(response.prototype, data, isUnsafe);
+
+          response = await objClient.getDisplayString();
+          check_display_string(response.displayString, data, isUnsafe);
+
+          if (data.isFunction && isUnsafe) {
+            // For function-related methods, object-client.js checks that the class
+            // of the grip is "Function", and if it's not, the method in object.js
+            // is not called. But some tests have a grip with a class that is not
+            // "Function" (e.g. it's "Proxy") but the DebuggerObject has a "Function"
+            // class because the object is callable (despite not being a Function object).
+            // So the grip class is changed in order to test the object.js method.
+            grip.class = "Function";
+            objClient = gThreadClient.pauseGrip(grip);
+            try {
+              response = await objClient.getParameterNames();
+              ok(true, "getParameterNames passed. DebuggerObject.class is 'Function'"
+                + "on the object actor");
+            } catch (e) {
+              ok(false, "getParameterNames failed. DebuggerObject.class may not be"
+                + " 'Function' on the object actor");
+            }
+          }
+        }
+
+        await gThreadClient.resume();
+        resolve();
+      });
+
+      data = {...defaults, ...data};
+
+      // Run the code and test the results.
+      let sandbox = Cu.Sandbox(systemPrincipal);
+      Object.assign(sandbox, {Services, systemPrincipal, Cu});
+      sandbox.eval(data.code);
+      gDebuggee.obj = sandbox.obj;
+      let inherits = `Object.create(obj, {
+        x: {value: 1},
+        [Symbol.for("x")]: {value: 2}
+      })`;
+      gDebuggee.eval(`stopMe(obj, ${inherits});`);
+      ok(sandbox.eval(data.afterTest), "Check after test passes");
+    });
+  }
+}
+
+function check_grip(grip, data, isUnsafe) {
+  if (isUnsafe) {
+    strictEqual(grip.class, data.class, "The grip has the proper class.");
+    strictEqual("preview" in grip, data.hasPreview, "Check preview presence.");
+  } else {
+    strictEqual(grip.class, "Object", "The grip has 'Object' class.");
+    ok("preview" in grip, "The grip has a preview.");
+  }
+}
+
+function check_properties(props, data, isUnsafe) {
+  let propNames = Reflect.ownKeys(props);
+  check_property_names(propNames, data, isUnsafe);
+  if (isUnsafe) {
+    deepEqual(props.x, undefined, "The property does not exist.");
+  } else {
+    strictEqual(props.x.value, 1, "The property has the right value.");
+  }
+}
+
+function check_property_names(props, data, isUnsafe) {
+  if (isUnsafe) {
+    strictEqual(props.length > 0, data.hasOwnPropertyNames,
+                "Check presence of own string properties.");
+  } else {
+    strictEqual(props.length, 1, "1 own property was retrieved.");
+    strictEqual(props[0], "x", "The property has the right name.");
+  }
+}
+
+function check_property(descr, data, isUnsafe) {
+  if (isUnsafe) {
+    deepEqual(descr, data.property, "Got the right property descriptor.");
+  } else {
+    strictEqual(descr.value, 1, "The property has the right value.");
+  }
+}
+
+function check_symbols(symbols, data, isUnsafe) {
+  check_symbol_names(symbols, data, isUnsafe);
+  if (!isUnsafe) {
+    check_symbol(symbols[0].descriptor, data, isUnsafe);
+  }
+}
+
+function check_symbol_names(props, data, isUnsafe) {
+  if (isUnsafe) {
+    strictEqual(props.length > 0, data.hasOwnPropertySymbols,
+                "Check presence of own symbol properties.");
+  } else {
+    strictEqual(props.length, 1, "1 own symbol property was retrieved.");
+    strictEqual(props[0].name, "Symbol(x)", "The symbol has the right name.");
+  }
+}
+
+function check_symbol(descr, data, isUnsafe) {
+  if (isUnsafe) {
+    deepEqual(descr, data.property, "Got the right symbol property descriptor.");
+  } else {
+    strictEqual(descr.value, 2, "The symbol property has the right value.");
+  }
+}
+
+function check_prototype(proto, data, isUnsafe) {
+  if (isUnsafe) {
+    deepEqual(proto.type, data.protoType, "Got the right prototype type.");
+  } else {
+    check_grip(proto, data, true);
+  }
+}
+
+function check_display_string(str, data, isUnsafe) {
+  if (isUnsafe) {
+    strictEqual(str, data.string, "The object stringifies correctly.");
+  } else {
+    strictEqual(str, "[object Object]", "The object stringifies correctly.");
+  }
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -172,16 +172,17 @@ reason = only ran on B2G
 [test_objectgrips-13.js]
 [test_objectgrips-14.js]
 [test_objectgrips-15.js]
 [test_objectgrips-16.js]
 [test_objectgrips-17.js]
 [test_objectgrips-18.js]
 [test_objectgrips-19.js]
 [test_objectgrips-20.js]
+[test_objectgrips-21.js]
 [test_promise_state-01.js]
 [test_promise_state-02.js]
 [test_promise_state-03.js]
 [test_interrupt.js]
 [test_stepping-01.js]
 [test_stepping-02.js]
 [test_stepping-03.js]
 [test_stepping-04.js]
--- a/devtools/shared/DevToolsUtils.js
+++ b/devtools/shared/DevToolsUtils.js
@@ -179,68 +179,122 @@ exports.defineLazyPrototypeGetter = func
  * @param Debugger.Object object
  *        The Debugger.Object to get the value from.
  * @param String key
  *        The key to look for.
  * @return Any
  */
 exports.getProperty = function (object, key) {
   let root = object;
-  try {
-    do {
-      const desc = object.getOwnPropertyDescriptor(key);
-      if (desc) {
-        if ("value" in desc) {
-          return desc.value;
+  while (object && exports.isSafeDebuggerObject(object)) {
+    let desc;
+    try {
+      desc = object.getOwnPropertyDescriptor(key);
+    } catch (e) {
+      // The above can throw when the debuggee does not subsume the object's
+      // compartment, or for some WrappedNatives like Cu.Sandbox.
+      return undefined;
+    }
+    if (desc) {
+      if ("value" in desc) {
+        return desc.value;
+      }
+      // Call the getter if it's safe.
+      if (exports.hasSafeGetter(desc)) {
+        try {
+          return desc.get.call(root).return;
+        } catch (e) {
+          // If anything goes wrong report the error and return undefined.
+          exports.reportException("getProperty", e);
         }
-        // Call the getter if it's safe.
-        return exports.hasSafeGetter(desc) ? desc.get.call(root).return : undefined;
       }
-      object = object.proto;
-    } while (object);
-  } catch (e) {
-    // If anything goes wrong report the error and return undefined.
-    exports.reportException("getProperty", e);
+      return undefined;
+    }
+    object = object.proto;
   }
   return undefined;
 };
 
 /**
  * Removes all the non-opaque security wrappers of a debuggee object.
- * Returns null if some wrapper can't be removed.
  *
  * @param obj Debugger.Object
  *        The debuggee object to be unwrapped.
- * @return Debugger.Object|null
- *        The unwrapped object, or null if some wrapper couldn't be removed.
+ * @return Debugger.Object|null|undefined
+ *      - If the object has no wrapper, the same `obj` is returned. Note DeadObject
+ *        objects belong to this case.
+ *      - Otherwise, if the debuggee doesn't subsume object's compartment, returns `null`.
+ *      - Otherwise, if the object belongs to an invisible-to-debugger compartment,
+ *        returns `undefined`. Note CPOW objects belong to this case.
+ *      - Otherwise, returns the unwrapped object.
  */
 exports.unwrap = function unwrap(obj) {
   // Check if `obj` has an opaque wrapper.
   if (obj.class === "Opaque") {
     return obj;
   }
 
-  // Attempt to unwrap. If this operation is not allowed, it may return null or throw.
+  // Attempt to unwrap via `obj.unwrap()`. Note that:
+  // - This will return `null` if the debuggee does not subsume object's compartment.
+  // - This will throw if the object belongs to an invisible-to-debugger compartment.
+  //   This case includes CPOWs (see bug 1391449).
+  // - This will return `obj` if there is no wrapper.
   let unwrapped;
   try {
     unwrapped = obj.unwrap();
   } catch (err) {
-    unwrapped = null;
+    return undefined;
   }
 
   // Check if further unwrapping is not possible.
   if (!unwrapped || unwrapped === obj) {
     return unwrapped;
   }
 
   // Recursively remove additional security wrappers.
   return unwrap(unwrapped);
 };
 
 /**
+ * Checks whether a debuggee object is safe. Unsafe objects may run proxy traps or throw
+ * when using `proto`, `isExtensible`, `isFrozen` or `isSealed`. Note that safe objects
+ * may still throw when calling `getOwnPropertyNames`, `getOwnPropertyDescriptor`, etc.
+ * Also note CPOW objects are considered to be unsafe, and DeadObject objects to be safe.
+ *
+ * @param obj Debugger.Object
+ *        The debuggee object to be checked.
+ * @return boolean
+ */
+exports.isSafeDebuggerObject = function (obj) {
+  let unwrapped = exports.unwrap(obj);
+
+  // Objects belonging to an invisible-to-debugger compartment might be proxies,
+  // so just in case consider them unsafe. CPOWs are included in this case.
+  if (unwrapped === undefined) {
+    return false;
+  }
+
+  // If the debuggee does not subsume the object's compartment, most properties won't
+  // be accessible. Cross-origin Window and Location objects might expose some, though.
+  // Therefore, it must be considered safe. Note that proxy objects have fully opaque
+  // security wrappers, so proxy traps won't run in this case.
+  if (unwrapped === null) {
+    return true;
+  }
+
+  // Proxy objects can run traps when accessed. `isProxy` getter is called on `unwrapped`
+  // instead of on `obj` in order to detect proxies behind transparent wrappers.
+  if (unwrapped.isProxy) {
+    return false;
+  }
+
+  return true;
+};
+
+/**
  * Determines if a descriptor has a getter which doesn't call into JavaScript.
  *
  * @param Object desc
  *        The descriptor to check for a safe getter.
  * @return Boolean
  *         Whether a safe getter was found.
  */
 exports.hasSafeGetter = function (desc) {
--- a/devtools/shared/webconsole/test/test_consoleapi.html
+++ b/devtools/shared/webconsole/test/test_consoleapi.html
@@ -144,17 +144,17 @@ function doConsoleCalls(aState)
       level: "log",
       filename: /test_consoleapi/,
       functionName: "doConsoleCalls",
       timeStamp: /^\d+$/,
       arguments: [
         {
           type: "object",
           actor: /[a-z]/,
-          class: "Inaccessible",
+          class: "InvisibleToDebugger: Object",
         },
       ],
     },
     {
       level: "error",
       filename: /test_consoleapi/,
       functionName: "fromAsmJS",
       timeStamp: /^\d+$/,