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 trusted, 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:
- A trust component, which defines the scope of trust
- A base-class component, which creates the shared state and controls distribution
- One or more trusted 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 + trust file (trust.js) ----------
import { Base } from '.../base.js';
import { Partner } from '.../partner.js';
import { Sub } from '.../sub.js';
// All other modules should import these from trust.js
export { Base, Partner, Sub };
// Do not reference computed exports yet!
let trusted; // We'll cache our trust data here later
/**
* Is a partner or sub-class trusted?
* @param {function} cls - Class object (constructor function, not name)
* @returns {boolean}
*
* Can be implemented with [].includes, Set([]).has, switch/case, etc.
*/
export const isTrusted = (cls) => {
trusted ||= [Partner, Sub];
return trusted.includes(cls);
}
The isTrusted function just needs to return true if the class (passed as a constructor function, not a name) 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 classes (which will store a reference to the same shared object in their own #insider private fields).
Here is the base-component pattern:
// ---------- Designated base class (base.js) ----------
import { isTrusted } from '.../trust.js';
export class Base {
#insider; // Base view of shared insider-properties object
static #insiderBaton = null; // Per-class hand-off baton
static #protoInsider = {
insiderMethod () {
// Optional - verify this insider-properties object is official:
if (this !== this.thys.#insider) throw new Error('Unauthorized');
// this: the shared insider-properties object
// this.thys: the original base object
}
};
constructor () {
const insider = this.#insider = Object.create(cls.#protoInsider); // Create insider properties object
insider.thys = this; // Enables unbound prototype methods to see original "this"
}
/**
* Pass this.#insider to instances of trusted classes
* @param {function} cls - The class (constructor function, not name)
* @param {Base} instance - The instance for which #insider is requested
* @param {function} receiver - A baton handoff receiver function
*/
_getInsider (cls, instance, receiver) {
if (!isTrusted(cls)) throw new Error('Untrusted request');
cls._passInsider(instance.#insider, receiver);
}
/**
* Get #insider for another object (base-class version)
* @param {Base} other - The object whose #insider is desired
* @returns {object|undefined} - #insider, if available
*/
#getInsiderFor (other) {
if (other instanceof Base) return other.#insider;
}
}
Prototype insider methods can be added directly to the static #protoInsider object. This will be used as the initial object prototype for the insider properties object. The constructor will add a thys property referring to the original object so that insider methods can be left unbound like standard prototype methods (within an insider method, “this” refers to the insider-properties object and “this.thys” refers to the original object).
Base._getInsider and Sub._passInsider or Partner._passInsider, in conjunction with receiver functions (covered below), work together to pass base-instance #insider to trusted sub-classes and partner classes.
Trusted/”Friend”-Class Components
Trusted-class constructors are responsible for requesting insider property access and caching it in their private #insider fields for use within each trusted 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, “hand-off” class-method of the specified class. The hand-off function uses a class-specific baton (every trusted sub-class must have its own), 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).
Trusted sub-classes and trusted partner classes (outside of the base-class hierarchy/prototype chain) are fairly similar, with just a couple of differences.
Trusted Sub-Class Pattern
// ---------- (Related) sub-class (sub.js) ----------
import { Base } from '.../trust.js'; // from trust.js, not base.js
const getProto = Object.getPrototypeOf, setProto = Object.setPrototypeOf;
export class Sub extends Base {
#insider; // Sub-class view of shared insider-properties object
static #insiderBaton; // Per-class hand-off baton
static #protoInsider = setProto({
insiderMethod () {
super.insiderMethod();
}
}, null);
constructor () {
// Request insider-properties object access
Base._getInsider(Partner, this, () => this.#insider = Sub.#insiderBaton);
const insider = this.#insider, protoInsider = Sub.#protoInsider;
// Fix #protoInsider and #insider prototypes
if (!getProto(protoInsider)) setProto(protoInsider, getProto(insider));
setProto(insider, protoInsider);
}
/**
* Pass a base #insider to trusted partner instances (called by Base._getInsider)
* @param {*} insider - The base #insider
* @param {function} receiver - The baton receiver function
*/
static _passInsider (insider, receiver) {
Sub.#insiderBaton = insider;
try { receiver(); } // Bona fide Sub-class receivers can access the baton
finally { Sub.#insiderBaton = null; }
}
}
Trusted sub-classes of Base may extend the insider-properties prototype, but some sub-classes might not be trusted. Because of this, insider prototypes are managed privately, and a target-constructor prototype is not automatically selected as it is for the “protected” pattern. Instead, the insider-properties object-prototype is initially set to the Base insider-prototype and subsequently updated within trusted sub-class constructors.
Trusted Partner-Class Pattern
// ---------- (Unrelated) partner class (partner.js) ----------
import { Base } from '.../trust.js'; // from trust.js, not base.js
export class Partner {
#insider; // Partner view of shared insider-properties object
static #insiderBaton; // Per-class hand-off baton
/**
* @param {Base} base - Base instance
*/
constructor (base) {
// Request insider-properties object access for base instance
Base._getInsider(Partner, base, () => this.#insider = Partner.#insiderBaton);
// Base._getInsider(Sub, this, () => this.#insider = Sub.#insiderBaton); // Sub-class version
}
/**
* Pass a base #insider to trusted partner instances (called by Base._getInsider)
* @param {*} insider - The base #insider
* @param {function} receiver - The baton receiver function
*/
static _passInsider (insider, receiver) {
Partner.#insiderBaton = insider;
try { receiver(); } // Bona fide Partner-class receivers can access the baton
finally { Partner.#insiderBaton = null; }
}
// A pseudo-insider method (requires caller to know #insider)
gatedMethod (insider) {
if (insider !== this.#insider) throw new Error('Unauthorized');
}
}
The pattern-as-shown assumes that a Partner instance is associated with a specific base (or sub-class) instance. Alternatively, one could just pass an instance to any method that requires it, and have the method request the associated #insider using #getInsiderFor (see “Accessing Another Object’s #insider”, below.)
The partner-class pattern does not include insider-prototype chaining, as there is no way to guarantee a single, predictable, consistent prototype chain in the general case. Partner classes should use the pseudo-insider-method approach (a method on the partner-class prototype that verifies that the caller has passed #insider as a shared secret) to provide insider functionality instead (see the gatedMethod example in the pattern code).
Accessing Another Object’s #insider
There are two ways to access another object’s #insider.
If the other object is an instance of the class that is requesting access, the method can access its #insider directly (private fields are private by class, not by instance).
The second way 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 classes).
The private #getInsiderFor method (in each non-base class) follows this pattern:
/**
* Get #insider for another object (partner/sub-class version)
* @param {Base|ThisClass} other - The object whose #insider is desired
* @returns {object|undefined} - #insider, if available
*/
#getInsiderFor (other) {
if (other instanceof ThisClass) return other.#insider;
if (other instanceof Base) {
let insider;
Base._getInsider(ThisClass, other, () => insider = ThisClass.#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:
- Follow the pattern, creating its own hand-off function and supplying its own class to
Base._getInsider - “Lie”, passing a trusted class to
Base._getInsiderinstead
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 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 code in the GitHub repository (see Resources, below) includes additional code to aid in prevention of class tampering in some execution contexts. That code has been omitted here in order to focus on general concepts and approaches.
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
