Mirror: A maybe slightly safer-ish wrapper around eval Function constructors

Compare changes

Choose any two refs to compare.

Changed files
+175 -74
.github
workflows
src
+26
.github/workflows/mirror.yml
··· 1 + # Mirrors to https://tangled.sh/@kitten.sh (knot.kitten.sh) 2 + name: Mirror (Git Backup) 3 + on: 4 + push: 5 + branches: 6 + - main 7 + jobs: 8 + mirror: 9 + runs-on: ubuntu-latest 10 + steps: 11 + - name: Checkout repository 12 + uses: actions/checkout@v4 13 + with: 14 + fetch-depth: 0 15 + fetch-tags: true 16 + - name: Mirror 17 + env: 18 + MIRROR_SSH_KEY: ${{ secrets.MIRROR_SSH_KEY }} 19 + GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=yes' 20 + run: | 21 + mkdir -p ~/.ssh 22 + echo "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa 23 + chmod 600 ~/.ssh/id_rsa 24 + ssh-keyscan -H knot.kitten.sh >> ~/.ssh/known_hosts 25 + git remote add mirror "git@knot.kitten.sh:kitten.sh/${GITHUB_REPOSITORY#*/}" 26 + git push --mirror mirror
+7 -6
README.md
··· 25 25 26 26 It only does three simple things: 27 27 - Isolate the [global object](https://developer.mozilla.org/en-US/docs/Glossary/Global_object) and uses a separate object using a `with` statement 28 - - Wraps all passed through globals, like `Array`, in a recursive proxy that disallows access to prototype-polluting propeties, such as `constructor` 29 - - In the browser: Creates an `iframe` element and uses that frame's globals instead 28 + - Wraps all passed through globals, like `Array`, in a recursive masking object that disallows access to object prototype properties 29 + - In the browser: Creates an `iframe` element and uses that frame's globals instead to prvent prototype pollution. 30 30 31 31 If you haven't run away screaming yet, maybe that's what you're looking for. Just a bit more safety. 32 32 But really, I wrote this just for fun and I haven't written any tests yet and neither have I tested all edge cases. 33 33 The export being named `SafeFunction` is really just ambitious. 34 34 35 - However, if you found a way to break out of `SafeFunction` and did something to the outside JS environment, let me 36 - know and file an issue. I'm curious to see how far `evalish` would have to go to fully faux-isolate eval'ed code! 35 + [**However, if you found a way to break out of `SafeFunction` and did something to the outside JS environment, let me 36 + know and file an issue.**](https://github.com/kitten/evalish/issues/new) 37 + I'm curious to see how far `evalish` would have to go to fully faux-isolate eval'ed code! 37 38 38 39 ## Usage 39 40 40 41 First install `evalish` alongside `react`: 41 42 42 43 ```sh 43 - yarn add use-editable 44 + yarn add evalish 44 45 # or 45 - npm install --save use-editable 46 + npm install --save evalish 46 47 ``` 47 48 48 49 You'll then be able to import `SafeFunction` and pass it argument names and code,
+2 -1
package.json
··· 2 2 "name": "evalish", 3 3 "description": "A maybe slightly safer-ish wrapper around eval Function constructors", 4 4 "DISCLAIMER": "Please maybe try something else first.. Please.", 5 - "version": "0.1.1", 5 + "version": "0.1.8", 6 6 "main": "dist/evalish.js", 7 7 "module": "dist/evalish.mjs", 8 8 "types": "dist/types/index.d.ts", ··· 57 57 "@rollup/plugin-buble": "^0.21.3", 58 58 "@rollup/plugin-commonjs": "^21.0.2", 59 59 "@rollup/plugin-node-resolve": "^13.1.3", 60 + "@types/node": "^18.0.6", 60 61 "@types/react": "^17.0.42", 61 62 "husky-v4": "^4.3.8", 62 63 "lint-staged": "^12.3.7",
+134 -66
src/index.ts
··· 1 - // These are marked with `Symbol.unscopables` for the Proxy 2 - const unscopables = { 3 - __proto__: true, 4 - prototype: true, 5 - constructor: true, 6 - }; 7 - 8 1 // Keys that'll always not be included (for Node.js) 9 2 const ignore = { 10 3 sys: true, ··· 15 8 require: true, 16 9 Function: true, 17 10 eval: true, 11 + process: true, 18 12 module: true, 19 13 exports: true, 14 + makeSafeGlobal: true, 20 15 __filename: true, 21 16 __dirname: true, 22 17 console: true, 23 18 }; 24 19 25 20 const noop = function () {} as any; 21 + const _freeze = Object.freeze; 22 + const _seal = Object.seal; 23 + const _keys = Object.keys; 24 + const _getOwnPropertyNames = Object.getOwnPropertyNames; 25 + const _getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; 26 + const _defineProperty = Object.defineProperty; 27 + const _create = Object.create; 28 + const _slice = Array.prototype.slice; 26 29 27 30 type Object = Record<string | symbol, unknown>; 28 31 ··· 30 33 function safeKey(target: Object, key: string | symbol): string | undefined { 31 34 return key !== 'constructor' && 32 35 key !== '__proto__' && 33 - key !== 'constructor' && 36 + key !== 'prototype' && 34 37 typeof key !== 'symbol' && 35 38 key in target 36 39 ? key 37 40 : undefined; 38 41 } 39 42 40 - // Wrap any given target with a Proxy preventing access to unscopables 41 - function withProxy(target: any) { 43 + function freeze(target: Object): Object { 44 + try { _freeze(target); } catch (_error) {} 45 + try { _seal(target); } catch (_error) {} 46 + return target; 47 + } 48 + 49 + const masked = new Set(); 50 + 51 + // Wrap any given target with a masking object preventing access to prototype properties 52 + function mask(target: any, toplevel: boolean) { 42 53 if ( 43 54 target == null || 44 55 (typeof target !== 'function' && typeof target !== 'object') 45 56 ) { 46 57 // If the target isn't a function or object then skip 47 58 return target; 48 - } else if ( 49 - typeof Proxy === 'function' && 50 - typeof Symbol === 'function' && 51 - Symbol.unscopables 52 - ) { 53 - // Mark hidden keys as unscopable 54 - target[Symbol.unscopables] = unscopables; 55 - // Wrap the target in a Proxy that disallows access to some keys 56 - return new Proxy(target, { 57 - // Return a value, if it's allowed to be returned, and wrap that value in a proxy recursively 58 - get(target, _key) { 59 - const key = safeKey(target, _key); 60 - return key !== undefined ? withProxy(target[key]) : undefined; 61 - }, 62 - has(target, key) { 63 - return !!safeKey(target, key); 64 - }, 65 - set: noop, 66 - deleteProperty: noop, 67 - defineProperty: noop, 68 - getOwnPropertyDescriptor: noop, 69 - }); 59 + } 60 + 61 + if (!('constructor' in target)) { 62 + toplevel = false; 63 + } 64 + 65 + if (toplevel && masked.has(target)) { 66 + return target; 67 + } else if (toplevel) { 68 + masked.add(target); 70 69 } 71 70 72 71 // Create a stand-in object or function 73 - const standin = 74 - typeof target === 'function' 75 - ? function (this: any) { 76 - return target.apply(this, arguments); 77 - } 78 - : Object.create(null); 72 + let standin = target; 73 + if (!toplevel) { 74 + standin = typeof target === 'function' 75 + ? (function (this: any) { 76 + if (new.target === undefined) { 77 + return target.apply(this, arguments); 78 + } else { 79 + const args = _slice.call(arguments); 80 + args.unshift(null); 81 + return new (target.bind.apply(target, args)); 82 + } 83 + }) 84 + : _create(null); 85 + } 86 + 79 87 // Copy all known keys over to the stand-in and recursively apply `withProxy` 80 88 // Prevent unsafe keys from being accessed 81 - const keys = ['constructor', 'prototype', '__proto__'].concat( 82 - Object.getOwnPropertyNames(target) 83 - ); 89 + const keys = ["__proto__", "constructor"]; 90 + try { 91 + // Chromium already restricts access to certain globals in an 92 + // iframe, this try catch block is to avoid 93 + // "Failed to enumerate the properties of 'Storage': access is denied for this document" 94 + keys.push(..._getOwnPropertyNames(target)); 95 + } catch (_error) { 96 + keys.push(..._keys(target)); 97 + } 98 + 99 + const seen = new Set(); 84 100 for (let i = 0; i < keys.length; i++) { 85 101 const key = keys[i]; 86 - Object.defineProperty(standin, key, { 87 - enumerable: true, 88 - get: safeKey(target, key) 89 - ? () => { 90 - return typeof target[key] === 'function' || 91 - typeof target[key] === 'object' 92 - ? withProxy(target[key]) 93 - : target[key]; 94 - } 95 - : noop, 96 - }); 102 + if (seen.has(key)) { 103 + continue; 104 + } else if ( 105 + key !== 'prototype' && 106 + (typeof standin !== 'function' || (key !== 'arguments' && key !== 'caller')) 107 + ) { 108 + seen.add(key); 109 + const descriptor = _getOwnPropertyDescriptor(standin, key) || {}; 110 + if (descriptor.configurable) { 111 + _defineProperty(standin, key, { 112 + enumerable: descriptor.enumerable, 113 + configurable: descriptor.configurable, 114 + get: (() => { 115 + if (!safeKey(target, key)) { 116 + return noop; 117 + } if (toplevel) { 118 + try { 119 + const value = mask(target[key], false); 120 + return () => value; 121 + } catch (_error) { 122 + return noop; 123 + } 124 + } else { 125 + return () => mask(target[key], false); 126 + } 127 + })(), 128 + }); 129 + } 130 + } 97 131 } 98 132 99 - return standin; 133 + if (standin.prototype != null) { 134 + standin.prototype = _create(null); 135 + } 136 + 137 + return toplevel ? standin : freeze(standin); 100 138 } 101 139 102 140 let safeGlobal: Record<string | symbol, unknown> | void; 141 + let vmGlobals: Record<string | symbol, unknown> = {}; 103 142 104 143 function makeSafeGlobal() { 105 144 if (safeGlobal) { ··· 114 153 115 154 // Get all available global names on `globalThis` and remove keys that are 116 155 // explicitly ignored 117 - const trueGlobalKeys = Object.getOwnPropertyNames(trueGlobal).filter( 156 + const trueGlobalKeys = _getOwnPropertyNames(trueGlobal).filter( 118 157 key => !ignore[key] 119 158 ); 120 159 121 160 // When we're in the browser, we can go a step further and try to create a 122 161 // new JS context and globals in a separate iframe 123 - let vmGlobals = trueGlobal; 162 + vmGlobals = trueGlobal; 124 163 let iframe: HTMLIFrameElement | void; 125 164 if (typeof document !== 'undefined') { 126 165 try { ··· 133 172 document.head.appendChild(iframe); 134 173 // We copy over all known globals (as seen on the original `globalThis`) 135 174 // from the new global we receive from the iframe 136 - vmGlobals = Object.create(null); 175 + vmGlobals = _create(null); 137 176 for (let i = 0, l = trueGlobalKeys.length; i < l; i++) { 138 177 const key = trueGlobalKeys[i]; 139 178 vmGlobals[key] = iframe.contentWindow![key]; ··· 141 180 } catch (_error) { 142 181 // When we're unsuccessful we revert to the original `globalThis` 143 182 vmGlobals = trueGlobal; 183 + } finally { 144 184 if (iframe) iframe.remove(); 145 185 } 186 + } else if (typeof require === 'function') { 187 + vmGlobals = _create(null); 188 + const scriptGlobal = new (require('vm').Script)('exports = globalThis').runInNewContext({}).exports; 189 + for (let i = 0, l = trueGlobalKeys.length; i < l; i++) { 190 + const key = trueGlobalKeys[i]; 191 + vmGlobals[key] = scriptGlobal[key]; 192 + } 146 193 } 147 194 148 - safeGlobal = Object.create(null); 195 + safeGlobal = _create(null); 149 196 150 197 // The safe global is initialised by copying all values from either `globalThis` 151 198 // or the isolated global. They're wrapped using `withProxy` which further disallows 152 199 // certain key accesses 153 200 for (let i = 0, l = trueGlobalKeys.length; i < l; i++) { 154 201 const key = trueGlobalKeys[i]; 155 - safeGlobal[key] = withProxy(vmGlobals[key]); 202 + safeGlobal[key] = mask(vmGlobals[key], true); 156 203 } 157 204 158 205 // We then reset all globals that are present on `globalThis` directly 159 206 for (const key in trueGlobal) safeGlobal[key] = undefined; 160 207 // We also reset all ignored keys explicitly 161 208 for (const key in ignore) safeGlobal[key] = undefined; 162 - // Lastly, we also disallow certain property accesses on the safe global 163 - safeGlobal = withProxy(safeGlobal!); 209 + // It _might_ be safe to expose the Function constructor like this... who knows 210 + safeGlobal!.Function = SafeFunction; 164 211 165 - // We're now free to remove the iframe element, if we've used it 166 - if (iframe) { 167 - iframe.remove(); 212 + // Lastly, we also disallow certain property accesses on the safe global 213 + // Wrap any given target with a Proxy preventing access to unscopables 214 + if (typeof Proxy === 'function') { 215 + // Wrap the target in a Proxy that disallows access to some keys 216 + return (safeGlobal = new Proxy(safeGlobal!, { 217 + // Return a value, if it's allowed to be returned and mask this value 218 + get(target, _key) { 219 + const key = safeKey(target, _key); 220 + return !ignore[_key] && key !== undefined ? target[key] : undefined; 221 + }, 222 + has(_target, _key) { 223 + return true; 224 + }, 225 + set: noop, 226 + deleteProperty: noop, 227 + defineProperty: noop, 228 + getOwnPropertyDescriptor: noop, 229 + })); 230 + } else { 231 + // NOTE: Some property accesses may leak through here without the Proxy 232 + return freeze(safeGlobal!); 168 233 } 169 - 170 - return safeGlobal; 171 234 } 172 235 173 236 interface SafeFunction { ··· 179 242 const safeGlobal = makeSafeGlobal(); 180 243 const code = args.pop(); 181 244 245 + // Retrieve Function constructor from vm globals 246 + const Function = vmGlobals.Function as FunctionConstructor | void; 247 + const Object = vmGlobals.Object as ObjectConstructor; 248 + const createFunction = (Function || Object.constructor.constructor) as FunctionConstructor; 249 + 182 250 // We pass in our safe global and use it using `with` (ikr...) 183 251 // We then add a wrapper function for strict-mode and a few closing 184 252 // statements to prevent the code from escaping the `with` block; 185 - const fn = new Function( 253 + const fn = createFunction( 186 254 'globalThis', 187 255 ...args, 188 256 'with (globalThis) {\n"use strict";\nreturn (function () {\n' +
+1 -1
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - "types": [], 3 + "types": ["node"], 4 4 "baseUrl": "./", 5 5 "esModuleInterop": true, 6 6 "forceConsistentCasingInFileNames": true,
+5
yarn.lock
··· 136 136 resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.4.tgz#48aedbf35efb3af1248e4cd4d792c730290cd5d6" 137 137 integrity sha512-M0+G6V0Y4YV8cqzHssZpaNCqvYwlCiulmm0PwpNLF55r/+cT8Ol42CHRU1SEaYFH2rTwiiE1aYg/2g2rrtGdPA== 138 138 139 + "@types/node@^18.0.6": 140 + version "18.0.6" 141 + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.6.tgz#0ba49ac517ad69abe7a1508bc9b3a5483df9d5d7" 142 + integrity sha512-/xUq6H2aQm261exT6iZTMifUySEt4GR5KX8eYyY+C4MSNPqSh9oNIP7tz2GLKTlFaiBbgZNxffoR3CVRG+cljw== 143 + 139 144 "@types/parse-json@^4.0.0": 140 145 version "4.0.0" 141 146 resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"