Why not to use Object.freeze for immutable JS objects?

Hello folks, Erik Marks from MetaMask here.

@danfinlay and I were recently talking about how to achieve object immutability in JS. He recalled discussing the matter with Agoric, and that Agoric uses something other than Object.freeze internally.

I am trying to understand the limitations of Object.freeze, and alternatives to it. If anyone at Agoric or otherwise has any insight, I’d greatly appreciate it.

Per the MDN article it seems to me that freezing small - whatever that means - objects recursively with Object.freeze ought to be fine in many cases. I suppose Immutable.js exists when the objects to be frozen are neither simple nor small. However, that library can significantly impact performance, in addition to having an API that tends to propagate throughout your code.

2 Likes

Freezing only freezes properties. JavaScript objects can hold mutable state that is unaffected by freeze. The harden function from https://github.com/Agoric/harden freezes everything it can find by transitive own property traversal[*]. If harden(obj) returns successfully, then all the object reachable from obj by own property traversal and inheritance are frozen. Usually, the most useful way to think about harden(obj) is as a way to make API surface tamper proof, rather than as a way to achieve immutability, aka, purity.

let x = 3;
const count = harden(function() => { return x++; });

The count function above is hardened but not pure.

However, if the subgraph reached from harden(obj) has no hidden mutable state, such that after harden(obj) successfully returns we know obj is pure, then before harden is called, we say that obj is purifiable.

In the default configuration of SES, all the intrinsic objects (i.e., the primordials, the objects that must exist before user code starts running) are pure. If two subgraphs share pure objects but are otherwise isolated, they cannot overtly communicate. By default, SES also denies them the special powers they need to read side channels and covert channels, like the ability to sense the duration of time. In that case, these subgraphs simply cannot communicate.

A pure module is one whose exports are pure values. A statically pure module is one that passes our static checking rules that ensure a module is pure under the assumption that it is executing under the SES default configuration and that it imports only pure values. A module that is not a pure module is a resource module. A module that is not statically pure must conservatively be assumed to be a resource module.

SES will provide a pure loader, to be shared by default by all code within the same SES root realm. The pure loader will only load statically pure modules. It will resolve all their imports only to statically pure modules, thereby ensuring that these modules only import and export pure values.

Jessie only supports pure modules. SES will also support per-compartment resource loaders that load resource modules. These resource loader can be configured to delegate to each other, and to their shared pure loader, to arrange for least-authority wiring of resource modules, as explained in the safe-modules repository.

[*] Technically, transitive reflective own property traversal. For data properties, this remains as expected — harden recurs on the property’s value. For accessor properties, harden does not call the getter. Rather, harden recurs on the getter and setter function themselves. Again, see the harden repository for details.

For reference, see

4 Likes

First, @markm, I want to belatedly offer my gratitude for your response, which I—and others on the MetaMask team—have referred to time and time again since you wrote it.

Second, I have a couple of follow-up questions:

  1. What are the “primordials”? Are they what MDN refers to as the “Standard built-in objects”? (For the remainder of this post, I’ll refer MDN’s “Standard built-in objects” as “intrinsic objects”.)

  2. We want to eliminate the possibility of some supply chain attacks—e.g., directly overwriting Promise.resolve—before LavaMoat is production-ready. We hope to accomplish this by freezing some intrinsic objects in MetaMask on boot, before any untrusted dependencies are loaded. Although we are not using SES, would you recommend @agoric/harden for this purpose?

Hi @rekmarks :wave:

I can answer some of your questions although Mark will be able to answer in more depth.

Primordials are a term that Mark uses that means “all of the JavaScript objects that are mandated by the ECMAScript spec to exist before the code starts running, but not including the global object. Host-mandated objects, such as document or require, are not primordials. All intrinsics are primordials.”

@agoric/harden doesn’t work outside of a SES environment. When harden recursively deep-freezes, it does so until it hits the “fringe”, which I understand to be the primordials frozen by SES. Without SES, harden isn’t able to freeze everything it needs to in order to be safe.

Is it for performance reasons that you are trying not to use SES? It seems like what you would want to use, ideally.

1 Like

Hi @kate_sills! Thanks for the response :ok_hand:

That makes sense. We are definitely concerned about performance, and are uncertain what kind of impact SES would have. Before addressing that, however, we also have a number of dependencies that are incompatible with SES.

Hi @rekmarks, I am confused.

What kind of safety do you think is achievable without SES? What safety claims to you expect to make for a system not built on SES? How do you expect to achieve those safety properties without SES?

@markm, we don’t intend to claim that we’ll fundamentally improve our security by doing this—we merely wish to make it more difficult to attack us. For instance, we would like to prevent “the most basic attacks” as described in the @agoric/harden readme.

By analogy, we’d like to put some locks on our door; while a sophisticated burglar can circumvent them, less skillful ones may be deterred.

Right at the top of that readme:

Note: To fully freeze all reachable proporties, harden() must be run in a SES environment. This package, used by itself, is insecure and should only be used for more easily testing code that will be run in SES.

As for “more difficult to attack us”, as used outside of SES, I don’t think harden provides any meaningful attack resistance at all. Let’s figure out how we can overcome your impediments to using SES and get LavaMoat onto actual SES asap. Until then we don’t even have a POC.

What’s the best first impediment for us to look at and fix together?

1 Like

I understand. To be clear, my question was born out of a desire to make any improvement at all on the status quo before fully moving onto SES and LavaMoat–we remain completely committed to both.

On that note, LavaMoat development is proceeding apace. I know that performance remains a concern, and I believe that @kumavis would be able to expand on that. Some issues will be resolved by removing deprecated dependencies–we’re just giving the community time to adapt as doing so involves breaking our API.

1 Like

A hypothetical attack for us is this:

We have a function, called getUserConsent(terms), and it returns a Promise<boolean> with the user’s approval or rejection of a given operation. The problem is, if the global Promise could be polluted by any dependency, then any dependency could override and corrupt a very foundational notion of user consent in any JavaScript based program.

Our initial tests suggested an early harden() of Promise did mitigate the pollution attacks we were aware of, could you share the other ways that mutable object state (harden outside of SES) might leave us vulnerable in this scenario, or could harden() have some benefits in a situation like this?

If Promise is hardened outside SES, then IIUC it leaves Promise.prototype unfrozen, because that is part of the non-SES primordial fringe. Also, Object.prototype would be unfrozen. The attacker could replace all methods on both, leaving most uses of the resulting promise, including p.then(...), corrupted. All these calls would be calling functions installed by the attacker.

1 Like