UNPKG

quarantiner

Version:

A script isolator that runs scripts inside a sandbox iframe

617 lines (512 loc) 24 kB
/* * Quarantiner - A script isolator. * Copyright (c) Dan Phillimore (asmblah) * https://github.com/asmblah/quarantiner/ * * Released under the MIT license * https://github.com/asmblah/quarantiner/raw/main/MIT-LICENSE.txt */ import Sandbox from './Sandbox'; const BOM_OR_DOM = Symbol('BOM/DOM'); const FunctionToString = Function.prototype.toString; type WritableDocument = Document & WritableObject; type WritableWindow = Window & typeof globalThis & WritableObject; export default class SandboxInitialiser { public constructor( private readonly mainWindow: Window & typeof globalThis, private readonly getSandbox: SandboxGetter, ) {} /** * Sets up the sandbox, installing proxies for globals etc. */ public initialise(sandbox: Sandbox): void { const mainWindow = this.mainWindow; const writableMainWindow = mainWindow as unknown as WritableGlobalObject; const HtmlAllCollection = mainWindow.HTMLAllCollection; const contentWindow = sandbox.getContentWindow(); const writableSandboxWindow = contentWindow as unknown as WritableGlobalObject; const sandboxWindow = contentWindow as Window & typeof globalThis; // Note that classes added here will not be proxied, so they are listed here for efficiency. const sandboxGlobals = new Map<string | symbol, unknown>([ ['Map', sandboxWindow.Map], ['Object', sandboxWindow.Object], ['Set', sandboxWindow.Set], ['WeakMap', sandboxWindow.WeakMap], ['WeakSet', sandboxWindow.WeakSet], ]); // A map from Proxies to the original object, such as the Document. const reverseProxyMap = new WeakMap(); // A map from original objects, such as the Document, to its Proxy. const proxyMap = new WeakMap(); const mainWindowArrayPrototype = mainWindow.Array.prototype; const sandboxWindowArrayPrototype = sandboxWindow.Array.prototype; // We cannot rely on Array.isArray(...) alone, as that will return true // for Array.prototype itself too. const isArray = (value: unknown): value is unknown[] => Array.isArray(value) && value !== mainWindowArrayPrototype && value !== sandboxWindowArrayPrototype; const storeProxy = ( originalValue: unknown, proxy: unknown, ): unknown => { proxyMap.set(originalValue as WeakKey, proxy); reverseProxyMap.set(proxy as WeakKey, originalValue); return proxy; }; const isNativeFunction = ( value: unknown, ): value is WritableCallableFunction => typeof value === 'function' && /^function\s+[\w_]+\(\)\s*\{\s*\[native code]/.test( FunctionToString.call(value), ); const createProxyIfNeeded = (value: unknown): unknown => { // Instead of a long list, check for a Symbol added to each prototype for speed. if ((value as WritableObject)[BOM_OR_DOM] === true) { return createObjectProxy( value as typeof value & WritableObject, ); } // Handle native BOM/DOM methods, e.g. .appendChild(...). if (isNativeFunction(value)) { return createObjectProxy(value); } if (isArray(value)) { /* * Array elements will be proxied if needed on fetch, etc. * We could map to a new array, but this way we save * by not needing to iterate over all elements * and modifications to the original array will affect the proxy. */ return createObjectProxy( value as typeof value & WritableObject, ); } // There is no need to proxy the value, for efficiency. return value; }; const proxyProperty = (target: object, property: string | symbol) => { const value = (target as { [name: string]: unknown })[ property as string ]; return proxyValue(value); }; const proxyValue = (value: unknown): unknown => { const valueType = typeof value; if ( value === null || (valueType !== 'object' && valueType !== 'function' && // `document.all`'s type is "undefined" for historical reasons. !(value instanceof HtmlAllCollection)) ) { // Scalar values require no special handling. return value; } if (proxyMap.has(value as WeakKey)) { return proxyMap.get(value as WeakKey); } return createProxyIfNeeded(value); }; /* * Maps proxied values back to their originals. * For example, only the native un-Proxy-wrapped DOM element * must be passed into `.appendChild(...)`. */ const unproxyValue = (value: unknown): unknown => { if (reverseProxyMap.has(value as WeakKey)) { return reverseProxyMap.get(value as WeakKey); } if (typeof value === 'function') { /* * Value is a function, e.g. the callback passed to .addEventListener(...). * We need to map any applicable arguments, e.g. wrapping an Event with a Proxy<Event>. * * Use a Proxy so that Object.defineProperty(...) will define properties * on the original function object, etc. * * TODO: Cache these proxy instances for efficiency. */ return new Proxy(value, { apply( target: WritableCallableFunction, thisArg: unknown, args: unknown[], ) { return target.apply( thisArg, args.map((arg) => proxyValue(arg)), ); }, }); } if (isArray(value)) { return value.map((element) => unproxyValue(element)); } return value; }; const unproxyArgs = (args: unknown[]): unknown[] => args.map((arg) => unproxyValue(arg)); const unproxyThisObject = (thisArg: unknown): object => reverseProxyMap.has(thisArg as WeakKey) ? reverseProxyMap.get(thisArg as WeakKey) : thisArg; const createInnerProxyTarget = (target: unknown): WritableObject => { if (typeof target === 'function') { // Proxy target must be a function for apply(...)/construct(...) traps to work. return (() => {}) as unknown as WritableObject; } if (Array.isArray(target)) { // Target must itself be an array for Array.isArray(...) to work. return [] as unknown as WritableObject; } return Object.create(null); }; const createObjectProxy = <T extends WritableObject>( target: T, proxyObjectProperty = proxyProperty, ) => { /* * Use a different actual Proxy target than the real object, * to avoid various Proxy invariant issues * such as being unable to override non-configurable non-writable properties. */ const innerTarget = createInnerProxyTarget(target); // Keep a reference to the original value for debugging. innerTarget.original = target; return storeProxy( target, new Proxy(innerTarget, { apply( _target: unknown, thisArg: unknown, args: unknown[], ): unknown { // Map `this` back to its original if applicable (see below). const unproxiedThisObject = unproxyThisObject(thisArg); const unproxiedArgs = unproxyArgs(args); return proxyValue( ( target as unknown as WritableCallableFunction ).apply(unproxiedThisObject, unproxiedArgs), ); }, construct(_target: T, args: unknown[]): object { const unproxiedArgs = unproxyArgs(args); return proxyValue( Reflect.construct( // NB: `target` here is the original constructor being proxied. target as unknown as new ( ...args: unknown[] ) => unknown, unproxiedArgs, ), ) as object; }, defineProperty( _target: T, property: string | symbol, attributes: PropertyDescriptor, ): boolean { Reflect.defineProperty(target, property, attributes); return true; }, deleteProperty( _target: T, property: string | symbol, ): boolean { return Reflect.deleteProperty(target, property); }, get(_target: T, property: string | symbol): unknown { return proxyObjectProperty(target, property); }, getOwnPropertyDescriptor( _target: T, property: string | symbol, ): PropertyDescriptor | undefined { return Reflect.getOwnPropertyDescriptor( target, property, ); }, getPrototypeOf(): object | null { const constructorName = target.constructor?.name ?? null; // TODO: Cache prototype for efficiency (see below). if (constructorName !== null) { // We have a constructor name - look up the constructor within // the sandbox window realm, so we can create a prototype extending its own. const sandboxRealmConstructor: WritableCallableFunction | null = ((sandboxWindow as WritableWindow)[ constructorName ] as WritableCallableFunction) ?? null; if (sandboxRealmConstructor !== null) { // TODO: Cache this prototype object in a WeakMap for efficiency. return Object.create( sandboxRealmConstructor.prototype, ); } } // We do not have a constructor property to look up. return Reflect.getPrototypeOf(target); }, has(_target: T, property: string | symbol): boolean { return Reflect.has(target, property); }, isExtensible(): boolean { return Reflect.isExtensible(target); }, ownKeys(): ArrayLike<string | symbol> { return Reflect.ownKeys(target); }, preventExtensions(): boolean { return Reflect.preventExtensions(target); }, set( _target: T, property: string, newValue: unknown, ): boolean { /* * Set properties directly on the BOM(Web API)/DOM object, * as allowing them to be set via the default proxy trap results in e.g.: * * `Uncaught TypeError: 'set event' called on an object that does not implement interface Window.`. */ (target as WritableObject)[property] = newValue; return true; }, setPrototypeOf( _target: T, newPrototype: object | null, ): boolean { return Reflect.setPrototypeOf(target, newPrototype); }, }), ); }; const isScalar = (value: unknown): boolean => { const type = typeof value; return type === 'number' || type === 'string' || type === 'boolean'; }; const sandboxWindowProxy = createObjectProxy( sandboxWindow as typeof window & WritableObject, (target, property) => { if (property === 'undefined') { // TODO: Define common globals during compilation to avoid lookups. return undefined; } // TODO: Return early if `property === Symbol.unscopables` for speed? // Properties to just fetch unchanged from the main window. if (['visualViewport'].includes(property as string)) { return writableMainWindow[property as string]; } // Overrides for specific global/window properties. if (sandboxGlobals.has(property)) { return sandboxGlobals.get(property); } const sandboxWindowValue = proxyProperty(target, property); if (isScalar(sandboxWindowValue)) { const mainWindowValue = writableMainWindow[property as string]; if (isScalar(mainWindowValue)) { // Fetch scalar values from the main window. return mainWindowValue; } } return sandboxWindowValue; }, ) as WritableWindow; const mainDocumentProxy = createObjectProxy( mainWindow.document as Document & WritableObject, ) as WritableDocument; // It should not (easily) be possible to access the sandbox document. // Main window should not (easily) be accessible - redirect to the sandbox window. proxyMap.set(mainWindow, sandboxWindowProxy); proxyMap.set(mainWindow.Array, sandboxWindow.Array); proxyMap.set(mainWindow.Boolean, sandboxWindow.Boolean); proxyMap.set(mainWindow.Number, sandboxWindow.Number); proxyMap.set(mainWindow.Object, sandboxWindow.Object); proxyMap.set(mainWindow.String, sandboxWindow.String); // Sandbox document should not (easily) be accessible - redirect to the main document's. proxyMap.set(sandboxWindow.document, mainDocumentProxy); const hookEventListenerHandling = () => { const nativeAddEventListener = mainWindow.EventTarget.prototype.addEventListener; const nativeRemoveEventListener = mainWindow.EventTarget.prototype.removeEventListener; const eventListenerMap = new WeakMap(); proxyMap.set( nativeAddEventListener, function addEventListener( this: EventTarget, type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean, ): void { // Map proxied DOM object back to its original. const unproxiedDomObject = unproxyThisObject(this); let eventListener: EventListener | null = null; if (typeof callback === 'function') { eventListener = (event: Event) => { // Map the Event with a Proxy<Event> so that `.target` etc. are proxied. callback.call(this, proxyValue(event) as Event); }; } else if ( callback !== null && typeof callback === 'object' ) { throw new Error( 'EventListenerObjects not yet supported', ); } eventListenerMap.set(callback as WeakKey, eventListener); nativeAddEventListener.call( unproxiedDomObject, type, eventListener, options, ); }, ); proxyMap.set( nativeRemoveEventListener, function removeEventListener( this: EventTarget, type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean, ): void { // Map proxied DOM object back to its original. const unproxiedDomObject = unproxyThisObject(this); const eventListener = eventListenerMap.has( callback as WeakKey, ) ? eventListenerMap.get(callback as WeakKey) : callback; nativeRemoveEventListener.call( unproxiedDomObject, type, eventListener, options, ); }, ); }; const markAsProxiable = (value: unknown) => { (value as WritableObject)[BOM_OR_DOM] = true; }; const hookBom = () => { // TODO: Can this be done the same as for the DOM, without overriding anything (other options mentioned elsewhere)? // Need to consider that MutationObserver is meant to be instantiated by the user. for (const bomClassName of ['MutationObserver', 'MutationRecord']) { const bomClass = writableSandboxWindow[ bomClassName ] as WritableCallableFunction; sandboxGlobals.set(bomClassName, createObjectProxy(bomClass)); markAsProxiable(bomClass.prototype); } sandboxGlobals.set('focus', () => mainWindow.focus()); sandboxGlobals.set( 'getSelection', () => proxyValue(mainWindow.getSelection()) as Selection | null, ); }; const hookDom = () => { // Instead of a long list, add a Symbol to each prototype to check for, for speed. for (const DomClass of [ HtmlAllCollection, mainWindow.Event, // TODO: Ensure both of these are covered by tests. sandboxWindow.Event, mainWindow.EventTarget, mainWindow.HTMLCollection, mainWindow.HTMLFormControlsCollection, mainWindow.Node, mainWindow.NodeList, mainWindow.Range, mainWindow.Selection, ]) { markAsProxiable(DomClass.prototype); } // CaretPosition is experimental, but supported in Firefox at time of writing. const CaretPosition = ( mainWindow as typeof mainWindow & { CaretPosition: WritableCallableFunction; } ).CaretPosition ?? null; if (CaretPosition !== null) { markAsProxiable(CaretPosition.prototype); } hookEventListenerHandling(); }; const hookArrayHandling = () => { const nativeArrayFrom = sandboxWindow.Array.from; proxyMap.set(nativeArrayFrom, <T>(array: ArrayLike<T>): unknown[] => // Proxy elements as applicable, e.g. for `Array.from(<NodeList>)`. nativeArrayFrom(array, (element) => proxyValue(element)), ); }; const hookStandard = () => { const NativeProxy = sandboxWindow.Proxy; class SandboxedProxy<T extends object> { public constructor(target: T, handler: ProxyHandler<T>) { const getTrap = handler.get ?? null; const modifiedHandler = getTrap ? { ...handler, /** * Override the get trap with one that will always handle our special BOM_OR_DOM symbol. */ get( target: T, property: string | symbol, receiver: unknown, ): unknown { if (property === BOM_OR_DOM) { // Don't allow a custom Proxy to handle fetches // of the special BOM_OR_DOM symbol property. return (target as WritableObject)[ property ]; } return ( getTrap as WritableCallableFunction ).call(target, target, property, receiver); }, } : handler; // See https://stackoverflow.com/a/40714458/691504. return new NativeProxy( target, Object.assign({}, modifiedHandler), ); } } sandboxGlobals.set('Proxy', SandboxedProxy as ProxyConstructor); hookArrayHandling(); }; hookStandard(); hookBom(); hookDom(); const sandboxContextEntrypoint: Entrypoint = ( wrapper: WrapperFunction, ): void => { wrapper( sandboxWindowProxy, // Redirect `.parent` back to the sandbox window. sandboxWindowProxy, sandboxWindowProxy, // Redirect `.top` back to the sandbox window. sandboxWindowProxy, ); }; // Define the Quarantiner API on the sandbox window/global object, // for when the script re-executes inside the sandbox. writableSandboxWindow.quarantiner = { getSandbox: this.getSandbox, quarantine: sandboxContextEntrypoint, }; } }