├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ └── ci-checks.yaml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── cli.js ├── make-test-package.js ├── package-lock.json ├── package.json ├── patch.js ├── patch.test.js └── test-app ├── index.js └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 4 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [*.json] 19 | indent_size = 2 20 | 21 | [{*.yml,*.yaml}] 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2020 4 | }, 5 | "extends": "eslint:recommended", 6 | "env": { 7 | "es6": true, 8 | "node": true, 9 | "jest": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: antelle 2 | -------------------------------------------------------------------------------- /.github/workflows/ci-checks.yaml: -------------------------------------------------------------------------------- 1 | name: CI Checks 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-latest, windows-latest, ubuntu-latest] 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Configure Xvfb 19 | # https://www.electronjs.org/docs/tutorial/testing-on-headless-ci 20 | run: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 21 | if: ${{ matrix.os == 'ubuntu-latest' }} 22 | - name: Install npm modules 23 | run: npm ci 24 | - name: Run CI checks 25 | run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | tmp/ 3 | node_modules/ 4 | .DS_Store 5 | ._* 6 | *.iml 7 | *.log 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "trailingComma": "none", 6 | "quoteProps": "preserve", 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Antelle 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Electron evil feature patcher 2 | 3 | ![CI Checks](https://github.com/antelle/electron-evil-feature-patcher/workflows/CI%20Checks/badge.svg) 4 | 5 | Patches Electron to remove certain features from it, such as debugging flags, that can be used for evil. 6 | 7 | Before: 8 | ```sh 9 | % test-app.app/Contents/MacOS/test-app --inspect 10 | Debugger listening on ws://127.0.0.1:9229/71e61f6e-c559-48a1-9b73-1530f5fd111a 11 | For help, see: https://nodejs.org/en/docs/inspector 12 | Test app started 13 | ``` 14 | 15 | After: 16 | ```sh 17 | % test-app.app/Contents/MacOS/test-app --inspect 18 | Test app started 19 | ``` 20 | 21 | More info about removed options can be found [below](#removed-capabilities). 22 | 23 | ## Motivation 24 | 25 | Electron has great debugging support! Unfortunately this can be used not only while developing an app, but also after you have already built and packaged it. This way your app can be started in an unexpected way, for example, an attacker may want to pass `--inspect-brk` and execute code as if it was done by your app. 26 | 27 | Is this a concern in Electron? Yes and no. If your app is not dealing with secrets or if it's not codesigned, it's not an issue at all. However, if you would like to limit the code run under the identity of your app, it can be an issue. 28 | 29 | This is being addressed in Electron in form of so-called "fuses", run-time toggles that can be switched on and off: https://www.electronjs.org/docs/tutorial/fuses. These features should be eventually "fuses" but I'm too lazy to contribute to Electron because the patches we need are located in interesting, hard-to-reach pieces of code, for example in node.js or Chromium. This is not fun to change! In this sense, this solution, or should I say this dirty hack, is a short-lived thing. 30 | 31 | ## Goals 32 | 33 | - disable all debugging features 34 | - test on supported operating systems 35 | - have it right now 36 | - minimize patching time 37 | - keep it simple 38 | 39 | ## Non-goals 40 | 41 | - do it all in a nice way 42 | - support other features 43 | - patch old Electron versions 44 | - protect from [physically local attacks](https://chromium.googlesource.com/chromium/src/+/master/docs/security/faq.md#Why-arent-physically_local-attacks-in-Chromes-threat-model) 45 | 46 | ## Removed capabilities 47 | 48 | - [`--inspect-brk`](https://www.electronjs.org/docs/api/command-line-switches#--inspect-brkhostport) 49 | - [`--inspect-brk-node`](https://github.com/nodejs/node/blob/master/src/node_options.cc#L263) 50 | - [`--inspect-port`](https://www.electronjs.org/docs/api/command-line-switches#--inspect-porthostport) 51 | - [`--inspect`](https://www.electronjs.org/docs/api/command-line-switches#--inspecthostport) 52 | - [`--inspect-publish-uid`](https://www.electronjs.org/docs/api/command-line-switches#--inspect-publish-uidstderrhttp) 53 | - [`--remote-debugging-pipe`](https://github.com/electron/electron/blob/4588a411610ee4095ab2a47e086f23fa4730e50e/shell/browser/electron_browser_main_parts.cc#L464) 54 | - [`--remote-debugging-port`](https://www.electronjs.org/docs/api/command-line-switches#--remote-debugging-portport) 55 | - [`--js-flags`](https://www.electronjs.org/docs/api/command-line-switches#--js-flagsflags) 56 | - [`SIGUSR1`](https://nodejs.org/fr/docs/guides/debugging-getting-started/#enable-inspector) 57 | - [`ELECTRON_RUN_AS_NODE`](https://www.electronjs.org/docs/api/environment-variables#electron_run_as_node) 58 | 59 | ## Usage 60 | 61 | Using the command line: 62 | ```sh 63 | npx electron-evil-feature-patcher your-app-path 64 | ``` 65 | 66 | Without `npx`: 67 | ```sh 68 | node electron-evil-feature-patcher/cli your-app-path 69 | ``` 70 | 71 | Using node.js: 72 | ```js 73 | const patch = require('electron-evil-feature-patcher'); 74 | patch({ path: 'your-app-path' }); 75 | ``` 76 | 77 | `your-app-path` is executable path, for macOS this is a packaged `.app`. 78 | 79 | Patching is done in-place, no backup is made. Second attempt to patch is a no-op. 80 | 81 | ## Version support 82 | 83 | Supported Electron versions are 12 and above. 84 | 85 | ## Internals 86 | 87 | How does the patching work? Now the implemented solution is pretty naive, all it does is replacing strings used as command-line options, variable names, etc... When testing the changes I made sure replaced options are not understood by the parser, for example, if `--inspect` is changed to `[space][space]inspect`, it's discarded, so that not possible to use the second variant in the patched version. 88 | 89 | This works good enough and doesn't require disassembly. However, this may change and maybe I'll switch to patching via assembly analysis in future. But for now the approach seems to solve our problem quite well. 90 | 91 | Detailed information about all replacements: 92 | 93 | - command-line option dashes removal: `--inspect` => `[space][space]inspect` 94 | Good enough for the node.js option parser, it just discards such options. 95 | - `--inspect` 96 | - `--inspect-brk` 97 | - `--inspect-brk-node` 98 | - `--inspect-port` 99 | - `--inspect-publish-uid` 100 | - `--debug` 101 | - `--debug-brk` 102 | - `--debug-port` 103 | - command-line option shadowing: `something` => `xxx` + `another` => `xxx` 104 | Used in cases when the Electron option parser is applied, this parser can't be fooled with the variant above, but it adds options to a hashmap, so here we pass the same string twice and the evil option is erased. 105 | - `--js-flags` 106 | - `--remote-debugging-pipe` 107 | - `--remote-debugging-port` 108 | - format message breakage: `something` => `some%sing` 109 | Causes segmentation fault when it's passed to `printf`, so even if we reach this place, the process crashes instead of starting debugging. It's the way we prevent initiating debugging with `SIGUSR1`. 110 | - `DevTools listening on ...` 111 | - `Debugger listening on ...` 112 | - Electron fuses: 113 | See more about them [here](https://www.electronjs.org/docs/tutorial/fuses), this is the only officially supported, sustainable way of patching Electron. 114 | - `ELECTRON_RUN_AS_NODE` 115 | 116 | ## Testing 117 | 118 | To run tests: 119 | ```sh 120 | npm test 121 | ``` 122 | 123 | They will build a test app, test non-patched and patched versions. 124 | 125 | ## Future 126 | 127 | In future, as it's mentioned before, it will be done using electron "fuses". One of them is already in use here for `ELECTRON_RUN_AS_NODE`, and I hope others will be added as well! Then this project will be as small as flipping a couple of flags. But that's future. 128 | 129 | ## Known issues 130 | 131 | You won't be able to use `fork` because it's built on `ELECTRON_RUN_AS_NODE`. Instead, I recommend the following: 132 | 133 | 1. start a new process from the main process, not renderer 134 | 2. come up with a suitable name of the command-line argument, for example, let it be `--my-worker` 135 | 3. handle this argument in your main.js (application entry point), so that it runs the desired logic instead of creating windows 136 | 4. don't forget to handle `disconnect` event that will happen when your app is terminated: 137 | ```js 138 | process.on('disconnect', () => process.exit(0)); 139 | ``` 140 | 5. spawn a helper process like this: 141 | ```js 142 | spawn(process.helperExecPath, [ 143 | '--my-worker', 144 | '--in-process-gpu', 145 | '--disable-gpu' 146 | ], { 147 | env: process.env, 148 | stdio: ['ignore', 'ignore', 'ignore', 'ipc'] 149 | }); 150 | ``` 151 | 152 | Pay attention to `process.helperExecPath` and not `process.execPath` used here. If you use `execPath`, it will start another instance of your app, which is not what you would expect from `fork`. 153 | 154 | After this you can communicate with the process as usual via IPC: `process.send(...)`, `process.on('message', ...)`, etc... 155 | 156 | There are extra flags here: `--in-process-gpu` and `--disable-gpu`. They're added to prevent another GPU helper from starting because it's unlikely you will need GPU there. You can remove them, however your app will spawn two processes instead of one. This may be seen by users as strange behavior. 157 | 158 | ## Questions 159 | 160 | Do you know another option to execute code in Electron? 161 | Is there a way to use one of disabled capabilities in a patched app? 162 | 163 | Please [let me know](https://github.com/antelle/electron-evil-feature-patcher/issues/new)! 164 | 165 | Are you using this project in your app? I'd be interested to hear from you, [drop me a line](mailto:antelle.net@gmail.com?subject=electron-evil-feature-patcher)! 166 | 167 | This project fixed a vulnerability in your product? Consider [donating](https://github.com/sponsors/antelle): although the fixes here are very simple, the research, testing, and bringing it to you took time! 168 | 169 | ## License 170 | 171 | MIT 172 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const patch = require('./patch'); 4 | const { version } = require('./package.json'); 5 | 6 | console.log(`Electron feature patcher v${version}`); 7 | const [packagePath] = process.argv.slice(2); 8 | if (!packagePath) { 9 | console.log('Usage: node patch path-to-your-electron-package'); 10 | process.exit(1); 11 | } 12 | 13 | const verbose = process.argv.includes('--verbose'); 14 | 15 | patch({ path: packagePath, verbose }); 16 | -------------------------------------------------------------------------------- /make-test-package.js: -------------------------------------------------------------------------------- 1 | const packager = require('electron-packager'); 2 | 3 | if (require.main === module) { 4 | makeTestPackage(); 5 | } 6 | 7 | async function makeTestPackage() { 8 | const [appPath] = await packager({ 9 | dir: 'test-app', 10 | out: 'tmp', 11 | overwrite: true, 12 | name: 'test-app', 13 | quiet: true 14 | }); 15 | return appPath; 16 | } 17 | 18 | module.exports = makeTestPackage; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-evil-feature-patcher", 3 | "version": "1.2.1", 4 | "description": "Patches Electron to remove certain features from it, such as debugging flags, that can be used for evil", 5 | "main": "patch.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/antelle/electron-evil-feature-patcher" 9 | }, 10 | "scripts": { 11 | "start": "node patch.js", 12 | "test": "jest" 13 | }, 14 | "keywords": [ 15 | "electron", 16 | "patcher", 17 | "feature fatcher" 18 | ], 19 | "author": "Antelle", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "electron": "^12.0.0", 23 | "electron-packager": "^15.2.0", 24 | "eslint": "^7.16.0", 25 | "jest": "^26.6.3", 26 | "prettier": "^2.2.1" 27 | }, 28 | "bin": { 29 | "electron-evil-feature-patcher": "cli.js" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /patch.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const PatchedSentinel = 'sLw2p0mwn1P3QsLwGbe'; 5 | const FuseConst = { 6 | Sentinel: 'dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX', 7 | ExpectedFuseVersion: 1, 8 | EnabledByte: 0x31, 9 | DisabledByte: 0x30, 10 | RemovedByte: 0x72, 11 | RunAsNode: 1 12 | }; 13 | 14 | const replacements = [ 15 | { 16 | name: 'Command-line option: --inspect', 17 | search: /\0--inspect\0/g, 18 | replace: '\0 inspect\0' 19 | }, 20 | { 21 | name: 'Command-line option: --inspect-brk', 22 | search: /\0--inspect-brk\0/g, 23 | replace: '\0 inspect-brk\0' 24 | }, 25 | { 26 | name: 'Command-line option: --inspect-port', 27 | search: /\0--inspect-port\0/g, 28 | replace: '\0 inspect-port\0' 29 | }, 30 | { 31 | name: 'Command-line option: --debug', 32 | search: /\0--debug\0/g, 33 | replace: '\0 debug\0' 34 | }, 35 | { 36 | name: 'Command-line option: --debug-brk', 37 | search: /\0--debug-brk\0/g, 38 | replace: '\0 debug-brk\0' 39 | }, 40 | { 41 | name: 'Command-line option: --debug-port', 42 | search: /\0--debug-port\0/g, 43 | replace: '\0 debug-port\0' 44 | }, 45 | { 46 | name: 'Command-line option: --inspect-brk-node', 47 | search: /\0--inspect-brk-node\0/g, 48 | replace: '\0 inspect-brk-node\0' 49 | }, 50 | { 51 | name: 'Command-line option: --inspect-publish-uid', 52 | search: /\0--inspect-publish-uid\0/g, 53 | replace: `\0 ${PatchedSentinel}\0` 54 | }, 55 | { 56 | name: 'Electron option: javascript-harmony', 57 | search: /\0javascript-harmony\0/g, 58 | replace: '\0xx\r\n \0\0\0\0\0\0\0\0\0\0\0\0\0\0' 59 | }, 60 | { 61 | name: 'Electron option: js-flags', 62 | search: /\0js-flags\0/g, 63 | replace: '\0xx\r\n \0\0\0\0' 64 | }, 65 | { 66 | name: 'Electron option: remote-debugging-pipe', 67 | search: /\0remote-debugging-pipe\0/g, 68 | replace: '\0xx\r\n \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0' 69 | }, 70 | { 71 | name: 'Electron option: remote-debugging-port', 72 | search: /\0remote-debugging-port\0/g, 73 | replace: '\0xx\r\n \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0' 74 | }, 75 | { 76 | name: 'Electron option: wait-for-debugger-children', 77 | search: /\0wait-for-debugger-children\0/g, 78 | replace: '\0xx\r\n \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0' 79 | }, 80 | { 81 | name: 'DevTools listening message', 82 | search: /\0\nDevTools listening on ws:\/\/%s%s\n\0/g, 83 | replace: '\0%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s\n\0' 84 | }, 85 | { 86 | name: 'Debugger listening message', 87 | search: /\0Debugger listening on %s\n\0/g, 88 | replace: '\0%s%s%s%s%s%s%s%s%s%s%s%s\n\0' 89 | } 90 | ]; 91 | 92 | function patch(options) { 93 | const { verbose } = options; 94 | const binary = findBinary(options); 95 | if (!fs.existsSync(binary)) { 96 | throw new Error(`Binary not found: ${binary}`); 97 | } 98 | if (verbose) { 99 | console.log(`Found binary: ${binary}`); 100 | } 101 | let data = fs.readFileSync(binary, 'latin1'); 102 | if (verbose) { 103 | console.log(`Read: ${data.length} bytes`); 104 | } 105 | if (data.includes(PatchedSentinel)) { 106 | if (verbose) { 107 | console.log(`Already patched`); 108 | } 109 | return; 110 | } 111 | const [, electronVersion] = data.match(/Electron\/(\d+\.\d+\.\d+)/); 112 | if (verbose) { 113 | console.log(`Found version: ${electronVersion}`); 114 | } 115 | if (electronVersion.split('.')[0] < 12) { 116 | throw new Error(`Minimal supported Electron version is 12, found ${electronVersion}`); 117 | } 118 | data = setFuseWireStatus(data, FuseConst.RunAsNode, false); 119 | if (verbose) { 120 | console.log(`Fuse wire status set`); 121 | } 122 | for (const replacement of replacements) { 123 | let replaced = false; 124 | data = data.replace(replacement.search, (match) => { 125 | if (replaced) { 126 | throw new Error(`Multiple matches found for ${replacement.name}`); 127 | } 128 | if (replacement.replace.length !== match.length) { 129 | throw new Error( 130 | `Length mismatch for ${replacement.name}: ` + 131 | `${replacement.replace.length} <> ${match.length}` 132 | ); 133 | } 134 | replaced = true; 135 | return replacement.replace; 136 | }); 137 | if (!replaced) { 138 | throw new Error(`Not found: ${replacement.name}`); 139 | } 140 | if (verbose) { 141 | console.log(`Replaced: ${replacement.name}`); 142 | } 143 | } 144 | if (verbose) { 145 | console.log(`Writing output file...`); 146 | } 147 | fs.writeFileSync(binary, Buffer.from(data, 'latin1')); 148 | if (verbose) { 149 | console.log(`Done`); 150 | } 151 | } 152 | 153 | function findBinary(options) { 154 | if (options.path.endsWith('.app')) { 155 | return path.join( 156 | options.path, 157 | 'Contents', 158 | 'Frameworks', 159 | 'Electron Framework.framework', 160 | 'Versions', 161 | 'A', 162 | 'Electron Framework' 163 | ); 164 | } 165 | return options.path; 166 | } 167 | 168 | function setFuseWireStatus(data, wireId, enabled) { 169 | let ix = data.indexOf(FuseConst.Sentinel); 170 | if (ix === -1) { 171 | throw new Error('Fuse sentinel not found'); 172 | } 173 | ix += FuseConst.Sentinel.length; 174 | const foundVersion = data.charCodeAt(ix); 175 | if (foundVersion !== FuseConst.ExpectedFuseVersion) { 176 | throw new Error( 177 | `Bad fuse version: ${foundVersion}, expected ${FuseConst.ExpectedFuseVersion}` 178 | ); 179 | } 180 | const fuseLength = data.charCodeAt(++ix); 181 | if (fuseLength < wireId) { 182 | throw new Error(`Fuse is too short: ${fuseLength} bytes, expected at least ${wireId}`); 183 | } 184 | let wireByte = data.charCodeAt(ix + wireId); 185 | if (wireByte === FuseConst.RemovedByte) { 186 | throw new Error(`Fuse wire ${wireId} is marked as removed`); 187 | } 188 | wireByte = String.fromCharCode(enabled ? FuseConst.EnabledByte : FuseConst.DisabledByte); 189 | data = data.substr(0, ix + wireId) + wireByte + data.substr(ix + wireId + 1); 190 | return data; 191 | } 192 | 193 | module.exports = patch; 194 | -------------------------------------------------------------------------------- /patch.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const http = require('http'); 3 | const { spawn } = require('child_process'); 4 | 5 | const makeTestPackage = require('./make-test-package'); 6 | 7 | const patch = require('./patch'); 8 | 9 | jest.setTimeout(20000); 10 | jest.retryTimes(0); 11 | 12 | const DefaultDebuggerPort = 9229; 13 | const AlternativeDebuggerPort = 9666; 14 | 15 | const Timeouts = { 16 | PollInterval: 100, 17 | BeforeSendingSigUsr1: 2000, 18 | DebuggerConnect: 3000, 19 | SelfExit: 6000, 20 | FileIsBusyRetry: 1000 21 | }; 22 | 23 | let env; 24 | let appPath; 25 | let ps; 26 | let stdoutData; 27 | let stderrData; 28 | 29 | beforeAll(async () => { 30 | try { 31 | appPath = await makeTestPackage(); 32 | } catch (e) { 33 | console.error('Error making test package', e); 34 | throw e; 35 | } 36 | }); 37 | 38 | beforeEach(() => { 39 | env = { 40 | ...process.env 41 | }; 42 | if (process.platform === 'linux') { 43 | env.DISPLAY = ':99.0'; // https://www.electronjs.org/docs/tutorial/testing-on-headless-ci 44 | } 45 | }); 46 | 47 | afterEach(() => { 48 | if (ps) { 49 | try { 50 | ps.kill(); 51 | } catch { 52 | // phew 53 | } 54 | ps = undefined; 55 | } 56 | }); 57 | 58 | describe('patch', () => { 59 | describe('original', () => { 60 | test('no args', async () => { 61 | runTestApp(); 62 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 63 | await assertExitsItself(); 64 | assertContainsOnlyAppOutputInStdOut(); 65 | assertStdErrIsEmpty(); 66 | }); 67 | 68 | test('--inspect', async () => { 69 | runTestApp('--inspect'); 70 | await assertCanConnectTcpDebugger(DefaultDebuggerPort); 71 | await assertExitsItself(); 72 | assertContainsOnlyAppOutputInStdOut(); 73 | assertContainsDebuggerMessageInStdErr(DefaultDebuggerPort); 74 | }); 75 | 76 | test('--inspect --inspect-port', async () => { 77 | runTestApp('--inspect', `--inspect-port=${AlternativeDebuggerPort}`); 78 | await assertCanConnectTcpDebugger(AlternativeDebuggerPort); 79 | await assertExitsItself(); 80 | assertContainsOnlyAppOutputInStdOut(); 81 | assertContainsDebuggerMessageInStdErr(AlternativeDebuggerPort); 82 | }); 83 | 84 | test('--inspect --inspect-publish-uid', async () => { 85 | runTestApp('--inspect', '--inspect-publish-uid=http'); 86 | await assertCanConnectTcpDebugger(DefaultDebuggerPort); 87 | await assertExitsItself(); 88 | assertContainsOnlyAppOutputInStdOut(); 89 | assertStdErrIsEmpty(); 90 | }); 91 | 92 | test('--inspect-brk', async () => { 93 | runTestApp('--inspect-brk'); 94 | await assertCanConnectTcpDebugger(DefaultDebuggerPort); 95 | assertStdOutIsEmpty(); 96 | }); 97 | 98 | test('--inspect-brk --inspect-port', async () => { 99 | runTestApp('--inspect-brk', `--inspect-port=${AlternativeDebuggerPort}`); 100 | await assertCanConnectTcpDebugger(AlternativeDebuggerPort); 101 | assertStdOutIsEmpty(); 102 | }); 103 | 104 | test('--inspect-brk --inspect-publish-uid', async () => { 105 | runTestApp('--inspect-brk', '--inspect-publish-uid=http'); 106 | await assertCanConnectTcpDebugger(DefaultDebuggerPort); 107 | assertStdOutIsEmpty(); 108 | }); 109 | 110 | test('--remote-debugging-port', async () => { 111 | runTestApp(`--remote-debugging-port=${AlternativeDebuggerPort}`); 112 | await assertCanConnectTcpDebugger(AlternativeDebuggerPort); 113 | await assertExitsItself(); 114 | assertContainsOnlyAppOutputInStdOut(); 115 | assertContainsRemoteDebuggerMessageInStdErr(AlternativeDebuggerPort); 116 | }); 117 | 118 | if (process.platform !== 'win32') { 119 | test('SIGUSR1', async () => { 120 | runTestApp(); 121 | await sleep(Timeouts.BeforeSendingSigUsr1); 122 | ps.kill('SIGUSR1'); 123 | await assertCanConnectTcpDebugger(DefaultDebuggerPort); 124 | await assertExitsItself(); 125 | assertContainsOnlyAppOutputInStdOut(); 126 | assertContainsDebuggerMessageInStdErr(DefaultDebuggerPort); 127 | }); 128 | } 129 | 130 | test('ELECTRON_RUN_AS_NODE', async () => { 131 | env.ELECTRON_RUN_AS_NODE = '1'; 132 | runTestApp('not-found.js'); 133 | await assertExitsWithStatusCode(1); 134 | assertStdOutIsEmpty(); 135 | expect(stdioToStr(stderrData)).toMatch(/Cannot find module .*not-found.js/); 136 | }); 137 | }); 138 | 139 | describe('patched', () => { 140 | beforeAll(async () => { 141 | await patchTestApp(); 142 | }); 143 | 144 | test('no args', async () => { 145 | runTestApp(); 146 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 147 | await assertExitsItself(); 148 | assertContainsOnlyAppOutputInStdOut(); 149 | assertStdErrIsEmpty(); 150 | }); 151 | 152 | test('--inspect', async () => { 153 | runTestApp('--inspect'); 154 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 155 | await assertExitsItself(); 156 | assertContainsOnlyAppOutputInStdOut(); 157 | assertStdErrIsEmpty(); 158 | }); 159 | 160 | test('--inspect --inspect-port', async () => { 161 | runTestApp('--inspect', `--inspect-port=${AlternativeDebuggerPort}`); 162 | await assertCannotConnectTcpDebugger(AlternativeDebuggerPort); 163 | await assertExitsItself(); 164 | assertContainsOnlyAppOutputInStdOut(); 165 | assertStdErrIsEmpty(); 166 | }); 167 | 168 | test('--inspect --inspect-publish-uid', async () => { 169 | runTestApp('--inspect', '--inspect-publish-uid=http'); 170 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 171 | await assertExitsItself(); 172 | assertContainsOnlyAppOutputInStdOut(); 173 | assertStdErrIsEmpty(); 174 | }); 175 | 176 | test('--inspect-brk', async () => { 177 | runTestApp('--inspect-brk'); 178 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 179 | await assertExitsItself(); 180 | assertContainsOnlyAppOutputInStdOut(); 181 | assertStdErrIsEmpty(); 182 | }); 183 | 184 | test('[space][space]inspect-brk', async () => { 185 | runTestApp(' inspect-brk'); 186 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 187 | await assertExitsItself(); 188 | assertContainsOnlyAppOutputInStdOut(); 189 | assertStdErrIsEmpty(); 190 | }); 191 | 192 | test('inspect-brk', async () => { 193 | runTestApp('inspect-brk'); 194 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 195 | await assertExitsItself(); 196 | assertContainsOnlyAppOutputInStdOut(); 197 | assertStdErrIsEmpty(); 198 | }); 199 | 200 | test('--inspect-brk --inspect-port', async () => { 201 | runTestApp('--inspect-brk', `--inspect-port=${AlternativeDebuggerPort}`); 202 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 203 | await assertExitsItself(); 204 | assertContainsOnlyAppOutputInStdOut(); 205 | assertStdErrIsEmpty(); 206 | }); 207 | 208 | test('--inspect-brk --inspect-publish-uid', async () => { 209 | runTestApp('--inspect-brk', '--inspect-publish-uid=http'); 210 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 211 | await assertExitsItself(); 212 | assertContainsOnlyAppOutputInStdOut(); 213 | assertStdErrIsEmpty(); 214 | }); 215 | 216 | test('--remote-debugging-port', async () => { 217 | runTestApp(`--remote-debugging-port=${AlternativeDebuggerPort}`); 218 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 219 | await assertExitsItself(); 220 | assertContainsOnlyAppOutputInStdOut(); 221 | assertStdErrIsEmpty(); 222 | }); 223 | 224 | test('xx\\r\\n[space]', async () => { 225 | runTestApp(`xx\r\n =${AlternativeDebuggerPort}`); 226 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 227 | await assertExitsItself(); 228 | assertContainsOnlyAppOutputInStdOut(); 229 | assertStdErrIsEmpty(); 230 | }); 231 | 232 | test('xx\\r\\n[space] with quotes', async () => { 233 | runTestApp(`'xx\r\n '=${AlternativeDebuggerPort}`); 234 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 235 | await assertExitsItself(); 236 | assertContainsOnlyAppOutputInStdOut(); 237 | assertStdErrIsEmpty(); 238 | }); 239 | 240 | test('xx\\r\\n', async () => { 241 | runTestApp(`xx\r\n=${AlternativeDebuggerPort}`); 242 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 243 | await assertExitsItself(); 244 | assertContainsOnlyAppOutputInStdOut(); 245 | assertStdErrIsEmpty(); 246 | }); 247 | 248 | test('xx\\r', async () => { 249 | runTestApp(`xx\r=${AlternativeDebuggerPort}`); 250 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 251 | await assertExitsItself(); 252 | assertContainsOnlyAppOutputInStdOut(); 253 | assertStdErrIsEmpty(); 254 | }); 255 | 256 | test('xx', async () => { 257 | runTestApp(`xx=${AlternativeDebuggerPort}`); 258 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 259 | await assertExitsItself(); 260 | assertContainsOnlyAppOutputInStdOut(); 261 | assertStdErrIsEmpty(); 262 | }); 263 | 264 | if (process.platform !== 'win32') { 265 | test('SIGUSR1', async () => { 266 | runTestApp(); 267 | await sleep(Timeouts.BeforeSendingSigUsr1); 268 | ps.kill('SIGUSR1'); 269 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 270 | assertCrashed(); 271 | assertStdErrIsEmpty(); 272 | }); 273 | } 274 | 275 | test('ELECTRON_RUN_AS_NODE', async () => { 276 | env.ELECTRON_RUN_AS_NODE = '1'; 277 | runTestApp('not-found.js'); 278 | await assertExitsItself(); 279 | assertContainsOnlyAppOutputInStdOut(); 280 | assertStdErrIsEmpty(); 281 | }); 282 | 283 | test('repeated patching', async () => { 284 | await patchTestApp(); 285 | runTestApp('--inspect'); 286 | await assertCannotConnectTcpDebugger(DefaultDebuggerPort); 287 | await assertExitsItself(); 288 | assertContainsOnlyAppOutputInStdOut(); 289 | assertStdErrIsEmpty(); 290 | }); 291 | }); 292 | }); 293 | 294 | async function patchTestApp() { 295 | let packagePath; 296 | switch (process.platform) { 297 | case 'darwin': 298 | packagePath = path.join(appPath, 'test-app.app'); 299 | break; 300 | case 'linux': 301 | packagePath = path.join(appPath, 'test-app'); 302 | break; 303 | case 'win32': 304 | packagePath = path.join(appPath, 'test-app.exe'); 305 | break; 306 | default: 307 | throw new Error(`Platform ${process.platform} is not supported`); 308 | } 309 | for (let i = 0; i < 10; i++) { 310 | try { 311 | return patch({ path: packagePath }); 312 | } catch (ex) { 313 | if (ex.toString().includes('text file is busy')) { 314 | await sleep(Timeouts.FileIsBusyRetry); 315 | continue; 316 | } 317 | throw ex; 318 | } 319 | } 320 | } 321 | 322 | async function assertExitsItself() { 323 | await waitForExit(ps); 324 | expect(ps.exitCode).toBe(0); 325 | } 326 | 327 | async function assertExitsWithStatusCode(statusCode) { 328 | await waitForExit(ps); 329 | expect(ps.exitCode).toBe(statusCode); 330 | } 331 | 332 | function assertCrashed() { 333 | expect(ps.signalCode).toMatch(/^SIG(SEGV|ABRT)/); 334 | expect(ps.connected).toBe(false); 335 | } 336 | 337 | function assertContainsOnlyAppOutputInStdOut() { 338 | expect(stdioToStr(stdoutData)).toBe('Test app started'); 339 | } 340 | 341 | function assertContainsDebuggerMessageInStdErr(port) { 342 | expect(stdioToStr(stderrData)).toMatch( 343 | new RegExp( 344 | `^Debugger listening on ws://127\\.0\\.0\\.1:${port}/[\\w-]{36}\\r?\\n` + 345 | 'For help, see: https://nodejs.org/en/docs/inspector$' 346 | ) 347 | ); 348 | } 349 | 350 | function assertContainsRemoteDebuggerMessageInStdErr(port) { 351 | expect(stdioToStr(stderrData)).toMatch( 352 | new RegExp( 353 | `^DevTools listening on ws://127\\.0\\.0\\.1:${port}/devtools/browser/[\\w-]{36}$` 354 | ) 355 | ); 356 | } 357 | 358 | function assertStdErrIsEmpty() { 359 | expect(stdioToStr(stderrData)).toBe(''); 360 | } 361 | 362 | function assertStdOutIsEmpty() { 363 | expect(stdioToStr(stdoutData)).toBe(''); 364 | } 365 | 366 | function stdioToStr(stdio) { 367 | return Buffer.concat(stdio).toString('utf8').trim().replace(/\0/g, ''); 368 | } 369 | 370 | async function assertCanConnectTcpDebugger(port) { 371 | expect(await waitCheckCanConnectTcpDebugger(port)).toBe(true); 372 | } 373 | 374 | async function assertCannotConnectTcpDebugger(port) { 375 | expect(await waitCheckCanConnectTcpDebugger(port)).toBe(false); 376 | } 377 | 378 | async function waitCheckCanConnectTcpDebugger(port) { 379 | const maxDate = Date.now() + Timeouts.DebuggerConnect; 380 | while (Date.now() < maxDate) { 381 | await sleep(Timeouts.PollInterval); 382 | if (await canConnectTcpDebugger(port)) { 383 | return true; 384 | } 385 | } 386 | return false; 387 | } 388 | 389 | async function canConnectTcpDebugger(port) { 390 | return new Promise((resolve) => { 391 | http.get(`http://127.0.0.1:${port}/`, (res) => { 392 | expect(typeof res.statusCode).toBe('number'); 393 | resolve(true); 394 | }).on('error', () => resolve(false)); 395 | }); 396 | } 397 | 398 | function runTestApp(...flags) { 399 | let binPath; 400 | switch (process.platform) { 401 | case 'darwin': 402 | binPath = path.join(appPath, 'test-app.app/Contents/MacOS/test-app'); 403 | break; 404 | case 'linux': 405 | binPath = path.join(appPath, 'test-app'); 406 | break; 407 | case 'win32': 408 | binPath = path.join(appPath, 'test-app.exe'); 409 | break; 410 | default: 411 | throw new Error(`Platform ${process.platform} is not supported`); 412 | } 413 | ps = spawn(binPath, [...flags], { env }); 414 | 415 | stdoutData = []; 416 | ps.stdout.on('data', (data) => { 417 | stdoutData.push(data); 418 | }); 419 | 420 | stderrData = []; 421 | ps.stderr.on('data', (data) => { 422 | stderrData.push(data); 423 | }); 424 | 425 | return ps; 426 | } 427 | 428 | async function waitForExit() { 429 | ps.stdin.write('exit\n'); 430 | const maxDate = Date.now() + Timeouts.SelfExit; 431 | while (Date.now() < maxDate) { 432 | await sleep(Timeouts.PollInterval); 433 | if (typeof ps.exitCode === 'number') { 434 | return; 435 | } 436 | if (ps.signalCode) { 437 | throw new Error( 438 | `App crashed with signal ${ps.signalCode}\n` + 439 | `STDOUT: ${stdioToStr(stdoutData)}\n` + 440 | `STDERR: ${stdioToStr(stderrData)}` 441 | ); 442 | } 443 | } 444 | throw new Error(`The app didn't exit after ${Timeouts.SelfExit}ms`); 445 | } 446 | 447 | function sleep(time) { 448 | return new Promise((resolve) => setTimeout(resolve, time)); 449 | } 450 | -------------------------------------------------------------------------------- /test-app/index.js: -------------------------------------------------------------------------------- 1 | console.log('Test app started'); 2 | setTimeout(() => process.exit(0), 5000); 3 | process.stdin.on('data', () => process.exit(0)); 4 | -------------------------------------------------------------------------------- /test-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "Antelle", 7 | "license": "MIT", 8 | "private": true 9 | } 10 | --------------------------------------------------------------------------------