Skip to content

Commit

Permalink
Run unsandboxed extensions with fetch() and new AsyncFunction instead…
Browse files Browse the repository at this point in the history
… of <script>

Benefits:
 - IIFE no longer needed
 - Extensions can await
 - Better error messages
Downsides:
 - CORS now required to load extensions
  • Loading branch information
GarboMuffin committed Aug 22, 2023
1 parent b23fb1e commit 55cda82
Show file tree
Hide file tree
Showing 3 changed files with 303 additions and 395 deletions.
75 changes: 38 additions & 37 deletions src/extension-support/tw-unsandboxed-extension-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,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<object[]>} 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
Expand Down Expand Up @@ -115,18 +114,12 @@ const setupUnsandboxedExtensionAPI = vm => new Promise(resolve => {

Scratch.translate = createTranslate(vm);

global.Scratch = Scratch;
global.ScratchExtensions = createScratchX(Scratch);
});
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
};
};

Expand All @@ -136,26 +129,34 @@ const teardownUnsandboxedExtensionAPI = () => {
* @param {Virtualmachine} vm
* @returns {Promise<object[]>} 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
};
265 changes: 265 additions & 0 deletions test/integration/tw_unsandboxed_extensions.js
Original file line number Diff line number Diff line change
@@ -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();
});
Loading

0 comments on commit 55cda82

Please sign in to comment.