OneUptime: node:vm sandbox escape in probe allows any project member to achieve RCE
Description
OneUptime is a solution for monitoring and managing online services. In versions 9.5.13 and below, custom JavaScript monitor feature uses Node.js's node:vm module (explicitly documented as not a security mechanism) to execute user-supplied code, allowing trivial sandbox escape via a well-known one-liner that grants full access to the underlying process. Because the probe runs with host networking and holds all cluster credentials (ONEUPTIME_SECRET, DATABASE_PASSWORD, REDIS_PASSWORD, CLICKHOUSE_PASSWORD) in its environment variables, and monitor creation is available to the lowest role (ProjectMember) with open registration enabled by default, any anonymous user can achieve full cluster compromise in about 30 seconds. This issue has been fixed in version 10.0.5.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
OneUptime's custom JavaScript monitor uses Node.js's vm module, allowing trivial sandbox escape that exposes cluster credentials and enables full cluster compromise by any user, including anonymous ones.
Vulnerability
Overview
OneUptime's custom JavaScript monitor feature executes user-supplied code using Node.js's node:vm module, which the Node.js documentation explicitly warns is not a security mechanism [3][4]. The vulnerable code in VMRunner.ts passes the monitor's customCode field directly to vm.runInContext() without any validation beyond a basic string type check [4]. This allows a trivial sandbox escape using a well-known one-liner that grants full access to the underlying process [3].
Exploitation
The probe process that runs these monitors operates with host networking and carries all cluster credentials—including ONEUPTIME_SECRET, DATABASE_PASSWORD, REDIS_PASSWORD, and CLICKHOUSE_PASSWORD—in its environment variables [3][4]. Monitor creation is permitted for the lowest role, ProjectMember, and open registration is enabled by default, meaning any anonymous user can register an account and create a custom JavaScript monitor [4]. The entire attack takes approximately 30 seconds [3].
Impact
Successful exploitation gives an attacker full control over the probe process and access to all cluster credentials, leading to complete compromise of the OneUptime deployment [3][4]. The advisory also notes that the IsolatedVM microservice is affected because it calls the same VMRunner.runCodeInSandbox() function [4].
Mitigation
The vulnerability has been fixed in OneUptime version 10.0.5 by replacing node:vm with the isolated-vm npm package, which runs code in a separate V8 isolate with limited resources [1][3]. Users should upgrade immediately. No workaround is available for versions 9.5.13 and below.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@oneuptime/commonnpm | < 10.0.0 | 10.0.0 |
Affected products
2- OneUptime/oneuptimev5Range: < 10.0.5
Patches
17f9ed4d43945feat: refactor SyntheticMonitor to use child processes for script execution
15 files changed · +1106 −429
Common/package.json+1 −0 modified@@ -101,6 +101,7 @@ "formik": "^2.4.6", "history": "^5.3.0", "ioredis": "^5.3.2", + "isolated-vm": "^6.0.2", "json2csv": "^5.0.7", "json5": "^2.2.3", "jsonwebtoken": "^9.0.0",
Common/package-lock.json+324 −3 modified@@ -62,6 +62,7 @@ "formik": "^2.4.6", "history": "^5.3.0", "ioredis": "^5.3.2", + "isolated-vm": "^6.0.2", "json2csv": "^5.0.7", "json5": "^2.2.3", "jsonwebtoken": "^9.0.0", @@ -6414,6 +6415,55 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -7102,6 +7152,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -8174,6 +8230,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -8214,6 +8285,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", @@ -8373,7 +8453,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -8628,6 +8707,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", @@ -9005,6 +9093,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", @@ -9466,6 +9563,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", @@ -9609,6 +9712,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10115,6 +10224,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -10650,6 +10765,19 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isolated-vm": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.0.2.tgz", + "integrity": "sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -13760,6 +13888,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -13818,6 +13958,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -13922,6 +14068,12 @@ "node": ">= 10.16.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13948,6 +14100,18 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -14175,7 +14339,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14762,6 +14925,32 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -14946,6 +15135,16 @@ "punycode": "^2.3.1" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15166,6 +15365,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -16555,6 +16778,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -17522,6 +17790,48 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", @@ -17821,6 +18131,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/twilio": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", @@ -18891,7 +19213,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": {
Common/Server/EnvironmentConfig.ts+8 −0 modified@@ -161,6 +161,14 @@ export const ClusterKey: ObjectID = new ObjectID( export const HasClusterKey: boolean = Boolean(process.env["ONEUPTIME_SECRET"]); +export const RegisterProbeKey: ObjectID = new ObjectID( + process.env["REGISTER_PROBE_KEY"] || "secret", +); + +export const HasRegisterProbeKey: boolean = Boolean( + process.env["REGISTER_PROBE_KEY"], +); + export const AppApiHostname: Hostname = Hostname.fromString( `${process.env["SERVER_APP_HOSTNAME"] || "localhost"}:${ process.env["APP_PORT"] || 80
Common/Server/Utils/VM/VMRunner.ts+214 −36 modified@@ -1,12 +1,8 @@ -import Dictionary from "../../../Types/Dictionary"; -import GenericObject from "../../../Types/GenericObject"; import ReturnResult from "../../../Types/IsolatedVM/ReturnResult"; -import { JSONObject, JSONValue } from "../../../Types/JSON"; -import axios from "axios"; -import http from "http"; -import https from "https"; +import { JSONObject } from "../../../Types/JSON"; +import axios, { AxiosResponse } from "axios"; import crypto from "crypto"; -import vm, { Context } from "node:vm"; +import ivm from "isolated-vm"; import CaptureSpan from "../Telemetry/CaptureSpan"; export default class VMRunner { @@ -16,49 +12,231 @@ export default class VMRunner { options: { timeout?: number; args?: JSONObject | undefined; - context?: Dictionary<GenericObject | string> | undefined; }; }): Promise<ReturnResult> { const { code, options } = data; + const timeout: number = options.timeout || 5000; const logMessages: string[] = []; - let sandbox: Context = { - console: { - log: (...args: JSONValue[]) => { + const isolate: ivm.Isolate = new ivm.Isolate({ memoryLimit: 128 }); + + try { + const context: ivm.Context = await isolate.createContext(); + const jail: ivm.Reference<Record<string, unknown>> = context.global; + + // Set up global object + await jail.set("global", jail.derefInto()); + + // console.log - fire-and-forget callback + await jail.set( + "_log", + new ivm.Callback((...args: string[]) => { logMessages.push(args.join(" ")); + }), + ); + + await context.eval(` + const console = { log: (...a) => _log(...a.map(v => { + try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } + catch(_) { return String(v); } + }))}; + `); + + // args - deep copy into isolate + if (options.args) { + await jail.set( + "_args", + new ivm.ExternalCopy(options.args).copyInto(), + ); + await context.eval("const args = _args;"); + } else { + await context.eval("const args = {};"); + } + + // axios (get, post, put, delete) - bridged via applySyncPromise + const axiosRef: ivm.Reference< + ( + method: string, + url: string, + dataOrConfig?: string, + ) => Promise<string> + > = new ivm.Reference( + async ( + method: string, + url: string, + dataOrConfig?: string, + ): Promise<string> => { + const parsed: JSONObject | undefined = dataOrConfig + ? (JSON.parse(dataOrConfig) as JSONObject) + : undefined; + + let response: AxiosResponse; + + switch (method) { + case "get": + response = await axios.get(url, parsed); + break; + case "post": + response = await axios.post(url, parsed); + break; + case "put": + response = await axios.put(url, parsed); + break; + case "delete": + response = await axios.delete(url, parsed); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + + return JSON.stringify({ + status: response.status, + headers: response.headers, + data: response.data, + }); }, - }, - http: http, - https: https, - axios: axios, - crypto: crypto, - setTimeout: setTimeout, - clearTimeout: clearTimeout, - setInterval: setInterval, - ...options.context, - }; + ); - if (options.args) { - sandbox = { - ...sandbox, - args: options.args, - }; - } + await jail.set("_axiosRef", axiosRef); + + await context.eval(` + const axios = { + get: async (url, config) => { + const r = await _axiosRef.applySyncPromise(undefined, ['get', url, config ? JSON.stringify(config) : undefined]); + return JSON.parse(r); + }, + post: async (url, data) => { + const r = await _axiosRef.applySyncPromise(undefined, ['post', url, data ? JSON.stringify(data) : undefined]); + return JSON.parse(r); + }, + put: async (url, data) => { + const r = await _axiosRef.applySyncPromise(undefined, ['put', url, data ? JSON.stringify(data) : undefined]); + return JSON.parse(r); + }, + delete: async (url, config) => { + const r = await _axiosRef.applySyncPromise(undefined, ['delete', url, config ? JSON.stringify(config) : undefined]); + return JSON.parse(r); + }, + }; + `); - vm.createContext(sandbox); // Contextify the object. + // crypto (createHash, createHmac, randomBytes) - bridged via applySync + const cryptoRef: ivm.Reference< + (op: string, ...args: string[]) => string + > = new ivm.Reference((op: string, ...args: string[]): string => { + switch (op) { + case "createHash": { + const [algorithm, inputData, encoding] = args; + return crypto + .createHash(algorithm!) + .update(inputData!) + .digest((encoding as crypto.BinaryToTextEncoding) || "hex"); + } + case "createHmac": { + const [algorithm, key, inputData, encoding] = args; + return crypto + .createHmac(algorithm!, key!) + .update(inputData!) + .digest((encoding as crypto.BinaryToTextEncoding) || "hex"); + } + case "randomBytes": { + const [size] = args; + return crypto.randomBytes(parseInt(size!)).toString("hex"); + } + default: + throw new Error(`Unsupported crypto operation: ${op}`); + } + }); - const script: string = `(async()=>{ + await jail.set("_cryptoRef", cryptoRef); + + await context.eval(` + const crypto = { + createHash: (algorithm) => ({ + _alg: algorithm, _data: '', + update(d) { this._data = d; return this; }, + digest(enc) { return _cryptoRef.applySync(undefined, ['createHash', this._alg, this._data, enc || 'hex']); } + }), + createHmac: (algorithm, key) => ({ + _alg: algorithm, _key: key, _data: '', + update(d) { this._data = d; return this; }, + digest(enc) { return _cryptoRef.applySync(undefined, ['createHmac', this._alg, this._key, this._data, enc || 'hex']); } + }), + randomBytes: (size) => ({ + toString(enc) { return _cryptoRef.applySync(undefined, ['randomBytes', String(size)]); } + }), + }; + `); + + // setTimeout / sleep - bridged via applySyncPromise + const sleepRef: ivm.Reference<(ms: number) => Promise<void>> = + new ivm.Reference((ms: number): Promise<void> => { + return new Promise((resolve: () => void) => { + global.setTimeout(resolve, Math.min(ms, timeout)); + }); + }); + + await jail.set("_sleepRef", sleepRef); + + await context.eval(` + function setTimeout(fn, ms) { + _sleepRef.applySyncPromise(undefined, [ms || 0]); + if (typeof fn === 'function') fn(); + } + async function sleep(ms) { + await _sleepRef.applySyncPromise(undefined, [ms || 0]); + } + `); + + // Wrap user code in async IIFE and evaluate + const wrappedCode: string = `(async () => { ${code} })()`; - const returnVal: any = await vm.runInContext(script, sandbox, { - timeout: options.timeout || 5000, - }); // run the script + // Run with overall timeout covering both CPU and I/O wait + const resultPromise: Promise<unknown> = context.eval(wrappedCode, { + promise: true, + timeout: timeout, + }); - return { - returnValue: returnVal, - logMessages, - }; + const overallTimeout: Promise<never> = new Promise( + ( + _resolve: (value: never) => void, + reject: (reason: Error) => void, + ) => { + global.setTimeout(() => { + reject(new Error("Script execution timed out")); + }, timeout + 5000); // 5s grace period beyond isolate timeout + }, + ); + + let returnValue: unknown; + + const result: unknown = await Promise.race([ + resultPromise, + overallTimeout, + ]); + + // If the result is an ivm.Reference or ExternalCopy, extract the value + if (result && typeof result === "object" && "copy" in result) { + try { + returnValue = (result as ivm.Reference<unknown>).copy(); + } catch { + returnValue = undefined; + } + } else { + returnValue = result; + } + + return { + returnValue, + logMessages, + }; + } finally { + if (!isolate.isDisposed) { + isolate.dispose(); + } + } } }
config.example.env+1 −0 modified@@ -22,6 +22,7 @@ CAPTCHA_SECRET_KEY= # Secrets - PLEASE CHANGE THESE. Please change these to something random. All of these can be different values. ONEUPTIME_SECRET=please-change-this-to-random-value +REGISTER_PROBE_KEY=please-change-this-to-random-value DATABASE_PASSWORD=please-change-this-to-random-value CLICKHOUSE_PASSWORD=please-change-this-to-random-value REDIS_PASSWORD=please-change-this-to-random-value
docker-compose.base.yml+3 −2 modified@@ -400,7 +400,7 @@ services: network_mode: host environment: ONEUPTIME_URL: ${GLOBAL_PROBE_1_ONEUPTIME_URL} - ONEUPTIME_SECRET: ${ONEUPTIME_SECRET} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} PROBE_NAME: ${GLOBAL_PROBE_1_NAME} PROBE_DESCRIPTION: ${GLOBAL_PROBE_1_DESCRIPTION} PROBE_MONITORING_WORKERS: ${GLOBAL_PROBE_1_MONITORING_WORKERS} @@ -424,7 +424,7 @@ services: network_mode: host environment: ONEUPTIME_URL: ${GLOBAL_PROBE_2_ONEUPTIME_URL} - ONEUPTIME_SECRET: ${ONEUPTIME_SECRET} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} PROBE_NAME: ${GLOBAL_PROBE_2_NAME} PROBE_DESCRIPTION: ${GLOBAL_PROBE_2_DESCRIPTION} PROBE_MONITORING_WORKERS: ${GLOBAL_PROBE_2_MONITORING_WORKERS} @@ -515,6 +515,7 @@ services: PORT: ${PROBE_INGEST_PORT} DISABLE_TELEMETRY: ${DISABLE_TELEMETRY_FOR_PROBE_INGEST} PROBE_INGEST_CONCURRENCY: ${PROBE_INGEST_CONCURRENCY} + REGISTER_PROBE_KEY: ${REGISTER_PROBE_KEY} logging: driver: "local" options:
HelmChart/Public/oneuptime/templates/_helpers.tpl+12 −0 modified@@ -198,6 +198,18 @@ Usage: {{- end }} {{- end }} +{{- define "oneuptime.env.registerProbeKey" }} +- name: REGISTER_PROBE_KEY + {{- if $.Values.registerProbeKey }} + value: {{ $.Values.registerProbeKey }} + {{- else }} + valueFrom: + secretKeyRef: + name: {{ printf "%s-%s" $.Release.Name "secrets" }} + key: register-probe-key + {{- end }} +{{- end }} + {{- define "oneuptime.env.runtime" }} - name: VAPID_PRIVATE_KEY
HelmChart/Public/oneuptime/templates/probe-ingest.yaml+1 −0 modified@@ -112,6 +112,7 @@ spec: value: {{ $.Values.probeIngest.disableTelemetryCollection | quote }} - name: PROBE_INGEST_CONCURRENCY value: {{ $.Values.probeIngest.concurrency | squote }} + {{- include "oneuptime.env.registerProbeKey" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} ports: - containerPort: {{ $.Values.probeIngest.ports.http }} protocol: TCP
HelmChart/Public/oneuptime/templates/probe.yaml+1 −1 modified@@ -131,7 +131,7 @@ spec: - name: NO_PROXY value: {{ $val.proxy.noProxy | squote }} {{- end }} - {{- include "oneuptime.env.oneuptimeSecret" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} + {{- include "oneuptime.env.registerProbeKey" (dict "Values" $.Values "Release" $.Release) | nindent 12 }} ports: - containerPort: {{ if and $val.ports $val.ports.http }}{{ $val.ports.http }}{{ else }}3874{{ end }} protocol: TCP
HelmChart/Public/oneuptime/templates/secrets.yaml+12 −0 modified@@ -17,6 +17,13 @@ stringData: {{- else }} oneuptime-secret: {{ index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "oneuptime-secret" | b64dec }} {{- end }} + {{- if .Values.registerProbeKey }} + register-probe-key: {{ .Values.registerProbeKey | quote }} + {{- else if (index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "register-probe-key") }} + register-probe-key: {{ index (lookup "v1" "Secret" $.Release.Namespace (printf "%s-secrets" $.Release.Name)).data "register-probe-key" | b64dec }} + {{- else }} + register-probe-key: {{ randAlphaNum 32 | quote }} + {{- end }} {{- if .Values.encryptionSecret }} encryption-secret: {{ .Values.encryptionSecret | quote }} {{- else }} @@ -48,6 +55,11 @@ stringData: {{- else }} oneuptime-secret: {{ randAlphaNum 32 | quote }} {{- end }} + {{- if .Values.registerProbeKey }} + register-probe-key: {{ .Values.registerProbeKey | quote }} + {{- else }} + register-probe-key: {{ randAlphaNum 32 | quote }} + {{- end }} {{- if .Values.encryptionSecret }} encryption-secret: {{ .Values.encryptionSecret | quote }} {{- else }}
HelmChart/Public/oneuptime/values.yaml+1 −0 modified@@ -32,6 +32,7 @@ image: # Important: You do need to set this to a long random values if you're using OneUptime in production. # Please set this to string. oneuptimeSecret: +registerProbeKey: encryptionSecret: # External Secrets
ProbeIngest/API/Register.ts+18 −2 modified@@ -1,7 +1,7 @@ import OneUptimeDate from "Common/Types/Date"; import BadDataException from "Common/Types/Exception/BadDataException"; import { JSONObject } from "Common/Types/JSON"; -import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; +import { RegisterProbeKey } from "Common/Server/EnvironmentConfig"; import ProbeService from "Common/Server/Services/ProbeService"; import Express, { ExpressRequest, @@ -19,7 +19,6 @@ const router: ExpressRouter = Express.getRouter(); // Register Global Probe. Custom Probe can be registered via dashboard. router.post( "/register", - ClusterKeyAuthorization.isAuthorizedServiceMiddleware, async ( req: ExpressRequest, res: ExpressResponse, @@ -28,6 +27,23 @@ router.post( try { const data: JSONObject = req.body; + const registerProbeKey: string | undefined = data[ + "registerProbeKey" + ] as string; + + if ( + !registerProbeKey || + registerProbeKey !== RegisterProbeKey.toString() + ) { + return Response.sendErrorResponse( + req, + res, + new BadDataException( + "Invalid or missing registerProbeKey. Please check REGISTER_PROBE_KEY environment variable.", + ), + ); + } + if (!data["probeKey"]) { return Response.sendErrorResponse( req,
Probe/Services/Register.ts+7 −5 modified@@ -15,10 +15,12 @@ import { JSONObject } from "Common/Types/JSON"; import ProbeStatusReport from "Common/Types/Probe/ProbeStatusReport"; import Sleep from "Common/Types/Sleep"; import API from "Common/Utils/API"; -import { HasClusterKey } from "Common/Server/EnvironmentConfig"; +import { + HasRegisterProbeKey, + RegisterProbeKey, +} from "Common/Server/EnvironmentConfig"; import LocalCache from "Common/Server/Infrastructure/LocalCache"; import logger from "Common/Server/Utils/Logger"; -import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization"; import ProxyConfig from "../Utils/ProxyConfig"; export default class Register { @@ -117,7 +119,7 @@ export default class Register { } private static async _registerProbe(): Promise<void> { - if (HasClusterKey) { + if (HasRegisterProbeKey) { const probeRegistrationUrl: URL = URL.fromString( PROBE_INGEST_URL.toString(), ).addRoute("/register"); @@ -131,7 +133,7 @@ export default class Register { probeKey: PROBE_KEY, probeName: PROBE_NAME, probeDescription: PROBE_DESCRIPTION, - clusterKey: ClusterKeyAuthorization.getClusterKey(), + registerProbeKey: RegisterProbeKey.toString(), }, options: { ...ProxyConfig.getRequestProxyAgents(probeRegistrationUrl), @@ -149,7 +151,7 @@ export default class Register { } else { // validate probe. if (!PROBE_ID) { - logger.error("PROBE_ID or ONEUPTIME_SECRET should be set"); + logger.error("PROBE_ID or REGISTER_PROBE_KEY should be set"); return process.exit(); }
Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts+164 −380 modified@@ -1,16 +1,12 @@ import { PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS } from "../../../Config"; import ProxyConfig from "../../ProxyConfig"; -import BadDataException from "Common/Types/Exception/BadDataException"; -import ReturnResult from "Common/Types/IsolatedVM/ReturnResult"; import BrowserType from "Common/Types/Monitor/SyntheticMonitors/BrowserType"; import ScreenSizeType from "Common/Types/Monitor/SyntheticMonitors/ScreenSizeType"; import SyntheticMonitorResponse from "Common/Types/Monitor/SyntheticMonitors/SyntheticMonitorResponse"; import ObjectID from "Common/Types/ObjectID"; import logger from "Common/Server/Utils/Logger"; -import VMRunner from "Common/Server/Utils/VM/VMRunner"; -import { Browser, BrowserContext, Page, chromium, firefox } from "playwright"; -import LocalFile from "Common/Server/Utils/LocalFile"; -import os from "os"; +import { ChildProcess, fork } from "child_process"; +import path from "path"; export interface SyntheticMonitorOptions { monitorId?: ObjectID | undefined; @@ -20,24 +16,24 @@ export interface SyntheticMonitorOptions { retryCountOnError?: number | undefined; } -interface BrowserLaunchOptions { - executablePath?: string; +interface WorkerConfig { + script: string; + browserType: BrowserType; + screenSizeType: ScreenSizeType; + timeout: number; proxy?: { server: string; - username?: string; - password?: string; - bypass?: string; - }; - args?: string[]; - headless?: boolean; - devtools?: boolean; - timeout?: number; + username?: string | undefined; + password?: string | undefined; + } | undefined; } -interface BrowserSession { - browser: Browser; - context: BrowserContext; - page: Page; +interface WorkerResult { + logMessages: string[]; + scriptError?: string | undefined; + result?: unknown | undefined; + screenshots: Record<string, string>; + executionTimeInMS: number; } export default class SyntheticMonitor { @@ -111,13 +107,70 @@ export default class SyntheticMonitor { return result; } + private static getSanitizedEnv(): Record<string, string> { + // Only pass safe environment variables to the worker process. + // Explicitly exclude all secrets (DATABASE_PASSWORD, REDIS_PASSWORD, + // CLICKHOUSE_PASSWORD, ONEUPTIME_SECRET, ENCRYPTION_SECRET, BILLING_PRIVATE_KEY, etc.) + const safeKeys: string[] = [ + "PATH", + "HOME", + "NODE_ENV", + "PLAYWRIGHT_BROWSERS_PATH", + "HTTP_PROXY_URL", + "http_proxy", + "HTTPS_PROXY_URL", + "https_proxy", + "NO_PROXY", + "no_proxy", + ]; + + const env: Record<string, string> = {}; + + for (const key of safeKeys) { + if (process.env[key]) { + env[key] = process.env[key]!; + } + } + + return env; + } + + private static getProxyConfig(): WorkerConfig["proxy"] | undefined { + if (!ProxyConfig.isProxyConfigured()) { + return undefined; + } + + const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl(); + const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl(); + const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl; + + if (!proxyUrl) { + return undefined; + } + + const proxyConfig: WorkerConfig["proxy"] = { + server: proxyUrl, + }; + + try { + const parsedUrl: globalThis.URL = new URL(proxyUrl); + if (parsedUrl.username && parsedUrl.password) { + proxyConfig.username = parsedUrl.username; + proxyConfig.password = parsedUrl.password; + } + } catch (error) { + logger.warn(`Failed to parse proxy URL for authentication: ${error}`); + } + + return proxyConfig; + } + private static async executeByBrowserAndScreenSize(options: { script: string; browserType: BrowserType; screenSizeType: ScreenSizeType; }): Promise<SyntheticMonitorResponse | null> { if (!options) { - // this should never happen options = { script: "", browserType: BrowserType.Chromium, @@ -135,385 +188,116 @@ export default class SyntheticMonitor { screenSizeType: options.screenSizeType, }; - let browserSession: BrowserSession | null = null; - - try { - let result: ReturnResult | null = null; - - const startTime: [number, number] = process.hrtime(); - - browserSession = await SyntheticMonitor.getPageByBrowserType({ - browserType: options.browserType, - screenSizeType: options.screenSizeType, - }); - - if (!browserSession) { - throw new BadDataException( - "Could not create Playwright browser session", - ); - } - - result = await VMRunner.runCodeInSandbox({ - code: options.script, - options: { - timeout: PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS, - args: {}, - context: { - browser: browserSession.browser, - page: browserSession.page, - screenSizeType: options.screenSizeType, - browserType: options.browserType, - }, - }, - }); + const timeout: number = PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS; - const endTime: [number, number] = process.hrtime(startTime); + const workerConfig: WorkerConfig = { + script: options.script, + browserType: options.browserType, + screenSizeType: options.screenSizeType, + timeout: timeout, + proxy: this.getProxyConfig(), + }; - const executionTimeInMS: number = Math.ceil( - (endTime[0] * 1000000000 + endTime[1]) / 1000000, + try { + const workerResult: WorkerResult = await this.forkWorker( + workerConfig, + timeout, ); - scriptResult.executionTimeInMS = executionTimeInMS; - - scriptResult.logMessages = result.logMessages; - - if (result.returnValue?.screenshots) { - if (!scriptResult.screenshots) { - scriptResult.screenshots = {}; - } - - for (const screenshotName in result.returnValue.screenshots) { - if (!result.returnValue.screenshots[screenshotName]) { - continue; - } - - // check if this is of type Buffer. If it is not, continue. - - if ( - !(result.returnValue.screenshots[screenshotName] instanceof Buffer) - ) { - continue; - } - - const screenshotBuffer: Buffer = result.returnValue.screenshots[ - screenshotName - ] as Buffer; - scriptResult.screenshots[screenshotName] = - screenshotBuffer.toString("base64"); // convert screenshots to base 64 - } - } - - scriptResult.result = result?.returnValue?.data; + scriptResult.logMessages = workerResult.logMessages; + scriptResult.scriptError = workerResult.scriptError; + scriptResult.result = workerResult.result as typeof scriptResult.result; + scriptResult.screenshots = workerResult.screenshots; + scriptResult.executionTimeInMS = workerResult.executionTimeInMS; } catch (err: unknown) { logger.error(err); scriptResult.scriptError = (err as Error)?.message || (err as Error).toString(); - } finally { - // Always dispose browser session to prevent zombie processes - await SyntheticMonitor.disposeBrowserSession(browserSession); } return scriptResult; } - private static getViewportHeightAndWidth(options: { - screenSizeType: ScreenSizeType; - }): { - height: number; - width: number; - } { - let viewPortHeight: number = 0; - let viewPortWidth: number = 0; - - switch (options.screenSizeType) { - case ScreenSizeType.Desktop: - viewPortHeight = 1080; - viewPortWidth = 1920; - break; - case ScreenSizeType.Mobile: - viewPortHeight = 640; - viewPortWidth = 360; - break; - case ScreenSizeType.Tablet: - viewPortHeight = 768; - viewPortWidth = 1024; - break; - default: - viewPortHeight = 1080; - viewPortWidth = 1920; - break; - } - - return { height: viewPortHeight, width: viewPortWidth }; - } - - private static getPlaywrightBrowsersPath(): string { - return ( - process.env["PLAYWRIGHT_BROWSERS_PATH"] || - `${os.homedir()}/.cache/ms-playwright` - ); - } - - public static async getChromeExecutablePath(): Promise<string> { - const browsersPath: string = this.getPlaywrightBrowsersPath(); - - const doesDirectoryExist: boolean = - await LocalFile.doesDirectoryExist(browsersPath); - if (!doesDirectoryExist) { - throw new BadDataException("Chrome executable path not found."); - } - - // get list of files in the directory - const directories: string[] = - await LocalFile.getListOfDirectories(browsersPath); - - if (directories.length === 0) { - throw new BadDataException("Chrome executable path not found."); - } - - const chromeInstallationName: string | undefined = directories.find( - (directory: string) => { - return directory.includes("chromium"); - }, - ); - - if (!chromeInstallationName) { - throw new BadDataException("Chrome executable path not found."); - } - - const chromeExecutableCandidates: Array<string> = [ - `${browsersPath}/${chromeInstallationName}/chrome-linux/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome-linux64/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome64/chrome`, - `${browsersPath}/${chromeInstallationName}/chrome/chrome`, - ]; - - for (const executablePath of chromeExecutableCandidates) { - if (await LocalFile.doesFileExist(executablePath)) { - return executablePath; - } - } - - throw new BadDataException("Chrome executable path not found."); - } - - public static async getFirefoxExecutablePath(): Promise<string> { - const browsersPath: string = this.getPlaywrightBrowsersPath(); - - const doesDirectoryExist: boolean = - await LocalFile.doesDirectoryExist(browsersPath); - if (!doesDirectoryExist) { - throw new BadDataException("Firefox executable path not found."); - } - - // get list of files in the directory - const directories: string[] = - await LocalFile.getListOfDirectories(browsersPath); - - if (directories.length === 0) { - throw new BadDataException("Firefox executable path not found."); - } - - const firefoxInstallationName: string | undefined = directories.find( - (directory: string) => { - return directory.includes("firefox"); - }, - ); - - if (!firefoxInstallationName) { - throw new BadDataException("Firefox executable path not found."); - } - - const firefoxExecutableCandidates: Array<string> = [ - `${browsersPath}/${firefoxInstallationName}/firefox/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox-linux64/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox64/firefox`, - `${browsersPath}/${firefoxInstallationName}/firefox-64/firefox`, - ]; - - for (const executablePath of firefoxExecutableCandidates) { - if (await LocalFile.doesFileExist(executablePath)) { - return executablePath; - } - } - - throw new BadDataException("Firefox executable path not found."); - } - - private static async getPageByBrowserType(data: { - browserType: BrowserType; - screenSizeType: ScreenSizeType; - }): Promise<BrowserSession> { - const viewport: { - height: number; - width: number; - } = SyntheticMonitor.getViewportHeightAndWidth({ - screenSizeType: data.screenSizeType, - }); - - // Prepare browser launch options with proxy support - const baseOptions: BrowserLaunchOptions = {}; - - // Configure proxy if available - if (ProxyConfig.isProxyConfigured()) { - const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl(); - const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl(); - - // Prefer HTTPS proxy, fall back to HTTP proxy - const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl; - - if (proxyUrl) { - baseOptions.proxy = { - server: proxyUrl, - }; - - // Extract username and password if present in proxy URL - try { - const parsedUrl: globalThis.URL = new URL(proxyUrl); - if (parsedUrl.username && parsedUrl.password) { - baseOptions.proxy.username = parsedUrl.username; - baseOptions.proxy.password = parsedUrl.password; - } - } catch (error) { - logger.warn(`Failed to parse proxy URL for authentication: ${error}`); - } - - logger.debug( - `Synthetic Monitor using proxy: ${proxyUrl} (HTTPS: ${Boolean(httpsProxyUrl)}, HTTP: ${Boolean(httpProxyUrl)})`, + private static async forkWorker( + config: WorkerConfig, + timeout: number, + ): Promise<WorkerResult> { + return new Promise( + ( + resolve: (value: WorkerResult) => void, + reject: (reason: Error) => void, + ) => { + // The worker file path. At runtime the compiled JS will be at the same + // relative location under the build output directory. + const workerPath: string = path.resolve( + __dirname, + "SyntheticMonitorWorker", ); - } - } - - if (data.browserType === BrowserType.Chromium) { - const browser: Browser = await chromium.launch({ - executablePath: await this.getChromeExecutablePath(), - ...baseOptions, - }); - - const context: BrowserContext = await browser.newContext({ - viewport: { - width: viewport.width, - height: viewport.height, - }, - }); - - const page: Page = await context.newPage(); - - return { - browser, - context, - page, - }; - } - - if (data.browserType === BrowserType.Firefox) { - const browser: Browser = await firefox.launch({ - executablePath: await this.getFirefoxExecutablePath(), - ...baseOptions, - }); - let context: BrowserContext | null = null; - - try { - context = await browser.newContext({ - viewport: { - width: viewport.width, - height: viewport.height, - }, + const child: ChildProcess = fork(workerPath, [], { + env: this.getSanitizedEnv(), + timeout: timeout + 30000, // fork-level timeout: script timeout + 30s for browser startup/shutdown + stdio: ["pipe", "pipe", "pipe", "ipc"], }); - const page: Page = await context.newPage(); - - return { - browser, - context, - page, - }; - } catch (error) { - await SyntheticMonitor.safeCloseBrowserContext(context); - await SyntheticMonitor.safeCloseBrowser(browser); - throw error; - } - } - - throw new BadDataException("Invalid Browser Type."); - } - - private static async disposeBrowserSession( - session: BrowserSession | null, - ): Promise<void> { - if (!session) { - return; - } - - await SyntheticMonitor.safeClosePage(session.page); - await SyntheticMonitor.safeCloseBrowserContexts({ - browser: session.browser, - }); - await SyntheticMonitor.safeCloseBrowser(session.browser); - } - - private static async safeClosePage(page?: Page | null): Promise<void> { - if (!page) { - return; - } - - try { - if (!page.isClosed()) { - await page.close(); - } - } catch (error) { - logger.warn( - `Failed to close Playwright page: ${(error as Error)?.message || error}`, - ); - } - } - - private static async safeCloseBrowserContext( - context?: BrowserContext | null, - ): Promise<void> { - if (!context) { - return; - } - - try { - await context.close(); - } catch (error) { - logger.warn( - `Failed to close Playwright browser context: ${(error as Error)?.message || error}`, - ); - } - } - - private static async safeCloseBrowser( - browser?: Browser | null, - ): Promise<void> { - if (!browser) { - return; - } + let resolved: boolean = false; + + // Explicit kill timer as final safety net + const killTimer: ReturnType<typeof setTimeout> = global.setTimeout( + () => { + if (!resolved) { + resolved = true; + child.kill("SIGKILL"); + reject( + new Error( + "Synthetic monitor worker killed after timeout", + ), + ); + } + }, + timeout + 60000, // kill after script timeout + 60s + ); - try { - if (browser.isConnected()) { - await browser.close(); - } - } catch (error) { - logger.warn( - `Failed to close Playwright browser: ${(error as Error)?.message || error}`, - ); - } - } + child.on("message", (result: WorkerResult) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + resolve(result); + } + }); - private static async safeCloseBrowserContexts(data: { - browser: Browser; - }): Promise<void> { - if (!data.browser || !data.browser.contexts) { - return; - } + child.on("error", (err: Error) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + reject(err); + } + }); - const contexts: Array<BrowserContext> = data.browser.contexts(); + child.on("exit", (exitCode: number | null) => { + if (!resolved) { + resolved = true; + global.clearTimeout(killTimer); + if (exitCode !== 0) { + reject( + new Error( + `Synthetic monitor worker exited with code ${exitCode}`, + ), + ); + } else { + // Worker exited cleanly but didn't send a message — shouldn't happen + reject( + new Error( + "Synthetic monitor worker exited without sending results", + ), + ); + } + } + }); - for (const context of contexts) { - await SyntheticMonitor.safeCloseBrowserContext(context); - } + // Send config to worker via IPC + child.send(config); + }, + ); } }
Probe/Utils/Monitors/MonitorTypes/SyntheticMonitorWorker.ts+339 −0 added@@ -0,0 +1,339 @@ +// This script is executed via child_process.fork() with a sanitized environment +// It launches Playwright, runs user code with node:vm (safe because env is stripped), +// and sends results back via IPC. + +import BrowserType from "Common/Types/Monitor/SyntheticMonitors/BrowserType"; +import ScreenSizeType from "Common/Types/Monitor/SyntheticMonitors/ScreenSizeType"; +import vm, { Context } from "node:vm"; +import { Browser, BrowserContext, Page, chromium, firefox } from "playwright"; +import LocalFile from "Common/Server/Utils/LocalFile"; +import os from "os"; + +interface WorkerConfig { + script: string; + browserType: BrowserType; + screenSizeType: ScreenSizeType; + timeout: number; + proxy?: { + server: string; + username?: string | undefined; + password?: string | undefined; + } | undefined; +} + +interface WorkerResult { + logMessages: string[]; + scriptError?: string | undefined; + result?: unknown | undefined; + screenshots: Record<string, string>; + executionTimeInMS: number; +} + +interface ProxyOptions { + server: string; + username?: string | undefined; + password?: string | undefined; +} + +function getViewportHeightAndWidth(screenSizeType: ScreenSizeType): { + height: number; + width: number; +} { + switch (screenSizeType) { + case ScreenSizeType.Desktop: + return { height: 1080, width: 1920 }; + case ScreenSizeType.Mobile: + return { height: 640, width: 360 }; + case ScreenSizeType.Tablet: + return { height: 768, width: 1024 }; + default: + return { height: 1080, width: 1920 }; + } +} + +function getPlaywrightBrowsersPath(): string { + return ( + process.env["PLAYWRIGHT_BROWSERS_PATH"] || + `${os.homedir()}/.cache/ms-playwright` + ); +} + +async function getChromeExecutablePath(): Promise<string> { + const browsersPath: string = getPlaywrightBrowsersPath(); + + const doesDirectoryExist: boolean = + await LocalFile.doesDirectoryExist(browsersPath); + if (!doesDirectoryExist) { + throw new Error("Chrome executable path not found."); + } + + const directories: string[] = + await LocalFile.getListOfDirectories(browsersPath); + + if (directories.length === 0) { + throw new Error("Chrome executable path not found."); + } + + const chromeInstallationName: string | undefined = directories.find( + (directory: string) => { + return directory.includes("chromium"); + }, + ); + + if (!chromeInstallationName) { + throw new Error("Chrome executable path not found."); + } + + const candidates: Array<string> = [ + `${browsersPath}/${chromeInstallationName}/chrome-linux/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome-linux64/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome64/chrome`, + `${browsersPath}/${chromeInstallationName}/chrome/chrome`, + ]; + + for (const executablePath of candidates) { + if (await LocalFile.doesFileExist(executablePath)) { + return executablePath; + } + } + + throw new Error("Chrome executable path not found."); +} + +async function getFirefoxExecutablePath(): Promise<string> { + const browsersPath: string = getPlaywrightBrowsersPath(); + + const doesDirectoryExist: boolean = + await LocalFile.doesDirectoryExist(browsersPath); + if (!doesDirectoryExist) { + throw new Error("Firefox executable path not found."); + } + + const directories: string[] = + await LocalFile.getListOfDirectories(browsersPath); + + if (directories.length === 0) { + throw new Error("Firefox executable path not found."); + } + + const firefoxInstallationName: string | undefined = directories.find( + (directory: string) => { + return directory.includes("firefox"); + }, + ); + + if (!firefoxInstallationName) { + throw new Error("Firefox executable path not found."); + } + + const candidates: Array<string> = [ + `${browsersPath}/${firefoxInstallationName}/firefox/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox-linux64/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox64/firefox`, + `${browsersPath}/${firefoxInstallationName}/firefox-64/firefox`, + ]; + + for (const executablePath of candidates) { + if (await LocalFile.doesFileExist(executablePath)) { + return executablePath; + } + } + + throw new Error("Firefox executable path not found."); +} + +async function launchBrowser( + config: WorkerConfig, +): Promise<{ browser: Browser; context: BrowserContext; page: Page }> { + const viewport: { height: number; width: number } = + getViewportHeightAndWidth(config.screenSizeType); + + let proxyOptions: ProxyOptions | undefined; + + if (config.proxy) { + proxyOptions = { + server: config.proxy.server, + }; + + if (config.proxy.username && config.proxy.password) { + proxyOptions.username = config.proxy.username; + proxyOptions.password = config.proxy.password; + } + } + + let browser: Browser; + + if (config.browserType === BrowserType.Chromium) { + const launchOptions: Record<string, unknown> = { + executablePath: await getChromeExecutablePath(), + }; + + if (proxyOptions) { + launchOptions["proxy"] = proxyOptions; + } + + browser = await chromium.launch(launchOptions); + } else if (config.browserType === BrowserType.Firefox) { + const launchOptions: Record<string, unknown> = { + executablePath: await getFirefoxExecutablePath(), + }; + + if (proxyOptions) { + launchOptions["proxy"] = proxyOptions; + } + + browser = await firefox.launch(launchOptions); + } else { + throw new Error("Invalid Browser Type."); + } + + const context: BrowserContext = await browser.newContext({ + viewport: { + width: viewport.width, + height: viewport.height, + }, + }); + + const page: Page = await context.newPage(); + + return { browser, context, page }; +} + +async function run(config: WorkerConfig): Promise<WorkerResult> { + const workerResult: WorkerResult = { + logMessages: [], + scriptError: undefined, + result: undefined, + screenshots: {}, + executionTimeInMS: 0, + }; + + let browser: Browser | null = null; + + try { + const startTime: [number, number] = process.hrtime(); + + const session: { browser: Browser; context: BrowserContext; page: Page } = + await launchBrowser(config); + + browser = session.browser; + + const logMessages: string[] = []; + + const sandbox: Context = { + console: { + log: (...args: unknown[]) => { + logMessages.push( + args + .map((v: unknown) => { + return typeof v === "object" ? JSON.stringify(v) : String(v); + }) + .join(" "), + ); + }, + }, + browser: session.browser, + page: session.page, + screenSizeType: config.screenSizeType, + browserType: config.browserType, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + }; + + vm.createContext(sandbox); + + const script: string = `(async()=>{ + ${config.script} + })()`; + + const returnVal: unknown = await vm.runInContext(script, sandbox, { + timeout: config.timeout, + }); + + const endTime: [number, number] = process.hrtime(startTime); + const executionTimeInMS: number = Math.ceil( + (endTime[0] * 1000000000 + endTime[1]) / 1000000, + ); + + workerResult.executionTimeInMS = executionTimeInMS; + workerResult.logMessages = logMessages; + + // Convert screenshots from Buffer to base64 + const returnObj: Record<string, unknown> = + returnVal && typeof returnVal === "object" + ? (returnVal as Record<string, unknown>) + : {}; + + if (returnObj["screenshots"]) { + const screenshots: Record<string, unknown> = returnObj[ + "screenshots" + ] as Record<string, unknown>; + + for (const screenshotName in screenshots) { + if (!screenshots[screenshotName]) { + continue; + } + + if (!(screenshots[screenshotName] instanceof Buffer)) { + continue; + } + + const screenshotBuffer: Buffer = screenshots[ + screenshotName + ] as Buffer; + workerResult.screenshots[screenshotName] = + screenshotBuffer.toString("base64"); + } + } + + workerResult.result = returnObj["data"]; + } catch (err: unknown) { + workerResult.scriptError = + (err as Error)?.message || String(err); + } finally { + // Close browser + if (browser) { + try { + const contexts: Array<BrowserContext> = browser.contexts(); + for (const ctx of contexts) { + try { + await ctx.close(); + } catch (_e: unknown) { + // ignore + } + } + if (browser.isConnected()) { + await browser.close(); + } + } catch (_e: unknown) { + // ignore cleanup errors + } + } + } + + return workerResult; +} + +// Entry point: receive config via IPC message +process.on("message", (config: WorkerConfig) => { + run(config) + .then((result: WorkerResult) => { + if (process.send) { + process.send(result); + } + process.exit(0); + }) + .catch((err: unknown) => { + if (process.send) { + process.send({ + logMessages: [], + scriptError: (err as Error)?.message || String(err), + result: undefined, + screenshots: {}, + executionTimeInMS: 0, + } as WorkerResult); + } + process.exit(1); + }); +});
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-v264-xqh4-9xmmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27574ghsaADVISORY
- github.com/OneUptime/oneuptime/commit/7f9ed4d43945574702a26b7c206e38cc344fe427ghsax_refsource_MISCWEB
- github.com/OneUptime/oneuptime/security/advisories/GHSA-v264-xqh4-9xmmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.