CVE-2023-40168: Incorrect Authorization (CWE-863)
value: TurboWarp is a desktop application that compiles scratch projects to JavaScript. TurboWarp Desktop versions prior to version 1.8.0 allowed a malicious project or custom extension to read arbitrary files from disk and upload them to a remote server. The only required user interaction is opening the sb3 file or loading the extension. The web version of TurboWarp is not affected. This bug has been addressed in commit `55e07e99b59` after an initial fix which was reverted. Users are advised to upgrade to version 1.8.0 or later. Users unable to upgrade should avoid opening sb3 files or loading extensions from untrusted sources.
CWE description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
CVSS v3 base score: 6.5
Published: 2023-08-17T20:15Z
Repository: https://github.com/TurboWarp/desktop
Language: JavaScript
Files changed: src/l10n/en.json, src/main/bypass-cors.js, src/main/extensions.js, src/main/index.js, src/renderer/desktop-settings/desktop-settings.jsx
Commit date: 2023-06-11 15:54:52 -0500
Commit message: Rewrite CORS enforcement and offline extension cache

This fixes the offline extension cache.
Also added an option to force CORS to be enabled.

Diff: diff --git a/src/l10n/en.json b/src/l10n/en.json
index 0a8e3bc..ae076f4 100644
--- a/src/l10n/en.json
+++ b/src/l10n/en.json
@@ -13,10 +13,12 @@
   "settings.camera": "Selected Camera",
   "settings.restart-for-device-change": "You may have to restart the application for microphone or camera changes to apply.",
   "settings.no-devices-detected": "No devices detected",
   "settings.background-throttling": "Sleep hidden windows",
   "settings.background-throttling-disabled": "Allowing hidden windows to remain active will result in higher CPU usage and lower battery life.",
+  "settings.bypass-cors": "Allow extensions to bypass CORS",
+  "settings.bypass-cors-enabled": "Disabling CORS could allow malicious projects or extensions to access any website or device on your network.",
   "menu.new-window": "New Window",
   "menu.learn-more": "Learn more",
   "unload.stay": "Stay",
   "unload.leave": "Leave",
   "unload.message": "Are you sure you want to quit?",
diff --git a/src/main/bypass-cors.js b/src/main/bypass-cors.js
new file mode 100644
index 0000000..6324b4c
--- /dev/null
+++ b/src/main/bypass-cors.js
@@ -0,0 +1,18 @@
+import {ipcMain} from 'electron';
+import {get, set} from './store';
+
+const BYPASS_CORS_KEY = 'bypass_cors';
+
+const canBypassCORS = () => !!get(BYPASS_CORS_KEY);
+
+ipcMain.on('bypass-cors/get-is-enabled', (e) => {
+  e.returnValue = canBypassCORS();
+});
+
+ipcMain.handle('bypass-cors/set-is-enabled', (e, enabled) => {
+  set(BYPASS_CORS_KEY, enabled);
+});
+
+export {
+  canBypassCORS
+};
diff --git a/src/main/extensions.js b/src/main/extensions.js
index dc61557..8005092 100644
--- a/src/main/extensions.js
+++ b/src/main/extensions.js
@@ -1,19 +1,61 @@
 import {app, protocol} from 'electron';
 import pathUtil from 'path';
 import {staticDir} from './environment';
+import {canBypassCORS} from './bypass-cors';
 
 const extensionDirectory = pathUtil.join(staticDir, 'extensions.turbowarp.org', '/');
 
 app.on('session-created', (session) => {
+  const rootFileURL = new URL(`file://${__dirname}/`).href;
+
+  // We can only use one onBeforeRequest -- calling it again removes the previous listener.
   session.webRequest.onBeforeRequest({
-    urls: ['https://extensions.turbowarp.org/*']
+    urls: ['file://*', 'https://extensions.turbowarp.org/*']
   }, (details, callback) => {
-    const path = new URL(details.url).pathname;
-    callback({
-      redirectURL: `tw-extensions://${path}`
-    });
+    const url = new URL(details.url);
+
+    if (url.protocol === 'file:') {
+      // Prevent file:// URLs from fetching other file:// URLs from outside the app.
+      callback({
+        cancel: !url.href.startsWith(rootFileURL)
+      });
+    } else if (url.origin === 'https://extensions.turbowarp.org') {
+      // Rewrite extensions.turbowarp.org to the offline cache.
+      callback({
+        redirectURL: `tw-extensions://${url.pathname}`
+      });
+    } else {
+      // This should never happen.
+      callback({});
+    }
+  });
+
+  // By default in Electron, file:// URLs bypass CORS. We enforce it ourselves here.
+  session.webRequest.onHeadersReceived((details, callback) => {
+    if (details.resourceType === 'xhr') {
+      const sourceURL = new URL(details.frame.url);
+      const destinationURL = new URL(details.url);
+      if ((destinationURL.protocol === 'http:' || destinationURL.protocol === 'https:') && sourceURL.protocol === 'file:') {
+        if (canBypassCORS()) {
+          callback({
+            responseHeaders: {
+              ...(details.responseHeaders || {}),
+              'access-control-allow-origin': '*'
+            }
+          });
+        } else {
+          const corsHeaders = details.responseHeaders?.['access-control-allow-origin'] || [];
+          const corsHeader = corsHeaders.join(',');
+          callback({
+            cancel: corsHeader !== '*'
+          });
+        }
+        return;
+      }
+    }
+    callback({});
   });
 });
 
 app.whenReady().then(() => {
   protocol.registerFileProtocol('tw-extensions', (request, callback) => {
diff --git a/src/main/index.js b/src/main/index.js
index 1e17826..86487ed 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -650,41 +650,10 @@ app.on('session-created', (session) => {
           }
         ]
       });
     }
   });
