diff --git a/src/engine/runtime.js b/src/engine/runtime.js index f8b2311a..42c8269a 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -453,6 +453,26 @@ class Runtime extends EventEmitter { * This mode is used by the TurboWarp Packager. */ this.isPackaged = false; + + /** + * Contains information about the external communication methods that the scripts inside the project + * can use to send data from inside the project to an external server. + * Do not update this directly. Use Runtime.setExternalCommunicationMethod() instead. + */ + this.externalCommunicationMethods = { + cloudVariables: false, + customExtensions: false + }; + this.on(Runtime.HAS_CLOUD_DATA_UPDATE, enabled => { + this.setExternalCommunicationMethod('cloudVariables', enabled); + }); + + /** + * If set to true, features such as reading colors from the user's webcam will be disabled + * when the project has access to any external communication method to protect user privacy. + * Do not update this directly. Use Runtime.setEnforcePrivacy() instead. + */ + this.enforcePrivacy = true; } /** @@ -1718,6 +1738,7 @@ class Runtime extends EventEmitter { this.renderer = renderer; this.renderer.setLayerGroupOrdering(StageLayering.LAYER_GROUPS); this.renderer.offscreenTouching = !this.runtimeOptions.fencing; + this.updatePrivacy(); } /** @@ -3146,6 +3167,36 @@ class Runtime extends EventEmitter { updateCurrentMSecs () { this.currentMSecs = Date.now(); } + + updatePrivacy () { + const enforceRestrictions = ( + this.enforcePrivacy && + Object.values(this.externalCommunicationMethods).some(i => i) + ); + if (this.renderer) { + this.renderer.setPrivateSkinAccess(!enforceRestrictions); + } + } + + /** + * @param {boolean} enabled True if restrictions should be enforced to protect user privacy. + */ + setEnforcePrivacy (enabled) { + this.enforcePrivacy = enabled; + this.updatePrivacy(); + } + + /** + * @param {string} method Name of the method in Runtime.externalCommunicationMethods + * @param {boolean} enabled True if the feature is enabled. + */ + setExternalCommunicationMethod (method, enabled) { + if (!Object.prototype.hasOwnProperty.call(this.externalCommunicationMethods, method)) { + throw new Error(`Unknown method: ${method}`); + } + this.externalCommunicationMethods[method] = enabled; + this.updatePrivacy(); + } } /** diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 846419eb..8a12009a 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -205,6 +205,8 @@ class ExtensionManager { throw new Error(`Invalid extension URL: ${extensionURL}`); } + this.runtime.setExternalCommunicationMethod('customExtensions', true); + this.loadingAsyncExtensions++; const sandboxMode = await this.securityManager.getSandboxMode(extensionURL); diff --git a/test/integration/tw_privacy.js b/test/integration/tw_privacy.js new file mode 100644 index 00000000..fda382f7 --- /dev/null +++ b/test/integration/tw_privacy.js @@ -0,0 +1,116 @@ +const {test} = require('tap'); +const Runtime = require('../../src/engine/runtime'); +const VM = require('../../src/virtual-machine'); + +const mockRenderer = () => ({ + setLayerGroupOrdering: () => { + // not relevant to this test + }, + + privateSkinAccess: true, + setPrivateSkinAccess (enabled) { + this.privateSkinAccess = enabled; + } +}); + +test('baseline: no external communication methods', t => { + const rt = new Runtime(); + rt.attachRenderer(mockRenderer()); + t.equal(rt.renderer.privateSkinAccess, true); + t.end(); +}); + +test('throws errors for unknown method', t => { + t.plan(1); + const rt = new Runtime(); + try { + rt.setExternalCommunicationMethod('something fake', true); + } catch (e) { + t.equal(e.message, 'Unknown method: something fake'); + } + t.end(); +}); + +test('communication method enabled after attaching renderer', t => { + const rt = new Runtime(); + rt.attachRenderer(mockRenderer()); + rt.setExternalCommunicationMethod('cloudVariables', true); + t.equal(rt.renderer.privateSkinAccess, false); + t.end(); +}); + +test('communication method enabled before attaching renderer', t => { + const rt = new Runtime(); + rt.setExternalCommunicationMethod('cloudVariables', true); + rt.attachRenderer(mockRenderer()); + t.equal(rt.renderer.privateSkinAccess, false); + t.end(); +}); + +test('disable enforcement', t => { + const rt = new Runtime(); + rt.attachRenderer(mockRenderer()); + rt.setEnforcePrivacy(false); + rt.setExternalCommunicationMethod('cloudVariables', true); + t.equal(rt.renderer.privateSkinAccess, true); + t.end(); +}); + +test('multiple features toggled', t => { + const rt = new Runtime(); + rt.attachRenderer(mockRenderer()); + rt.setExternalCommunicationMethod('cloudVariables', true); + t.equal(rt.renderer.privateSkinAccess, false); + rt.setExternalCommunicationMethod('customExtensions', true); + t.equal(rt.renderer.privateSkinAccess, false); + rt.setExternalCommunicationMethod('cloudVariables', false); + t.equal(rt.renderer.privateSkinAccess, false); + rt.setExternalCommunicationMethod('customExtensions', false); + t.equal(rt.renderer.privateSkinAccess, true); + t.end(); +}); + +test('cloud variables', t => { + const rt = new Runtime(); + rt.attachRenderer(mockRenderer()); + + rt.addCloudVariable(); + t.equal(rt.renderer.privateSkinAccess, false); + + rt.addCloudVariable(); + t.equal(rt.renderer.privateSkinAccess, false); + + rt.removeCloudVariable(); + t.equal(rt.renderer.privateSkinAccess, false); + + rt.removeCloudVariable(); + t.equal(rt.renderer.privateSkinAccess, true); + + t.end(); +}); + +test('custom extensions', async t => { + const vm = new VM(); + vm.attachRenderer(mockRenderer()); + + vm.extensionManager.securityManager.getSandboxMode = () => 'unsandboxed'; + global.document = { + createElement: () => { + const element = {}; + setTimeout(() => { + global.Scratch.extensions.register({ + getInfo: () => ({}) + }); + }); + return element; + }, + body: { + appendChild: () => {} + } + }; + + t.equal(vm.renderer.privateSkinAccess, true); + await vm.extensionManager.loadExtensionURL('data:application/javascript;,'); + t.equal(vm.renderer.privateSkinAccess, false); + t.end(); +});