From 2b265e55a0ccd85bfb84746d918339daa77678a4 Mon Sep 17 00:00:00 2001 From: GarboMuffin Date: Wed, 16 Nov 2022 20:51:48 -0600 Subject: [PATCH] Disable private skin access when cloud variables or custom extensions are used This is a breaking change. Replaces logic that was previously in our scratch-gui fork. Upstream does the opposite; they disable cloud variables when video sensing is used. We can't do the same because of custom extensions. Disabling all custom extensions when video sensing is used would be too limiting. The concern with custom extensions is not that they will steal private skins, but that a project could use their blocks to fetch() color information to a remote server similar to cloud variables. The end result is not dissimilar: Cloud variables and video sensing shouldn't be used together. --- src/engine/runtime.js | 51 +++++++++ src/extension-support/extension-manager.js | 2 + test/integration/tw_privacy.js | 116 +++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 test/integration/tw_privacy.js 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(); +});