Securely Implementing Trusted, “Friend”-Like Classes In JavaScript

Background

JavaScript object properties are public by default.

Since ECMAScript (ES) 2020, there is native support for private properties (and methods) using the hash (#) prefix.

In my previous post, I demonstrate a way to implement natively-enforced (not based on the underscore (_) convention) protected-style properties that doesn’t require shared lexical scope or public accessors (i.e. it behaves very much like you would expect a native implementation to).

The historical way to simulate protected properties was to use a lexically-scoped WeakMap of instances to properties (or, more efficiently, to a protected-state object).

The “legacy” lexically-scoped WeakMap approach is more like trusted-class access. A method at any class level can access any type of instance stored in the WeakMap, even if it’s a less-or-differently-derived instance. C++ has a similar concept called a friend class.

In this article, I’ll show how we can implement friend-like classes securely in JavaScript while allowing classes to be distributed across different files (no shared-lexical-scope limitation).

“Insider” Properties

“Insider” properties will be stored in an object on a per-base-class-instance, and will be accessible to the designated base class and all trusted sub-classes (related by inheritance) and unrelated (in terms of inheritance) partner-classes.

This model has three types of components:

  1. A trust component, which defines the scope of trust
  2. A base-class component, which creates the shared state and controls distribution
  3. One or more trusted, “partner” or “friend” classes which request and cache access to the shared state (these can be sub-classes of the base or completely outside of the base-class hierarchy)

The Trust Component

The trust component is a stand-alone file that contains the “trust configuration”. It is also a JavaScript “barrel file” that exports the related base and trusted sub-classes.

This approach groups trusted classes in a way that can be deployment/application-specific without modifying the class files themselves in any way in addition to insuring trusted-version consistency and proper dependency-loading resolution.

Here is the trust-component pattern:

// "Barrel" bundling of base + trusted sub/partner-classes
import { Base } from './insider-base.js';
import { Partner } from './insider-partner.js';
import { Sub } from './insider-sub.js';
export { Base, Partner, Sub };

let trusted;

// Return the trusted sub-class list
// (but not before the classes are initialized)
export const isTrusted = (cls) => {
    // Adjust the trusted-class array below as required
    trusted ||= [Partner, Sub];
    return trusted.includes(cls);
};

The isTrusted function just needs to return true if the class is trusted, or false if it isn’t. It can be implemented using an array (and .includes) as shown here (efficient and low-overhead for short lists, a Set (and .has; more performant for long lists), if statements, switch/case statements, or any other approach that fits your needs.

Base-Class Component

In addition to your core base-class behavior, the base class is also responsible for creating the initial, per-instance #insider state object and controlling distribution of access to trusted sub-classes (which will store a reference to the same shared object in their own #insider private fields).

Here is the base-component pattern:

import { isTrusted } from './insider-trusted.js';

export const Base = (() => {
    const cls = Object.freeze(class Base {
        static #insiderBaton = null; // Per-class handoff baton
        #insider = { /* insider: true /* Instance insider properties */ };

        /*
         * Base-class-only class-method to pass #insider access
         * @param {Class} reqCls - The requesting method's class (for proper handoff)
         * @param {Object} instance - The instance whose #insider is requested
         * @param {Function} receiver - Baton-receiver/instance-#insider-setter function
         */
        static _getInsider (reqCls, instance, receiver) {
            // Request must be for a class on the trusted list
            if (!isTrusted(reqCls)) throw new Error('Untrusted request');
            // Make sure the handoff class-method is a frozen function
            const passProps = Object.getOwnPropertyDescriptor(reqCls, '_passInsider');
            if (typeof passProps.value !== 'function' || passProps.writable !== false || passProps.configurable !== false) throw new Error('Unsafe handoff');
            // Use the supplied class-level handoff method to pass #insider to the receiver
            reqCls._passInsider(instance.#insider, receiver);
        }
    });
    Object.freeze(cls.prototype);
    return cls;
})();

Partner/Friend-Class Components

Partner-class constructors are responsible for requesting insider property access and caching it in their private #insider fields for use within each sub-class level.

They do this by calling a known, frozen, class-method of a known class (the base class), passing it their class object, the instance for which #insider properties are desired (usually this for sub-class partner classes), and a “baton receiver function”.

The base class passes the #insider state object and the supplied receiver function to a known, frozen, “handoff” class-method of the specified class. The handoff function uses a class-specific baton (every trusted sub-class must have one), which the receiver function (as an instance method of the same class) is able to receive.

The #insider state of other instances can be received by supplying a receiver function that sets something other than this.#insider (typically a variable in the requesting method’s local scope).

The partner-class pattern looks like this:

 import { Base } from './insider-trusted.js';

 export const Partner = (() => {
    const cls = Object.freeze(class Sub extends Base {
        static #insiderBaton = null; // Handoff baton (in every sub-class)
        #insider; // Per-class-level private view of shared #insider state

        constructor (baseInstance) {
            super();
            // Sub-class partners can just use `this` instead of `baseInstance`
            Base._getInsider(cls, baseInstance, () => this.#insider = cls.#insiderBaton);
            // Insider instance properties (this.#insider.prop) are ready here
        }

        /*
         * Baton handoff function (all sub-classes); called by Base._getInsider
         * @param {Object} insider - The requested instances #insider
         * @param {Function} receiver - The receiver function to call
         */
        static _passInsider (insider, receiver) {
            cls.#insiderBaton = insider;
            receiver(); // Receiver must be a cls method to accept the baton
            cls.#insiderBaton = null;
        }
    });
    Object.freeze(cls.prototype);
    return cls;
})();

Accessing Another Instance’s #insider

There are two ways to access another instance’s #insider.

The first method uses native JavaScript cross-instance, direct private-field access (e.g. otherInstance.#insider). This works if the other instance is at least as derived as the accessing method’s class, in the same class hierarchy. This will always work from base-class methods, since trusted sub-classes are derived from the base class.

The second method uses Base._getInsider with a custom receiver function. Since it’s invoking a base-class method, it can be used between any instances (as long as the requesting method is in one of the trusted sub-classes).

        #getInsiderFor (other) {
            if (other instanceof cls) return other.#insider;
            if (other instanceof Base) { // (Only in partner classes)
                let insider;
                Base._getInsider(cls, other, () => insider = cls.#insiderBaton);
                return insider;
            }
        }

Security

Most forms of type checking in JavaScript, including those based on an object’s constructor or new.target can be misdirected through code manipulation. Private element (#) access is managed directly at the JavaScript-engine level, however, and therefore does not have that problem. This model leverages that mechanism for verifying that only methods of actually-trusted classes can gain access.

Base._getInsider will only ever pass #insider via a pre-determined method on a class it is configured to trust. A method in an untrusted class has two options:

  1. Follow the pattern, creating its own handoff function and supplying its own class to Base._getInsider
  2. “Lie”, passing a trusted class to Base._getInsider instead

In the first case, the class will not be on the trusted list, so Base._getInsider will throw an error without even attempting to distribute access. This result will be typical for trust misconfiguration (a sub-class that should be trusted wasn’t added to the trust configuration, or the wrong trust configuration is being loaded).

In the second case, Base._getInsider will distribute #insider access to the specified (trusted class) baton, but the requesting method, being of a different class, will have no access to the trusted-class baton. This might happen as the result of malicious code, or if “hard-wired” class names are being used instead of the boilerplate approach in the pattern as documented.

The patterns here include code to freeze class and prototype objects. This is largely performative (only protecting against some types of coding mistakes, as opposed to actual attacks), however, unless you own the execution context and can ensure that Object.freeze(Object) is run before any untrusted code executes.

Resources

The code is also available on GitHub at https://github.com/bkatzung/insider-js.

Related

Implementing Secure, Cross-File JavaScript Protected Properties And Methods

Leave a Reply