-
-  const rootFileURL = new URL(`file://${__dirname}/`).href;
-
-  // Enforce additional restrictions when fetching file:// URIs
-  session.webRequest.onBeforeRequest((details, callback) => {
-    const destinationURL = new URL(details.url);
-    if (destinationURL.protocol === 'file:' && !destinationURL.href.startsWith(rootFileURL)) {
-      return callback({
-        cancel: true
-      });
-    }
-    callback({});
-  });
-
-  // Enforce CORS when a file:// URI fetches something from the broader internet
-  session.webRequest.onHeadersReceived((details, callback) => {
-    if (details.resourceType === 'xhr') {
-      const sourceURL = new URL(details.frame.url);
-      const destinationURL = new URL(details.url);
-      if ((destinationURL.protocol === 'http:' || destinationURL.protocol === 'https:') && sourceURL.protocol === 'file:') {
-        const corsHeaders = details.responseHeaders?.['access-control-allow-origin'] || [];
-        const corsHeader = corsHeaders.join(',');
-        if (corsHeader !== '*') {
-          return callback({
-            cancel: true
-          });
-        }
-      }
-    }
-    callback({});
-  });
 });
 
 app.on('web-contents-created', (event, webContents) => {
   webContents.on('context-menu', (event, params) => {
     const text = params.selectionText;
diff --git a/src/renderer/desktop-settings/desktop-settings.jsx b/src/renderer/desktop-settings/desktop-settings.jsx
index 22d54f8..5e96cdb 100644
--- a/src/renderer/desktop-settings/desktop-settings.jsx
+++ b/src/renderer/desktop-settings/desktop-settings.jsx
@@ -53,20 +53,21 @@ class DesktopSettings extends React.Component {
       videoDevices: [],
       selectedVideoDevice: ID_NONE,
       mediaDevicesNeedRestart: false,
 
       isHardwareAccelerationEnabled: ipcRenderer.sendSync('hardware-acceleration/get-is-enabled'),
-
-      isBackgroundThrottlingEnabled: ipcRenderer.sendSync('background-throttling/get-is-enabled')
+      isBackgroundThrottlingEnabled: ipcRenderer.sendSync('background-throttling/get-is-enabled'),
+      canBypassCORS: ipcRenderer.sendSync('bypass-cors/get-is-enabled')
     };
 
     this.handleChangeUpdateCheckerEnabled = this.handleChangeUpdateCheckerEnabled.bind(this);
     this.handleOpenPrivacyPolicy = this.handleOpenPrivacyPolicy.bind(this);
     this.handleSelectedAudioDeviceChanged = this.handleSelectedAudioDeviceChanged.bind(this);
     this.handleSelectedVideoDeviceChanged = this.handleSelectedVideoDeviceChanged.bind(this);
     this.handleChangeHardwareAccelerationEnabled = this.handleChangeHardwareAccelerationEnabled.bind(this);
     this.handleBackgroundThrottlingChanged = this.handleBackgroundThrottlingChanged.bind(this);
+    this.handleBypassCORSChanged = this.handleBypassCORSChanged.bind(this);
     this.handleOpenUserData = this.handleOpenUserData.bind(this);
   }
 
   componentDidMount () {
     this.updateMediaDevices();
@@ -132,10 +133,18 @@ class DesktopSettings extends React.Component {
     this.setState({
       isBackgroundThrottlingEnabled: enabled
     });
   }
 
+  handleBypassCORSChanged (e) {
+    const enabled = e.target.checked;
+    ipcRenderer.invoke('bypass-cors/set-is-enabled', enabled);
+    this.setState({
+      canBypassCORS: enabled
+    });
+  }
+
   handleOpenUserData () {
     ipcRenderer.send('open-user-data');
   }
 
   render () {
@@ -229,10 +238,26 @@ class DesktopSettings extends React.Component {
               {getTranslation('settings.background-throttling-disabled')}
             </div>
           )}
         </div>
 
+        <div className={styles.option}>
+          <label className={styles.label}>
+            <input
+              type="checkbox"
+              checked={this.state.canBypassCORS}
+              onChange={this.handleBypassCORSChanged}
+            />
+            {getTranslation('settings.bypass-cors')}
+          </label>
+          {this.state.canBypassCORS && (
+            <div className={styles.warning}>
+              {getTranslation('settings.bypass-cors-enabled')}
+            </div>
+          )}
+        </div>
+
         <div className={styles.option}>
           <button onClick={this.handleOpenUserData}>
             {getTranslation('settings.open-user-data')}
           </button>
         </div>

Vulnerable code:     urls: ['https://extensions.turbowarp.org/*']
    const path = new URL(details.url).pathname;
    callback({
      redirectURL: `tw-extensions://${path}`
    });

  const rootFileURL = new URL(`file://${__dirname}/`).href;

  // Enforce additional restrictions when fetching file:// URIs
  session.webRequest.onBeforeRequest((details, callback) => {
    const destinationURL = new URL(details.url);
    if (destinationURL.protocol === 'file:' && !destinationURL.href.startsWith(rootFileURL)) {
      return callback({
        cancel: true
      });
    }
    callback({});
  });

  // Enforce CORS when a file:// URI fetches something from the broader internet
  session.webRequest.onHeadersReceived((details, callback) => {
    if (details.resourceType === 'xhr') {
      const sourceURL = new URL(details.frame.url);
      const destinationURL = new URL(details.url);
      if ((destinationURL.protocol === 'http:' || destinationURL.protocol === 'https:') && sourceURL.protocol === 'file:') {
        const corsHeaders = details.responseHeaders?.['access-control-allow-origin'] || [];
        const corsHeader = corsHeaders.join(',');
        if (corsHeader !== '*') {
          return callback({
            cancel: true
          });
        }
      }
    }
    callback({});
  });

      isBackgroundThrottlingEnabled: ipcRenderer.sendSync('background-throttling/get-is-enabled')
Fixed code:   "settings.bypass-cors": "Allow extensions to bypass CORS",
  "settings.bypass-cors-enabled": "Disabling CORS could allow malicious projects or extensions to access any website or device on your network.",
import {ipcMain} from 'electron';
import {get, set} from './store';

const BYPASS_CORS_KEY = 'bypass_cors';

const canBypassCORS = () => !!get(BYPASS_CORS_KEY);

ipcMain.on('bypass-cors/get-is-enabled', (e) => {
  e.returnValue = canBypassCORS();
});

ipcMain.handle('bypass-cors/set-is-enabled', (e, enabled) => {
  set(BYPASS_CORS_KEY, enabled);
});

export {
  canBypassCORS
};
import {canBypassCORS} from './bypass-cors';
  const rootFileURL = new URL(`file://${__dirname}/`).href;

  // We can only use one onBeforeRequest -- calling it again removes the previous listener.
    urls: ['file://*', 'https://extensions.turbowarp.org/*']
    const url = new URL(details.url);

    if (url.protocol === 'file:') {
      // Prevent file:// URLs from fetching other file:// URLs from outside the app.
      callback({
        cancel: !url.href.startsWith(rootFileURL)
      });
    } else if (url.origin === 'https://extensions.turbowarp.org') {
      // Rewrite extensions.turbowarp.org to the offline cache.
      callback({
        redirectURL: `tw-extensions://${url.pathname}`
      });
    } else {
      // This should never happen.
      callback({});
    }
  });

  // By default in Electron, file:// URLs bypass CORS. We enforce it ourselves here.
  session.webRequest.onHeadersReceived((details, callback) => {
    if (details.resourceType === 'xhr') {
      const sourceURL = new URL(details.frame.url);
      const destinationURL = new URL(details.url);
      if ((destinationURL.protocol === 'http:' || destinationURL.protocol === 'https:') && sourceURL.protocol === 'file:') {
        if (canBypassCORS()) {
          callback({
            responseHeaders: {
              ...(details.responseHeaders || {}),
              'access-control-allow-origin': '*'
            }
          });
        } else {
          const corsHeaders = details.responseHeaders?.['access-control-allow-origin'] || [];
          const corsHeader = corsHeaders.join(',');
          callback({
            cancel: corsHeader !== '*'
          });
        }
        return;
      }
    }
    callback({});
      isBackgroundThrottlingEnabled: ipcRenderer.sendSync('background-throttling/get-is-enabled'),
      canBypassCORS: ipcRenderer.sendSync('bypass-cors/get-is-enabled')
    this.handleBypassCORSChanged = this.handleBypassCORSChanged.bind(this);
  handleBypassCORSChanged (e) {
    const enabled = e.target.checked;
    ipcRenderer.invoke('bypass-cors/set-is-enabled', enabled);
    this.setState({
      canBypassCORS: enabled
    });
  }

        <div className={styles.option}>
          <label className={styles.label}>
            <input
              type="checkbox"
              checked={this.state.canBypassCORS}
              onChange={this.handleBypassCORSChanged}
            />
            {getTranslation('settings.bypass-cors')}
          </label>
          {this.state.canBypassCORS && (
            <div className={styles.warning}>
              {getTranslation('settings.bypass-cors-enabled')}
            </div>
          )}
        </div>