diff --git a/src/extension-support/extension-worker.js b/src/extension-support/extension-worker.js index b0a9b89522..bdc5286a25 100644 --- a/src/extension-support/extension-worker.js +++ b/src/extension-support/extension-worker.js @@ -1,6 +1,7 @@ /* eslint-env worker */ const ScratchCommon = require('./tw-extension-api-common'); +const createScratchX = require('./tw-scratchx-compatibility-layer'); const dispatch = require('../dispatch/worker-dispatch'); const log = require('../util/log'); const {isWorker} = require('./tw-extension-worker-context'); @@ -95,4 +96,4 @@ global.Scratch.extensions = { register: extensionWorker.register.bind(extensionWorker) }; -global.ScratchExtensions = require('./tw-scratchx-compatibility-layer'); +global.ScratchExtensions = createScratchX(global.Scratch); diff --git a/src/extension-support/tw-scratchx-compatibility-layer.js b/src/extension-support/tw-scratchx-compatibility-layer.js index 227af2b52a..bceae527aa 100644 --- a/src/extension-support/tw-scratchx-compatibility-layer.js +++ b/src/extension-support/tw-scratchx-compatibility-layer.js @@ -1,8 +1,5 @@ // ScratchX API Documentation: https://github.com/LLK/scratchx/wiki/ -// Global Scratch API from extension-worker.js -/* globals Scratch */ - const ArgumentType = require('./argument-type'); const BlockType = require('./block-type'); @@ -191,31 +188,36 @@ const convert = (name, descriptor, functions) => { const extensionNameToExtension = new Map(); -const register = (name, descriptor, functions) => { - const scratch3Extension = convert(name, descriptor, functions); - extensionNameToExtension.set(name, scratch3Extension); - Scratch.extensions.register(scratch3Extension); -}; - /** - * @param {string} extensionName - * @returns {ScratchXStatus} + * @param {*} Scratch Scratch 3.0 extension API object + * @returns {*} ScratchX-compatible API object */ -const getStatus = extensionName => { - const extension = extensionNameToExtension.get(extensionName); - if (extension) { - return extension._getStatus(); - } - return { - status: 0, - msg: 'does not exist' +const createScratchX = Scratch => { + const register = (name, descriptor, functions) => { + const scratch3Extension = convert(name, descriptor, functions); + extensionNameToExtension.set(name, scratch3Extension); + Scratch.extensions.register(scratch3Extension); }; -}; -module.exports = { - register, - getStatus, + /** + * @param {string} extensionName + * @returns {ScratchXStatus} + */ + const getStatus = extensionName => { + const extension = extensionNameToExtension.get(extensionName); + if (extension) { + return extension._getStatus(); + } + return { + status: 0, + msg: 'does not exist' + }; + }; - // For tests - convert + return { + register, + getStatus + }; }; + +module.exports = createScratchX; diff --git a/src/extension-support/tw-unsandboxed-extension-runner.js b/src/extension-support/tw-unsandboxed-extension-runner.js index c249b8ced5..ec68264613 100644 --- a/src/extension-support/tw-unsandboxed-extension-runner.js +++ b/src/extension-support/tw-unsandboxed-extension-runner.js @@ -1,4 +1,5 @@ const ScratchCommon = require('./tw-extension-api-common'); +const createScratchX = require('./tw-scratchx-compatibility-layer'); const AsyncLimiter = require('../util/async-limiter'); const createTranslate = require('./tw-l10n'); @@ -16,19 +17,18 @@ const parseURL = url => { }; /** - * Sets up the global.Scratch API for an unsandboxed extension. + * Create the unsandboxed extension API objects. * @param {VirtualMachine} vm - * @returns {Promise} Resolves with a list of extension objects when Scratch.extensions.register is called. + * @returns {*} The objects */ -const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => { - const extensionObjects = []; - const register = extensionObject => { - extensionObjects.push(extensionObject); - resolve(extensionObjects); +const createAPI = vm => { + const extensions = []; + const register = extension => { + extensions.push(extension); }; - // Create a new copy of global.Scratch for each extension - const Scratch = Object.assign({}, global.Scratch || {}, ScratchCommon); + // Each extension should get its own copy of Scratch so they don't break things badly + const Scratch = Object.assign({}, global.Scratch, ScratchCommon); Scratch.extensions = { unsandboxed: true, register @@ -114,18 +114,12 @@ const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => { Scratch.translate = createTranslate(vm); - global.Scratch = Scratch; - global.ScratchExtensions = require('./tw-scratchx-compatibility-layer'); -}); + const ScratchExtensions = createScratchX(Scratch); -/** - * Disable the existing global.Scratch unsandboxed extension APIs. - * This helps debug poorly designed extensions. - */ -const teardownUnsandboxedExtensionAPI = () => { - // We can assume global.Scratch already exists. - global.Scratch.extensions.register = () => { - throw new Error('Too late to register new extensions.'); + return { + Scratch, + ScratchExtensions, + extensions }; }; @@ -135,26 +129,34 @@ const teardownUnsandboxedExtensionAPI = () => { * @param {Virtualmachine} vm * @returns {Promise} Resolves with a list of extension objects if the extension was loaded successfully. */ -const loadUnsandboxedExtension = (extensionURL, vm) => new Promise((resolve, reject) => { - setupUnsandboxedExtensionAPI(vm).then(resolve); +const loadUnsandboxedExtension = async (extensionURL, vm) => { + const res = await fetch(extensionURL); + if (!res.ok) { + throw new Error(`HTTP status ${extensionURL}`); + } - const script = document.createElement('script'); - script.onerror = () => { - reject(new Error(`Error in unsandboxed script ${extensionURL}. Check the console for more information.`)); - }; - script.src = extensionURL; - document.body.appendChild(script); -}).then(objects => { - teardownUnsandboxedExtensionAPI(); - return objects; -}); - -// Because loading unsandboxed extensions requires messing with global state (global.Scratch), -// only let one extension load at a time. + const text = await res.text(); + + // AsyncFunction isn't a global like Function, but we can still access it indirectly like this + const AsyncFunction = (async () => {}).constructor; + + const api = createAPI(vm); + const fn = new AsyncFunction('Scratch', 'ScratchExtensions', text); + await fn(api.Scratch, api.ScratchExtensions); + + if (api.extensions.length === 0) { + throw new Error('Extension called register() 0 times'); + } + return api.extensions; +}; + +// For now we force them to load one at a time to ensure consistent order const limiter = new AsyncLimiter(loadUnsandboxedExtension, 1); const load = (extensionURL, vm) => limiter.do(extensionURL, vm); module.exports = { - setupUnsandboxedExtensionAPI, - load + load, + + // For tests: + createAPI }; diff --git a/test/integration/tw_privacy.js b/test/integration/tw_privacy.js index fda382f711..f8695d49a3 100644 --- a/test/integration/tw_privacy.js +++ b/test/integration/tw_privacy.js @@ -94,23 +94,13 @@ test('custom extensions', async t => { vm.attachRenderer(mockRenderer()); vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed'; - global.document = { - createElement: () => { - const element = {}; - setTimeout(() => { - global.Scratch.extensions.register({ - getInfo: () => ({}) - }); - }); - return element; - }, - body: { - appendChild: () => {} - } - }; + global.fetch = () => Promise.resolve({ + ok: true, + text: () => Promise.resolve('Scratch.extensions.register({getInfo: () => ({id: "test", blocks: []})})') + }); t.equal(vm.renderer.privateSkinAccess, true); - await vm.extensionManager.loadExtensionURL('data:application/javascript;,'); + await vm.extensionManager.loadExtensionURL('data:application/javascript;,fake URL see fetch() mock'); t.equal(vm.renderer.privateSkinAccess, false); t.end(); }); diff --git a/test/integration/tw_security_manager.js b/test/integration/tw_security_manager.js index cae95d25a3..9286a91c9c 100644 --- a/test/integration/tw_security_manager.js +++ b/test/integration/tw_security_manager.js @@ -2,7 +2,7 @@ const {test} = require('tap'); const fs = require('fs'); const path = require('path'); const VirtualMachine = require('../../src/virtual-machine'); -const {setupUnsandboxedExtensionAPI} = require('../../src/extension-support/tw-unsandboxed-extension-runner'); +const {createAPI} = require('../../src/extension-support/tw-unsandboxed-extension-runner'); const testProject = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-project-with-extensions.sb3')); @@ -67,49 +67,49 @@ test('Allow both extensions', async t => { test('canFetch', async t => { const vm = new VirtualMachine(); - setupUnsandboxedExtensionAPI(vm); + const api = createAPI(vm); global.location = { href: 'https://example.com/' }; // data: and blob: are always allowed, shouldn't call security manager vm.securityManager.canFetch = () => t.fail('security manager should be ignored for these protocols'); - t.equal(await global.Scratch.canFetch('data:text/html,test'), true); - t.equal(await global.Scratch.canFetch('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); + t.equal(await api.Scratch.canFetch('data:text/html,test'), true); + t.equal(await api.Scratch.canFetch('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); vm.securityManager.canFetch = () => false; - t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false); - t.equal(await global.Scratch.canFetch('http://example.com/'), false); - t.equal(await global.Scratch.canFetch('https://example.com/'), false); - t.equal(await global.Scratch.canFetch('null'), false); - t.equal(await global.Scratch.canFetch(null), false); + t.equal(await api.Scratch.canFetch('file:///etc/hosts'), false); + t.equal(await api.Scratch.canFetch('http://example.com/'), false); + t.equal(await api.Scratch.canFetch('https://example.com/'), false); + t.equal(await api.Scratch.canFetch('null'), false); + t.equal(await api.Scratch.canFetch(null), false); vm.securityManager.canFetch = () => Promise.resolve(false); - t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false); - t.equal(await global.Scratch.canFetch('http://example.com/'), false); - t.equal(await global.Scratch.canFetch('https://example.com/'), false); - t.equal(await global.Scratch.canFetch('boring.html'), false); - t.equal(await global.Scratch.canFetch('null'), false); - t.equal(await global.Scratch.canFetch(null), false); + t.equal(await api.Scratch.canFetch('file:///etc/hosts'), false); + t.equal(await api.Scratch.canFetch('http://example.com/'), false); + t.equal(await api.Scratch.canFetch('https://example.com/'), false); + t.equal(await api.Scratch.canFetch('boring.html'), false); + t.equal(await api.Scratch.canFetch('null'), false); + t.equal(await api.Scratch.canFetch(null), false); vm.securityManager.canFetch = () => true; - t.equal(await global.Scratch.canFetch('file:///etc/hosts'), true); - t.equal(await global.Scratch.canFetch('http://example.com/'), true); - t.equal(await global.Scratch.canFetch('https://example.com/'), true); - t.equal(await global.Scratch.canFetch('boring.html'), true); - t.equal(await global.Scratch.canFetch('null'), true); - t.equal(await global.Scratch.canFetch(null), true); + t.equal(await api.Scratch.canFetch('file:///etc/hosts'), true); + t.equal(await api.Scratch.canFetch('http://example.com/'), true); + t.equal(await api.Scratch.canFetch('https://example.com/'), true); + t.equal(await api.Scratch.canFetch('boring.html'), true); + t.equal(await api.Scratch.canFetch('null'), true); + t.equal(await api.Scratch.canFetch(null), true); const calledWithURLs = []; vm.securityManager.canFetch = async url => { calledWithURLs.push(url); return url === 'https://example.com/null'; }; - t.equal(await global.Scratch.canFetch('file:///etc/hosts'), false); - t.equal(await global.Scratch.canFetch('http://example.com/'), false); - t.equal(await global.Scratch.canFetch('https://example.com/null'), true); - t.equal(await global.Scratch.canFetch('null'), true); - t.equal(await global.Scratch.canFetch(null), true); + t.equal(await api.Scratch.canFetch('file:///etc/hosts'), false); + t.equal(await api.Scratch.canFetch('http://example.com/'), false); + t.equal(await api.Scratch.canFetch('https://example.com/null'), true); + t.equal(await api.Scratch.canFetch('null'), true); + t.equal(await api.Scratch.canFetch(null), true); t.same(calledWithURLs, [ 'file:///etc/hosts', 'http://example.com/', @@ -123,50 +123,50 @@ test('canFetch', async t => { test('canOpenWindow', async t => { const vm = new VirtualMachine(); - setupUnsandboxedExtensionAPI(vm); + const api = createAPI(vm); global.location = { href: 'https://example.com/' }; // javascript: should never be allowed, shouldn't call security manager vm.securityManager.canOpenWindow = () => t.fail('should not call security manager for javascript:'); - t.equal(await global.Scratch.canOpenWindow('javascript:alert(1)'), false); + t.equal(await api.Scratch.canOpenWindow('javascript:alert(1)'), false); vm.securityManager.canOpenWindow = () => false; - t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false); - t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), false); - t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false); - t.equal(await global.Scratch.canOpenWindow('index.html'), false); - t.equal(await global.Scratch.canOpenWindow(null), false); + t.equal(await api.Scratch.canOpenWindow('data:text/html,test'), false); + t.equal(await api.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canOpenWindow('file:///etc/hosts'), false); + t.equal(await api.Scratch.canOpenWindow('https://example.com/'), false); + t.equal(await api.Scratch.canOpenWindow('index.html'), false); + t.equal(await api.Scratch.canOpenWindow(null), false); vm.securityManager.canOpenWindow = () => Promise.resolve(false); - t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false); - t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), false); - t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false); - t.equal(await global.Scratch.canOpenWindow('index.html'), false); - t.equal(await global.Scratch.canOpenWindow(null), false); + t.equal(await api.Scratch.canOpenWindow('data:text/html,test'), false); + t.equal(await api.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canOpenWindow('file:///etc/hosts'), false); + t.equal(await api.Scratch.canOpenWindow('https://example.com/'), false); + t.equal(await api.Scratch.canOpenWindow('index.html'), false); + t.equal(await api.Scratch.canOpenWindow(null), false); vm.securityManager.canOpenWindow = () => true; - t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), true); - t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); - t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), true); - t.equal(await global.Scratch.canOpenWindow('https://example.com/'), true); - t.equal(await global.Scratch.canOpenWindow('index.html'), true); - t.equal(await global.Scratch.canOpenWindow(null), true); + t.equal(await api.Scratch.canOpenWindow('data:text/html,test'), true); + t.equal(await api.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); + t.equal(await api.Scratch.canOpenWindow('file:///etc/hosts'), true); + t.equal(await api.Scratch.canOpenWindow('https://example.com/'), true); + t.equal(await api.Scratch.canOpenWindow('index.html'), true); + t.equal(await api.Scratch.canOpenWindow(null), true); const calledWithURLs = []; vm.securityManager.canOpenWindow = async url => { calledWithURLs.push(url); return url === 'file:///etc/hosts'; }; - t.equal(await global.Scratch.canOpenWindow('data:text/html,test'), false); - t.equal(await global.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canOpenWindow('file:///etc/hosts'), true); - t.equal(await global.Scratch.canOpenWindow('https://example.com/'), false); - t.equal(await global.Scratch.canOpenWindow('index.html'), false); - t.equal(await global.Scratch.canOpenWindow(null), false); + t.equal(await api.Scratch.canOpenWindow('data:text/html,test'), false); + t.equal(await api.Scratch.canOpenWindow('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canOpenWindow('file:///etc/hosts'), true); + t.equal(await api.Scratch.canOpenWindow('https://example.com/'), false); + t.equal(await api.Scratch.canOpenWindow('index.html'), false); + t.equal(await api.Scratch.canOpenWindow(null), false); t.same(calledWithURLs, [ 'data:text/html,test', 'blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd', @@ -181,50 +181,50 @@ test('canOpenWindow', async t => { test('canRedirect', async t => { const vm = new VirtualMachine(); - setupUnsandboxedExtensionAPI(vm); + const api = createAPI(vm); global.location = { href: 'https://example.com/' }; // javascript: should never be allowed, shouldn't call security manager vm.securityManager.canRedirect = () => t.fail('should not call security manager for javascript:'); - t.equal(await global.Scratch.canRedirect('javascript:alert(1)'), false); + t.equal(await api.Scratch.canRedirect('javascript:alert(1)'), false); vm.securityManager.canRedirect = () => false; - t.equal(await global.Scratch.canRedirect('data:text/html,test'), false); - t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), false); - t.equal(await global.Scratch.canRedirect('https://example.com/'), false); - t.equal(await global.Scratch.canRedirect('index.html'), false); - t.equal(await global.Scratch.canRedirect(null), false); + t.equal(await api.Scratch.canRedirect('data:text/html,test'), false); + t.equal(await api.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canRedirect('file:///etc/hosts'), false); + t.equal(await api.Scratch.canRedirect('https://example.com/'), false); + t.equal(await api.Scratch.canRedirect('index.html'), false); + t.equal(await api.Scratch.canRedirect(null), false); vm.securityManager.canRedirect = () => Promise.resolve(false); - t.equal(await global.Scratch.canRedirect('data:text/html,test'), false); - t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), false); - t.equal(await global.Scratch.canRedirect('https://example.com/'), false); - t.equal(await global.Scratch.canRedirect('index.html'), false); - t.equal(await global.Scratch.canRedirect(null), false); + t.equal(await api.Scratch.canRedirect('data:text/html,test'), false); + t.equal(await api.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canRedirect('file:///etc/hosts'), false); + t.equal(await api.Scratch.canRedirect('https://example.com/'), false); + t.equal(await api.Scratch.canRedirect('index.html'), false); + t.equal(await api.Scratch.canRedirect(null), false); vm.securityManager.canRedirect = () => true; - t.equal(await global.Scratch.canRedirect('data:text/html,test'), true); - t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); - t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), true); - t.equal(await global.Scratch.canRedirect('https://example.com/'), true); - t.equal(await global.Scratch.canRedirect('index.html'), true); - t.equal(await global.Scratch.canRedirect(null), true); + t.equal(await api.Scratch.canRedirect('data:text/html,test'), true); + t.equal(await api.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), true); + t.equal(await api.Scratch.canRedirect('file:///etc/hosts'), true); + t.equal(await api.Scratch.canRedirect('https://example.com/'), true); + t.equal(await api.Scratch.canRedirect('index.html'), true); + t.equal(await api.Scratch.canRedirect(null), true); const calledWithURLs = []; vm.securityManager.canRedirect = async url => { calledWithURLs.push(url); return url === 'file:///etc/hosts'; }; - t.equal(await global.Scratch.canRedirect('data:text/html,test'), false); - t.equal(await global.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); - t.equal(await global.Scratch.canRedirect('file:///etc/hosts'), true); - t.equal(await global.Scratch.canRedirect('https://example.com/'), false); - t.equal(await global.Scratch.canRedirect('index.html'), false); - t.equal(await global.Scratch.canRedirect(null), false); + t.equal(await api.Scratch.canRedirect('data:text/html,test'), false); + t.equal(await api.Scratch.canRedirect('blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd'), false); + t.equal(await api.Scratch.canRedirect('file:///etc/hosts'), true); + t.equal(await api.Scratch.canRedirect('https://example.com/'), false); + t.equal(await api.Scratch.canRedirect('index.html'), false); + t.equal(await api.Scratch.canRedirect(null), false); t.same(calledWithURLs, [ 'data:text/html,test', 'blob:https://example.com/8c071bf8-c0b6-4a48-81d7-6413c2adf3dd', diff --git a/test/integration/tw_unsandboxed_extensions.js b/test/integration/tw_unsandboxed_extensions.js new file mode 100644 index 0000000000..079ba4b2cc --- /dev/null +++ b/test/integration/tw_unsandboxed_extensions.js @@ -0,0 +1,265 @@ +const tap = require('tap'); +const {test} = tap; +const VirtualMachine = require('../../src/virtual-machine'); +const {createAPI} = require('../../src/extension-support/tw-unsandboxed-extension-runner'); + +/* globals Request */ +// Mock several browser APIs that unsandboxed extensions use. +global.location = { + href: 'https://turbowarp.org/editor' +}; +global.Request = class { + constructor (url) { + this.url = url; + } +}; +global.fetch = (url, options = {}) => ( + Promise.resolve(`[Response ${url instanceof Request ? url.url : url} options=${JSON.stringify(options)}]`) +); +global.window = { + open: (url, target, features) => `[Window ${url} target=${target || ''} features=${features || ''}]` +}; + +test('basic register', t => { + const vm = new VirtualMachine(); + const api = createAPI(vm); + + class MyExtension {} + api.Scratch.extensions.register(new MyExtension()); + + t.equal(api.extensions.length, 1); + t.ok(api.extensions[0] instanceof MyExtension); + + api.Scratch.extensions.register(new MyExtension()); + t.equal(api.extensions.length, 2); + t.ok(api.extensions[0] instanceof MyExtension); + t.ok(api.extensions[1] instanceof MyExtension); + + t.end(); +}); + +test('basic APIs', t => { + const vm = new VirtualMachine(); + const api = createAPI(vm); + + t.equal(api.Scratch.ArgumentType.NUMBER, 'number'); + t.equal(api.Scratch.BlockType.REPORTER, 'reporter'); + t.equal(api.Scratch.TargetType.SPRITE, 'sprite'); + t.equal(api.Scratch.Cast.toNumber('3.14'), 3.14); + + t.equal(api.Scratch.vm, vm); + t.equal(api.Scratch.renderer, vm.runtime.renderer); + t.equal(api.Scratch.extensions.unsandboxed, true); + + t.end(); +}); + +test('ScratchX', t => { + const vm = new VirtualMachine(); + const api = createAPI(vm); + + const ext = { + test: () => 2 + }; + const descriptor = { + blocks: [ + ['r', 'test', 'test'] + ] + }; + api.ScratchExtensions.register('Test', descriptor, ext); + + t.equal(api.extensions.length, 1); + t.end(); +}); + +test('canFetch', t => { + // see tw_security_manager.js + const vm = new VirtualMachine(); + const api = createAPI(vm); + api.Scratch.canFetch('https://example.com/').then(allowed => { + t.ok(allowed); + t.end(); + }); +}); + +test('fetch', async t => { + t.plan(5); + const vm = new VirtualMachine(); + const api = createAPI(vm); + api.Scratch.canFetch = url => url === 'https://example.com/2'; + await t.rejects(api.Scratch.fetch('https://example.com/1'), /Permission to fetch https:\/\/example.com\/1 rejected/); + await t.rejects(api.Scratch.fetch(new Request('https://example.com/1')), /Permission to fetch https:\/\/example.com\/1 rejected/); + t.equal(await api.Scratch.fetch('https://example.com/2'), '[Response https://example.com/2 options={"redirect":"error"}]'); + t.equal(await api.Scratch.fetch(new Request('https://example.com/2')), '[Response https://example.com/2 options={"redirect":"error"}]'); + t.equal(await api.Scratch.fetch('https://example.com/2', { + // redirect should be ignored and always set to error + redirect: 'follow', + method: 'POST', + body: 'abc' + }), '[Response https://example.com/2 options={"redirect":"error","method":"POST","body":"abc"}]'); + t.end(); +}); + +test('canOpenWindow', async t => { + // see tw_security_manager.js + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + const result = api.Scratch.canOpenWindow('https://example.com/'); + t.type(result, Promise); + t.equal(await result, true); + t.end(); +}); + +test('openWindow', async t => { + t.plan(3); + const vm = new VirtualMachine(); + const api = createAPI(vm); + api.Scratch.canOpenWindow = url => url === 'https://example.com/2'; + await t.rejects(api.Scratch.openWindow('https://example.com/1'), /Permission to open tab https:\/\/example.com\/1 rejected/); + t.equal(await api.Scratch.openWindow('https://example.com/2'), '[Window https://example.com/2 target=_blank features=noreferrer]'); + t.equal(await api.Scratch.openWindow('https://example.com/2', 'popup=1'), '[Window https://example.com/2 target=_blank features=noreferrer,popup=1]'); + t.end(); +}); + +test('canRedirect', async t => { + // see tw_security_manager.js + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + const result = api.Scratch.canRedirect('https://example.com/'); + t.type(result, Promise); + t.equal(await result, true); + t.end(); +}); + +test('redirect', async t => { + t.plan(3); + const vm = new VirtualMachine(); + const api = createAPI(vm); + api.Scratch.canRedirect = url => url === 'https://example.com/safe-redirect-place'; + await t.rejects(api.Scratch.redirect('https://example.com/not-safe'), /Permission to redirect to https:\/\/example.com\/not-safe rejected/); + t.equal(global.location.href, 'https://turbowarp.org/editor'); + await api.Scratch.redirect('https://example.com/safe-redirect-place'); + t.equal(global.location.href, 'https://example.com/safe-redirect-place'); + t.end(); +}); + +test('translate', async t => { + t.plan(5); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + t.equal(api.Scratch.translate({ + id: 'test1', + default: 'Message 1: {var}', + description: 'Description' + }, { + var: 'test' + }), 'Message 1: test'); + t.equal(api.Scratch.translate('test1 {var}', { + var: 'ok' + }), 'test1 ok'); + + api.Scratch.translate.setup({ + en: { + test1: 'EN Message 1: {var}' + }, + es: { + test1: 'ES Message 1: {var}' + } + }); + t.equal(api.Scratch.translate({ + id: 'test1', + default: 'Message 1: {var}', + description: 'Description' + }, { + var: 'test' + }), 'EN Message 1: test'); + t.equal(api.Scratch.translate('test1 {var}', { + var: 'ok' + }), 'test1 ok'); + + await vm.setLocale('es'); + // do not call setup() again; real extensions will not do that. + // need to make sure that the translatiosn are saved after calling setLocale. + t.equal(api.Scratch.translate({ + id: 'test1', + default: 'Message 1: {var}', + description: 'Description' + }, { + var: 'test' + }), 'ES Message 1: test'); + + t.end(); +}); + +test('canRecordAudio', async t => { + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + vm.securityManager.canRecordAudio = () => false; + t.equal(await api.Scratch.canRecordAudio(), false); + + vm.securityManager.canRecordAudio = () => true; + t.equal(await api.Scratch.canRecordAudio(), true); + + t.end(); +}); + +test('canRecordVideo', async t => { + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + vm.securityManager.canRecordVideo = () => false; + t.equal(await api.Scratch.canRecordVideo(), false); + + vm.securityManager.canRecordVideo = () => true; + t.equal(await api.Scratch.canRecordVideo(), true); + + t.end(); +}); + +test('canReadClipboard', async t => { + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + vm.securityManager.canReadClipboard = () => false; + t.equal(await api.Scratch.canReadClipboard(), false); + + vm.securityManager.canReadClipboard = () => true; + t.equal(await api.Scratch.canReadClipboard(), true); + + t.end(); +}); + +test('canNotify', async t => { + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + vm.securityManager.canNotify = () => false; + t.equal(await api.Scratch.canNotify(), false); + + vm.securityManager.canNotify = () => true; + t.equal(await api.Scratch.canNotify(), true); + + t.end(); +}); + +test('canGeolocate', async t => { + t.plan(2); + const vm = new VirtualMachine(); + const api = createAPI(vm); + + vm.securityManager.canGeolocate = () => false; + t.equal(await api.Scratch.canGeolocate(), false); + + vm.securityManager.canGeolocate = () => true; + t.equal(await api.Scratch.canGeolocate(), true); + + t.end(); +}); diff --git a/test/unit/tw_extension_manager.js b/test/unit/tw_extension_manager.js index c7b18defb6..24e74d2fa8 100644 --- a/test/unit/tw_extension_manager.js +++ b/test/unit/tw_extension_manager.js @@ -27,42 +27,38 @@ test('_isValidExtensionURL', t => { test('loadExtensionURL, getExtensionURLs, deduplication', async t => { const vm = new VM(); - let loadedExtensions = 0; vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed'; - global.document = { - createElement: () => { - loadedExtensions++; - const element = {}; - setTimeout(() => { - global.Scratch.extensions.register({ - getInfo: () => ({ - id: `extension${loadedExtensions}` - }) - }); + + global.loadedExtensionCount = 0; + global.fetch = () => Promise.resolve({ + ok: true, + text: () => Promise.resolve(` + const id = ++global.loadedExtensionCount; + Scratch.extensions.register({ + getInfo: () => ({ + id: "testextension" + id + }), + blocks: [] }); - return element; - }, - body: { - appendChild: () => {} - } - }; + `) + }); const url1 = 'https://turbowarp.org/1.js'; t.equal(vm.extensionManager.isExtensionURLLoaded(url1), false); t.same(vm.extensionManager.getExtensionURLs(), {}); await vm.extensionManager.loadExtensionURL(url1); t.equal(vm.extensionManager.isExtensionURLLoaded(url1), true); - t.equal(loadedExtensions, 1); + t.equal(global.loadedExtensionCount, 1); t.same(vm.extensionManager.getExtensionURLs(), { - extension1: url1 + testextension1: url1 }); // Loading the extension again should do nothing. await vm.extensionManager.loadExtensionURL(url1); t.equal(vm.extensionManager.isExtensionURLLoaded(url1), true); - t.equal(loadedExtensions, 1); + t.equal(global.loadedExtensionCount, 1); t.same(vm.extensionManager.getExtensionURLs(), { - extension1: url1 + testextension1: url1 }); // Loading another extension should work @@ -70,10 +66,10 @@ test('loadExtensionURL, getExtensionURLs, deduplication', async t => { t.equal(vm.extensionManager.isExtensionURLLoaded(url2), false); await vm.extensionManager.loadExtensionURL(url2); t.equal(vm.extensionManager.isExtensionURLLoaded(url2), true); - t.equal(loadedExtensions, 2); + t.equal(global.loadedExtensionCount, 2); t.same(vm.extensionManager.getExtensionURLs(), { - extension1: url1, - extension2: url2 + testextension1: url1, + testextension2: url2 }); t.end(); diff --git a/test/unit/tw_scratchx.js b/test/unit/tw_scratchx.js index 90ecb1c307..bf2d8d9562 100644 --- a/test/unit/tw_scratchx.js +++ b/test/unit/tw_scratchx.js @@ -1,5 +1,5 @@ const ScratchXUtilities = require('../../src/extension-support/tw-scratchx-utilities'); -const ScratchExtensions = require('../../src/extension-support/tw-scratchx-compatibility-layer'); +const createScratchX = require('../../src/extension-support/tw-scratchx-compatibility-layer'); const {test} = require('tap'); test('argument index to id', t => { @@ -19,7 +19,34 @@ test('generate extension id', t => { t.end(); }); +const mockScratchExtensions = () => { + const mockScratch = {}; + return createScratchX(mockScratch); +}; + +const convert = (...args) => { + let registered = null; + const mockScratch = { + extensions: { + register: extensionObject => { + if (registered) { + // In tests we don't want this + throw new Error('register() called twice'); + } + registered = extensionObject; + } + } + }; + const ScratchExtensions = createScratchX(mockScratch); + ScratchExtensions.register(...args); + if (!registered) { + throw new Error('Did not register()'); + } + return registered; +}; + test('register', t => { + const ScratchExtensions = mockScratchExtensions(); t.type(ScratchExtensions.register, 'function'); t.end(); }); @@ -49,7 +76,7 @@ test('complex extension', async t => { const touching = sprite => sprite === 'Sprite9'; - const converted = ScratchExtensions.convert( + const converted = convert( 'My Extension', { blocks: [ @@ -215,7 +242,7 @@ test('complex extension', async t => { }); test('display name', t => { - const converted = ScratchExtensions.convert( + const converted = convert( 'Internal Name', { blocks: [], @@ -234,7 +261,7 @@ test('_getStatus', t => { status: 2, msg: 'Ready' }); - const converted = ScratchExtensions.convert( + const converted = convert( 'Name', { blocks: [] diff --git a/test/unit/tw_unsandboxed_extensions.js b/test/unit/tw_unsandboxed_extensions.js deleted file mode 100644 index 4052486678..0000000000 --- a/test/unit/tw_unsandboxed_extensions.js +++ /dev/null @@ -1,358 +0,0 @@ -const tap = require('tap'); -const UnsandboxedExtensionRunner = require('../../src/extension-support/tw-unsandboxed-extension-runner'); -const VirtualMachine = require('../../src/virtual-machine'); - -// Mock enough of the document API for the extension runner to think it works. -// To more accurately test this, we want to make sure that the URLs we pass in are just strings. -// We use a bit of hacky state here to make our document mock know what function to run -// when a script with a given URL "loads" -const scriptCallbacks = new Map(); -const setScript = (src, callback) => { - scriptCallbacks.set(src, callback); -}; -global.document = { - createElement: tagName => { - if (tagName.toLowerCase() !== 'script') { - throw new Error(`Unknown element: ${tagName}`); - } - return { - tagName: 'SCRIPT', - src: '', - onload: () => {}, - onerror: () => {} - }; - }, - body: { - appendChild: element => { - if (element.tagName === 'SCRIPT') { - setTimeout(() => { - const callback = scriptCallbacks.get(element.src); - if (callback) { - callback(); - element.onload(); - } else { - element.onerror(); - } - }, 50); - } - } - } -}; - -// Mock various DOM APIs for fetching, window opening, redirecting, etc. -/* globals Request */ -global.Request = class { - constructor (url) { - this.url = url; - } -}; -global.fetch = (url, options = {}) => ( - Promise.resolve(`[Response ${url instanceof Request ? url.url : url} options=${JSON.stringify(options)}]`) -); -global.window = { - open: (url, target, features) => `[Window ${url} target=${target || ''} features=${features || ''}]` -}; - -tap.beforeEach(async () => { - scriptCallbacks.clear(); - global.location = { - href: 'https://example.com/' - }; -}); - -const {test} = tap; - -test('basic API', async t => { - t.plan(9); - const vm = new VirtualMachine(); - class MyExtension {} - setScript('https://turbowarp.org/1.js', () => { - t.equal(global.Scratch.vm, vm); - t.equal(global.Scratch.renderer, vm.runtime.renderer); - t.equal(global.Scratch.extensions.unsandboxed, true); - - // These APIs are tested elsewhere, just make sure they're getting exported - t.equal(global.Scratch.ArgumentType.NUMBER, 'number'); - t.equal(global.Scratch.BlockType.REPORTER, 'reporter'); - t.equal(global.Scratch.TargetType.SPRITE, 'sprite'); - t.equal(global.Scratch.Cast.toNumber('3.14'), 3.14); - - global.Scratch.extensions.register(new MyExtension()); - }); - const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm); - t.equal(extensions.length, 1); - t.ok(extensions[0] instanceof MyExtension); - t.end(); -}); - -test('multiple VMs loading extensions', async t => { - const vm1 = new VirtualMachine(); - const vm2 = new VirtualMachine(); - - class Extension1 {} - class Extension2 {} - - let api1 = null; - setScript('https://turbowarp.org/1.js', async () => { - // Even if this extension takes a while to register, we should still have our own - // global.Scratch. - await new Promise(resolve => setTimeout(resolve, 100)); - - if (api1) throw new Error('already ran 1'); - api1 = global.Scratch; - global.Scratch.extensions.register(new Extension1()); - }); - - let api2 = null; - setScript('https://turbowarp.org/2.js', () => { - if (api2) throw new Error('already ran 2'); - api2 = global.Scratch; - global.Scratch.extensions.register(new Extension2()); - }); - - const extensions = await Promise.all([ - UnsandboxedExtensionRunner.load('https://turbowarp.org/1.js', vm1), - UnsandboxedExtensionRunner.load('https://turbowarp.org/2.js', vm2) - ]); - - t.not(api1, api2); - t.type(api1.extensions.register, 'function'); - t.type(api2.extensions.register, 'function'); - t.equal(api1.vm, vm1); - t.equal(api2.vm, vm2); - - t.equal(extensions.length, 2); - t.equal(extensions[0].length, 1); - t.equal(extensions[1].length, 1); - t.ok(extensions[0][0] instanceof Extension1); - t.ok(extensions[1][0] instanceof Extension2); - - t.end(); -}); - -test('register multiple extensions in one script', async t => { - const vm = new VirtualMachine(); - class Extension1 {} - class Extension2 {} - setScript('https://turbowarp.org/multiple.js', () => { - global.Scratch.extensions.register(new Extension1()); - global.Scratch.extensions.register(new Extension2()); - }); - const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/multiple.js', vm); - t.equal(extensions.length, 2); - t.ok(extensions[0] instanceof Extension1); - t.ok(extensions[1] instanceof Extension2); - t.end(); -}); - -test('extension error results in rejection', async t => { - const vm = new VirtualMachine(); - try { - await UnsandboxedExtensionRunner.load('https://turbowarp.org/404.js', vm); - // Above should throw an error as the script will not load successfully - t.fail(); - } catch (e) { - t.pass(); - } - t.end(); -}); - -test('ScratchX', async t => { - const vm = new VirtualMachine(); - setScript('https://turbowarp.org/scratchx.js', () => { - const ext = { - test: () => 2 - }; - const descriptor = { - blocks: [ - ['r', 'test', 'test'] - ] - }; - global.ScratchExtensions.register('Test', descriptor, ext); - }); - const extensions = await UnsandboxedExtensionRunner.load('https://turbowarp.org/scratchx.js', vm); - t.equal(extensions.length, 1); - t.equal(extensions[0].test(), 2); - t.end(); -}); - -test('canFetch', async t => { - // see tw_security_manager.js - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - const result = global.Scratch.canFetch('https://example.com/'); - t.type(result, Promise); - t.equal(await result, true); - t.end(); -}); - -test('fetch', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - global.Scratch.canFetch = url => url === 'https://example.com/2'; - await t.rejects(global.Scratch.fetch('https://example.com/1'), /Permission to fetch https:\/\/example.com\/1 rejected/); - await t.rejects(global.Scratch.fetch(new Request('https://example.com/1')), /Permission to fetch https:\/\/example.com\/1 rejected/); - t.equal(await global.Scratch.fetch('https://example.com/2'), '[Response https://example.com/2 options={"redirect":"error"}]'); - t.equal(await global.Scratch.fetch(new Request('https://example.com/2')), '[Response https://example.com/2 options={"redirect":"error"}]'); - t.equal(await global.Scratch.fetch('https://example.com/2', { - // redirect should be ignored and always set to error - redirect: 'follow', - method: 'POST', - body: 'abc' - }), '[Response https://example.com/2 options={"redirect":"error","method":"POST","body":"abc"}]'); - t.end(); -}); - -test('canOpenWindow', async t => { - // see tw_security_manager.js - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - const result = global.Scratch.canOpenWindow('https://example.com/'); - t.type(result, Promise); - t.equal(await result, true); - t.end(); -}); - -test('openWindow', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - global.Scratch.canOpenWindow = url => url === 'https://example.com/2'; - await t.rejects(global.Scratch.openWindow('https://example.com/1'), /Permission to open tab https:\/\/example.com\/1 rejected/); - t.equal(await global.Scratch.openWindow('https://example.com/2'), '[Window https://example.com/2 target=_blank features=noreferrer]'); - t.equal(await global.Scratch.openWindow('https://example.com/2', 'popup=1'), '[Window https://example.com/2 target=_blank features=noreferrer,popup=1]'); - t.end(); -}); - -test('canRedirect', async t => { - // see tw_security_manager.js - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - const result = global.Scratch.canRedirect('https://example.com/'); - t.type(result, Promise); - t.equal(await result, true); - t.end(); -}); - -test('redirect', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - global.Scratch.canRedirect = url => url === 'https://example.com/2'; - await t.rejects(global.Scratch.redirect('https://example.com/1'), /Permission to redirect to https:\/\/example.com\/1 rejected/); - t.equal(global.location.href, 'https://example.com/'); - await global.Scratch.redirect('https://example.com/2'); - t.equal(global.location.href, 'https://example.com/2'); - t.end(); -}); - -test('translate', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - t.equal(global.Scratch.translate({ - id: 'test1', - default: 'Message 1: {var}', - description: 'Description' - }, { - var: 'test' - }), 'Message 1: test'); - t.equal(global.Scratch.translate('test1 {var}', { - var: 'ok' - }), 'test1 ok'); - - global.Scratch.translate.setup({ - en: { - test1: 'EN Message 1: {var}' - }, - es: { - test1: 'ES Message 1: {var}' - } - }); - t.equal(global.Scratch.translate({ - id: 'test1', - default: 'Message 1: {var}', - description: 'Description' - }, { - var: 'test' - }), 'EN Message 1: test'); - t.equal(global.Scratch.translate('test1 {var}', { - var: 'ok' - }), 'test1 ok'); - - await vm.setLocale('es'); - // do not call setup() again; real extensions will not do that. - // need to make sure that the translatiosn are saved after calling setLocale. - t.equal(global.Scratch.translate({ - id: 'test1', - default: 'Message 1: {var}', - description: 'Description' - }, { - var: 'test' - }), 'ES Message 1: test'); - - t.end(); -}); - -test('canRecordAudio', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - vm.securityManager.canRecordAudio = () => false; - t.equal(await global.Scratch.canRecordAudio(), false); - - vm.securityManager.canRecordAudio = () => true; - t.equal(await global.Scratch.canRecordAudio(), true); - - t.end(); -}); - -test('canRecordVideo', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - vm.securityManager.canRecordVideo = () => false; - t.equal(await global.Scratch.canRecordVideo(), false); - - vm.securityManager.canRecordVideo = () => true; - t.equal(await global.Scratch.canRecordVideo(), true); - - t.end(); -}); - -test('canReadClipboard', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - vm.securityManager.canReadClipboard = () => false; - t.equal(await global.Scratch.canReadClipboard(), false); - - vm.securityManager.canReadClipboard = () => true; - t.equal(await global.Scratch.canReadClipboard(), true); - - t.end(); -}); - -test('canNotify', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - vm.securityManager.canNotify = () => false; - t.equal(await global.Scratch.canNotify(), false); - - vm.securityManager.canNotify = () => true; - t.equal(await global.Scratch.canNotify(), true); - - t.end(); -}); - -test('canGeolocate', async t => { - const vm = new VirtualMachine(); - UnsandboxedExtensionRunner.setupUnsandboxedExtensionAPI(vm); - - vm.securityManager.canGeolocate = () => false; - t.equal(await global.Scratch.canGeolocate(), false); - - vm.securityManager.canGeolocate = () => true; - t.equal(await global.Scratch.canGeolocate(), true); - - t.end(); -});