Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use fetch() and AsyncFunction instead of <script> for unsandboxed extensions #157

Merged
merged 3 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/extension-support/extension-worker.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -95,4 +96,4 @@ global.Scratch.extensions = {
register: extensionWorker.register.bind(extensionWorker)
};

global.ScratchExtensions = require('./tw-scratchx-compatibility-layer');
global.ScratchExtensions = createScratchX(global.Scratch);
52 changes: 27 additions & 25 deletions src/extension-support/tw-scratchx-compatibility-layer.js
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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;
76 changes: 39 additions & 37 deletions src/extension-support/tw-unsandboxed-extension-runner.js
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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<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 @@ -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
};
};

Expand All @@ -135,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
};
20 changes: 5 additions & 15 deletions test/integration/tw_privacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading
Loading