From dcbe2db7790794e5f66fb909ec89b11eb5783aef Mon Sep 17 00:00:00 2001 From: rwaldron Date: Fri, 2 Aug 2024 10:56:55 -0400 Subject: [PATCH] feat: @W-16299313 PoC iframe keepAlive --- README.md | 13 +-- karma.config.js | 2 - .../near-membrane-base/src/environment.ts | 12 +-- .../near-membrane-dom/src/browser-realm.ts | 64 ++++++------- packages/near-membrane-dom/src/types.ts | 1 - packages/near-membrane-dom/src/window.ts | 8 +- .../near-membrane-shared-dom/src/Element.ts | 7 +- .../near-membrane-shared-dom/src/constants.ts | 54 ----------- .../near-membrane-shared-dom/src/index.ts | 1 - test/dom/create-virtual-environment.spec.js | 91 +++---------------- test/dom/custom-element.spec.js | 15 +-- test/dom/ffbug.spec.js | 1 - 12 files changed, 58 insertions(+), 211 deletions(-) delete mode 100644 packages/near-membrane-shared-dom/src/constants.ts diff --git a/README.md b/README.md index 0f226c1d..4fd33b27 100644 --- a/README.md +++ b/README.md @@ -38,15 +38,9 @@ Since you can have multiple sandboxes associated to the Blue Realm, there is a p In browsers, since we don't have a way to create a light-weight Realm that is synchronously accessible (that will be solved in part by the [stage 3 ShadowRealms Proposal](https://github.com/tc39/proposal-shadowrealm)), we are forced to use a same-domain iframe in order to isolate the code to be evaluated inside a sandbox for a particular window. -#### Detached iframes - -Since the iframes have many ways to reach out to the opener/top window reference, we are forced to use a detached `iframe`, which is, on itself, a complication. A detached `iframe`'s window is a window that does not have any host behavior associated to it, in other words, this window does not have an origin after disconnecting the iframe, which means it can't execute any DOM API without throwing an error. Luckily for us, the JavaScript intrinsics, and all JavaScript language features specified by ECMA262 and ECMA402 are still alive and kicking in that iframe, except for one feature, dynamic imports in a form of `import(specifier)`. - -To mitigate the issue with dynamic imports, we are forced to transpile the code that attempts to use this feature of the language, otherwise it will just fail to fetch the module because there is no origin available at the host level. However, transpiling dynamic imports is a very common way to bundle code for production systems today. - #### Unforgeables -The `window` reference in the detached iframe, just like any other `window` reference in browsers, contains various unforgeable descriptors, these are descriptors installed in Window, and other globals that are non-configurable, and therefor this library cannot remove them or replace them with a Red Proxy. Must notable, we have the window's prototype chain that is completely unforgeable: +The `window` reference in the iframe, just like any other `window` reference in browsers, contains various unforgeable descriptors, these are descriptors installed in Window, and other globals that are non-configurable, and therefor this library cannot remove them or replace them with a Red Proxy. Must notable, we have the window's prototype chain that is completely unforgeable: ``` window -> Window.prototype -> WindowProperties.prototype -> EventTarget.prototype @@ -54,8 +48,6 @@ window -> Window.prototype -> WindowProperties.prototype -> EventTarget.prototyp What we do in this case is to keep the identity of those unforgeable around, but changing the descriptors installing on them, and any other method that expects these identities to be passed to them. This make them effectively harmless because they don't give any power. -Additionally, there are other unforgeables like `location` that are host bounded, in that case, we don't have to do much since the detaching mechanism will automatically invalidate them. - These can only be virtualized via transpilation if they need to be available inside the sandbox. Such transpilation process is not provided as part of this library. #### Requirements @@ -93,13 +85,10 @@ We do not know the applications of this library just yet, but we suspect that th * Debugging is still very challenging considering that dev-tools are still catching up with the Proxies. Chrome for example has differences displaying proxies in the console vs the watch panel. -Additionally, there is an existing bug in ChromeDev-tools that prevent a detached iframe to be debugged (https://bugs.chromium.org/p/chromium/issues/detail?id=1015462). - ### WindowProxy The `window` reference in the iframe, just like any other `window` reference in browsers, exhibit a bizarre behavior, the `WindowProxy` behavior. This has two big implications for this implementation when attempting to give access to other window references coming from same domain iframes (e.g.: sandboxing the main app + one iframe): -* each window will require a new detached iframe to sandbox each of them, but if the iframe navigates to another page, the window reference remains the same, but the internal of the non-observable real window are changing. Otherwise distortions defined for the sandbox will not apply to the identity of the methods from the same-domain iframe. * GCing the sandbox when the iframe navigates out is tricky due to the fact that the original iframe's window reference remains the same, and it is used by few of the internal maps. For those reasons, we do not support accessing other realm instances from within the sandbox at the moment. diff --git a/karma.config.js b/karma.config.js index 7503c2b9..421d7517 100644 --- a/karma.config.js +++ b/karma.config.js @@ -7,8 +7,6 @@ const globby = require('globby'); const istanbul = require('rollup-plugin-istanbul'); const { nodeResolve } = require('@rollup/plugin-node-resolve'); -process.env.CHROME_BIN = require('puppeteer').executablePath(); - let testFilesPattern = './test/**/*.spec.js'; const basePath = path.resolve(__dirname, './'); diff --git a/packages/near-membrane-base/src/environment.ts b/packages/near-membrane-base/src/environment.ts index 0314a6b9..88a88166 100644 --- a/packages/near-membrane-base/src/environment.ts +++ b/packages/near-membrane-base/src/environment.ts @@ -305,22 +305,12 @@ export class VirtualEnvironment { } } - lazyRemapProperties( - target: ProxyTarget, - ownKeys: PropertyKey[], - unforgeableGlobalThisKeys?: PropertyKey[] - ) { + lazyRemapProperties(target: ProxyTarget, ownKeys: PropertyKey[]) { if ((typeof target === 'object' && target !== null) || typeof target === 'function') { const args: Parameters = [ this.blueGetTransferableValue(target) as Pointer, ]; ReflectApply(ArrayProtoPush, args, ownKeys); - if (unforgeableGlobalThisKeys?.length) { - // Use `LOCKER_NEAR_MEMBRANE_UNDEFINED_VALUE_SYMBOL` to delimit - // `ownKeys` and `unforgeableGlobalThisKeys`. - args[args.length] = LOCKER_NEAR_MEMBRANE_UNDEFINED_VALUE_SYMBOL; - ReflectApply(ArrayProtoPush, args, unforgeableGlobalThisKeys); - } ReflectApply(this.redCallableInstallLazyPropertyDescriptors, undefined, args); } } diff --git a/packages/near-membrane-dom/src/browser-realm.ts b/packages/near-membrane-dom/src/browser-realm.ts index 4332f401..bedb9c84 100644 --- a/packages/near-membrane-dom/src/browser-realm.ts +++ b/packages/near-membrane-dom/src/browser-realm.ts @@ -21,11 +21,10 @@ import { DocumentProtoClose, DocumentProtoCreateElement, DocumentProtoOpen, - ElementProtoRemove, + ElementProtoAttachShadow, ElementProtoSetAttribute, HTMLElementProtoStyleGetter, HTMLIFrameElementProtoContentWindowGetter, - IS_OLD_CHROMIUM_BROWSER, NodeProtoAppendChild, NodeProtoLastChildGetter, } from '@locker/near-membrane-shared-dom'; @@ -37,7 +36,6 @@ import { getCachedGlobalObjectReferences, filterWindowKeys, removeWindowDescriptors, - unforgeablePoisonedWindowKeys, } from './window'; const IFRAME_SANDBOX_ATTRIBUTE_VALUE = 'allow-same-origin allow-scripts'; @@ -45,17 +43,29 @@ const IFRAME_SANDBOX_ATTRIBUTE_VALUE = 'allow-same-origin allow-scripts'; const revoked = toSafeWeakSet(new WeakSetCtor()); const blueCreateHooksCallbackCache = toSafeWeakMap(new WeakMapCtor()); -function createDetachableIframe(doc: Document): HTMLIFrameElement { - const iframe = ReflectApply(DocumentProtoCreateElement, doc, ['iframe']) as HTMLIFrameElement; +let iframeStash: ShadowRoot; + +function createShadowHiddenIframe(doc: Document): HTMLIFrameElement { // It is impossible to test whether the NodeProtoLastChildGetter branch is // reached in a normal Karma test environment. const parent: Element = ReflectApply(DocumentProtoBodyGetter, doc, []) ?? /* istanbul ignore next */ ReflectApply(NodeProtoLastChildGetter, doc, []); + + if (!iframeStash) { + const host = ReflectApply(DocumentProtoCreateElement, doc, ['div']) as HTMLDivElement; + + iframeStash = ReflectApply(ElementProtoAttachShadow, host, [ + { mode: 'closed' }, + ]) as ShadowRoot; + + ReflectApply(NodeProtoAppendChild, parent, [host]); + } + const iframe = ReflectApply(DocumentProtoCreateElement, doc, ['iframe']) as HTMLIFrameElement; const style: CSSStyleDeclaration = ReflectApply(HTMLElementProtoStyleGetter, iframe, []); style.display = 'none'; ReflectApply(ElementProtoSetAttribute, iframe, ['sandbox', IFRAME_SANDBOX_ATTRIBUTE_VALUE]); - ReflectApply(NodeProtoAppendChild, parent, [iframe]); + ReflectApply(NodeProtoAppendChild, iframeStash, [iframe]); return iframe; } @@ -76,13 +86,12 @@ function createIframeVirtualEnvironment( endowments, globalObjectShape, instrumentation, - keepAlive = true, liveTargetCallback, maxPerfMode = false, signSourceCallback, // eslint-disable-next-line prefer-object-spread } = ObjectAssign({ __proto__: null }, providedOptions) as BrowserEnvironmentOptions; - const iframe = createDetachableIframe(blueRefs.document); + const iframe = createShadowHiddenIframe(blueRefs.document); const redWindow: GlobalObject = ReflectApply( HTMLIFrameElementProtoContentWindowGetter, iframe, @@ -115,7 +124,7 @@ function createIframeVirtualEnvironment( distortionCallback, instrumentation, liveTargetCallback, - revokedProxyCallback: keepAlive ? revokedProxyCallback : undefined, + revokedProxyCallback, signSourceCallback, }); linkIntrinsics(env, globalObject); @@ -139,12 +148,7 @@ function createIframeVirtualEnvironment( blueRefs.window, shouldUseDefaultGlobalOwnKeys ? (defaultGlobalOwnKeys as PropertyKey[]) - : filterWindowKeys(getFilteredGlobalOwnKeys(globalObjectShape, maxPerfMode)), - // Chromium based browsers have a bug that nulls the result of `window` - // getters in detached iframes when the property descriptor of `window.window` - // is retrieved. - // https://bugs.chromium.org/p/chromium/issues/detail?id=1305302 - keepAlive ? undefined : unforgeablePoisonedWindowKeys + : filterWindowKeys(getFilteredGlobalOwnKeys(globalObjectShape, maxPerfMode)) ); if (endowments) { const filteredEndowments: PropertyDescriptorMap = {}; @@ -161,27 +165,15 @@ function createIframeVirtualEnvironment( env.lazyRemapProperties(blueRefs.EventTargetProto, blueRefs.EventTargetProtoOwnKeys); // We don't remap `blueRefs.WindowPropertiesProto` because it is "magical" // in that it provides access to elements by id. - // - // Once we get the iframe info ready, and all mapped, we can proceed to - // detach the iframe only if `options.keepAlive` isn't true. - if (keepAlive) { - // @TODO: Temporary hack to preserve the document reference in Firefox. - // https://bugzilla.mozilla.org/show_bug.cgi?id=543435 - const { document: redDocument } = redWindow; - // Revoke the proxies of the redDocument and redWindow to prevent access. - revoked.add(redDocument); - revoked.add(redWindow); - ReflectApply(DocumentProtoOpen, redDocument, []); - ReflectApply(DocumentProtoClose, redDocument, []); - } else { - if (IS_OLD_CHROMIUM_BROWSER) { - // For Chromium < v86 browsers we evaluate the `window` object to - // kickstart the realm so that `window` persists when the iframe is - // removed from the document. - redIndirectEval('window'); - } - ReflectApply(ElementProtoRemove, iframe, []); - } + + // @TODO: Temporary hack to preserve the document reference in Firefox. + // https://bugzilla.mozilla.org/show_bug.cgi?id=543435 (reviewed 2024-08-02, still reproduces) + const { document: redDocument } = redWindow; + // Revoke the proxies of the redDocument and redWindow to prevent access. + revoked.add(redDocument); + revoked.add(redWindow); + ReflectApply(DocumentProtoOpen, redDocument, []); + ReflectApply(DocumentProtoClose, redDocument, []); return env; } diff --git a/packages/near-membrane-dom/src/types.ts b/packages/near-membrane-dom/src/types.ts index 63f01ac5..ac105753 100644 --- a/packages/near-membrane-dom/src/types.ts +++ b/packages/near-membrane-dom/src/types.ts @@ -11,7 +11,6 @@ export interface BrowserEnvironmentOptions { endowments?: PropertyDescriptorMap; globalObjectShape?: object; instrumentation?: Instrumentation; - keepAlive?: boolean; liveTargetCallback?: LiveTargetCallback; maxPerfMode?: boolean; signSourceCallback?: SignSourceCallback; diff --git a/packages/near-membrane-dom/src/window.ts b/packages/near-membrane-dom/src/window.ts index b5195287..63752320 100644 --- a/packages/near-membrane-dom/src/window.ts +++ b/packages/near-membrane-dom/src/window.ts @@ -7,7 +7,7 @@ import { SetCtor, SetProtoHas, } from '@locker/near-membrane-shared'; -import { IS_CHROMIUM_BROWSER, rootWindow } from '@locker/near-membrane-shared-dom'; +import { rootWindow } from '@locker/near-membrane-shared-dom'; interface CachedBlueReferencesRecord extends Object { document: Document; @@ -23,12 +23,6 @@ const blueDocumentToRecordMap: WeakMap = t new WeakMap() ); -// Chromium based browsers have a bug that nulls the result of `window` -// getters in detached iframes when the property descriptor of `window.window` -// is retrieved. -// https://bugs.chromium.org/p/chromium/issues/detail?id=1305302 -export const unforgeablePoisonedWindowKeys = IS_CHROMIUM_BROWSER ? ['window'] : undefined; - export function getCachedGlobalObjectReferences( globalObject: WindowProxy & typeof globalThis ): CachedBlueReferencesRecord | undefined { diff --git a/packages/near-membrane-shared-dom/src/Element.ts b/packages/near-membrane-shared-dom/src/Element.ts index 8a2cace7..da391d8d 100644 --- a/packages/near-membrane-shared-dom/src/Element.ts +++ b/packages/near-membrane-shared-dom/src/Element.ts @@ -1,2 +1,5 @@ -export const { remove: ElementProtoRemove, setAttribute: ElementProtoSetAttribute } = - Element.prototype; +export const { + attachShadow: ElementProtoAttachShadow, + remove: ElementProtoRemove, + setAttribute: ElementProtoSetAttribute, +} = Element.prototype; diff --git a/packages/near-membrane-shared-dom/src/constants.ts b/packages/near-membrane-shared-dom/src/constants.ts deleted file mode 100644 index 732032ec..00000000 --- a/packages/near-membrane-shared-dom/src/constants.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - ArrayIsArray, - ArrayProtoFind, - ReflectApply, - RegExpProtoTest, -} from '@locker/near-membrane-shared'; -import { rootWindow } from './Window'; - -const { - // We don't cherry-pick the 'userAgent' property from `navigator` here - // to avoid triggering its getter. - navigator, - navigator: { userAgentData }, -}: any = rootWindow; -// The user-agent client hints API is experimental and subject to change. -// https://caniuse.com/mdn-api_navigator_useragentdata - -// istanbul ignore next: optional chaining and nullish coalescing results in an expansion that contains an unreachable "void 0" branch for every occurrence of the operator -const brands: { brand: string; version: string }[] = userAgentData?.brands; - -// Note: Chromium identifies itself as Chrome in its user-agent string. -// https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent -const chromiumUserAgentRegExp = / (?:Headless)?Chrome\/\d+/; - -let userAgent: string | undefined; - -function getUserAgent(): string { - if (userAgent === undefined) { - userAgent = navigator.userAgent as string; - } - return userAgent; -} - -export const IS_CHROMIUM_BROWSER = - // While experimental, `navigator.userAgentData.brands` may be defined as an - // empty array in headless Chromium based browsers. - ArrayIsArray(brands) && brands.length - ? // Use user-agent client hints API if available to avoid deprecation - // warnings. - // https://developer.mozilla.org/en-US/docs/Web/API/User-Agent_Client_Hints_API - - // istanbul ignore next: this code is not reachable in the coverage run. - ReflectApply(ArrayProtoFind, brands, [ - // prettier-ignore - (item: any) => item?.brand === 'Chromium', - ]) !== undefined - : // Fallback to a standard user-agent string sniff. - ReflectApply(RegExpProtoTest, chromiumUserAgentRegExp, [getUserAgent()]); - -export const IS_OLD_CHROMIUM_BROWSER = - IS_CHROMIUM_BROWSER && - // Chromium added support for `navigator.userAgentData` in v90. - // https://caniuse.com/mdn-api_navigator_useragentdata - userAgentData === undefined; diff --git a/packages/near-membrane-shared-dom/src/index.ts b/packages/near-membrane-shared-dom/src/index.ts index 70c71f8f..b84a1668 100644 --- a/packages/near-membrane-shared-dom/src/index.ts +++ b/packages/near-membrane-shared-dom/src/index.ts @@ -1,4 +1,3 @@ -export * from './constants'; export * from './Document'; export * from './DOMException'; export * from './Element'; diff --git a/test/dom/create-virtual-environment.spec.js b/test/dom/create-virtual-environment.spec.js index bced7534..a08a8438 100644 --- a/test/dom/create-virtual-environment.spec.js +++ b/test/dom/create-virtual-environment.spec.js @@ -36,6 +36,19 @@ describe('createVirtualEnvironment', () => { }); }); + describe('creates an environment that is always kept alive (ie. sandbox host iframe attached)', () => { + it('but is not discoverable via querySelectorAll', () => { + createVirtualEnvironment(window /* no options */); + const iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toBe(0); + }); + it('but is not discoverable via getElementsByTagName', () => { + createVirtualEnvironment(window /* no options */); + const iframes = document.getElementsByTagName('iframe'); + expect(iframes.length).toBe(0); + }); + }); + describe('options.distortionCallback', () => { it('distorts getters', () => { expect.assertions(3); @@ -74,84 +87,6 @@ describe('createVirtualEnvironment', () => { }); }); - describe('options.keepAlive', () => { - it('is true', () => { - expect.assertions(2); - - const { length: framesOffset } = window.frames; - const env = createVirtualEnvironment(window, { keepAlive: true }); - const iframes = [...document.body.querySelectorAll('iframe')]; - expect(window.frames.length).toBe(framesOffset + 1); - expect(() => env.evaluate('')).not.toThrow(); - iframes.forEach((iframe) => iframe.remove()); - }); - it('is true and revokes the attached iframe proxy', () => { - expect.assertions(16); - - const { length: framesOffset } = window.frames; - const env1 = createVirtualEnvironment(window, { keepAlive: true }); - const env2 = createVirtualEnvironment(window, { keepAlive: true }); - document.body.append(document.createElement('iframe')); - const iframes = [...document.body.querySelectorAll('iframe')]; - const remapDescriptors = { - frames: { - configurable: true, - enumerable: true, - value: Array.from(window.frames), - writable: true, - }, - }; - env1.remapProperties(window, remapDescriptors); - env2.remapProperties(window, remapDescriptors); - for (const env of [env1, env2]) { - for (let i = 0; i < 3; i += 1) { - expect(() => - env.evaluate(` - const contentWindow = window.frames[${framesOffset + i}]; - const iframes = [...document.body.querySelectorAll('iframe')]; - const iframe = iframes.find((iframe) => iframe.contentWindow === contentWindow); - iframe.contentDocument; - iframe.contentWindow; - `) - ).not.toThrow(); - if (i === 2) { - expect(() => - env.evaluate(` - const contentWindow = window.frames[${framesOffset + i}]; - const iframes = [...document.body.querySelectorAll('iframe')]; - const iframe = iframes.find((iframe) => iframe.contentWindow === contentWindow); - iframe.contentDocument.nodeName; - iframe.contentWindow.parent; - `) - ).not.toThrow(); - } else { - expect(() => - env.evaluate(` - const contentWindow = window.frames[${framesOffset + i}]; - const iframes = [...document.body.querySelectorAll('iframe')]; - const iframe = iframes.find((iframe) => iframe.contentWindow === contentWindow); - iframe.contentDocument.nodeName; - `) - ).toThrow(); - expect(() => - env.evaluate(` - const contentWindow = window.frames[${framesOffset + i}]; - const iframes = [...document.body.querySelectorAll('iframe')]; - const iframe = iframes.find((iframe) => iframe.contentWindow === contentWindow); - iframe.contentWindow.parent; - `) - ).toThrow(); - } - } - } - iframes.forEach((iframe) => iframe.remove()); - }); - it('is false', () => { - const env = createVirtualEnvironment(window, { keepAlive: false }); - expect(() => env.evaluate('')).not.toThrow(); - }); - }); - describe('options.liveTargetCallback', () => { it('affects target liveness', () => { const a = { foo: 1 }; diff --git a/test/dom/custom-element.spec.js b/test/dom/custom-element.spec.js index d3c67343..cc71d41d 100644 --- a/test/dom/custom-element.spec.js +++ b/test/dom/custom-element.spec.js @@ -15,7 +15,7 @@ const envOptions = { customElements.define('x-external', ExternalElement); describe('Outer Realm Custom Element', () => { - it('should be accessible within the sandbox', () => { + it('should be accessible within the sandbox if exists on provided globalObjectShape', () => { expect.assertions(3); const env = createVirtualEnvironment(window, envOptions); @@ -25,8 +25,11 @@ describe('Outer Realm Custom Element', () => { expect(elm.identity()).toBe('ExternalElement'); `); env.evaluate(` - document.body.innerHTML = ''; - const elm = document.body.firstChild; + const container = document.createElement('div'); + container.id = 'look-here'; + document.body.append(container); + container.innerHTML = ''; + const elm = document.getElementById('look-here').firstChild; expect(elm.identity()).toBe('ExternalElement'); `); env.evaluate(` @@ -34,7 +37,7 @@ describe('Outer Realm Custom Element', () => { expect(elm.identity()).toBe('ExternalElement'); `); }); - it('should be extensible within the sandbox', () => { + it('should be extensible within the sandbox if exists on provided globalObjectShape', () => { expect.assertions(3); const env = createVirtualEnvironment(window, envOptions); @@ -49,7 +52,7 @@ describe('Outer Realm Custom Element', () => { expect(elm instanceof ExternalElement).toBe(true); `); }); - it('should be extensible and can be new from within the sandbox', () => { + it('should be extensible and can be new from within the sandbox if exists on provided globalObjectShape', () => { expect.assertions(3); const env = createVirtualEnvironment(window, envOptions); @@ -64,7 +67,7 @@ describe('Outer Realm Custom Element', () => { expect(elm instanceof ExternalElement).toBe(true); `); }); - it('should get access to external registered elements', () => { + it('should get access to external registered elements if exists on provided globalObjectShape', () => { expect.assertions(1); window.refToExternalElement = ExternalElement; diff --git a/test/dom/ffbug.spec.js b/test/dom/ffbug.spec.js index 8fe7874f..d3d2fd3d 100644 --- a/test/dom/ffbug.spec.js +++ b/test/dom/ffbug.spec.js @@ -17,7 +17,6 @@ describe('FF BugFix 543435', () => { done(); }, }), - keepAlive: true, }); env.evaluate(`