diff --git a/lib/jasmine-core/jasmine-html.js b/lib/jasmine-core/jasmine-html.js index 8c098d84..9e504590 100644 --- a/lib/jasmine-core/jasmine-html.js +++ b/lib/jasmine-core/jasmine-html.js @@ -50,10 +50,16 @@ const getJasmineHtmlRequireObj = (function() { private$.FailuresView = htmlRequire.FailuresView(j$, private$); private$.PerformanceView = htmlRequire.PerformanceView(j$, private$); private$.TabBar = htmlRequire.TabBar(j$, private$); - j$.HtmlReporterV2Urls = htmlRequire.HtmlReporterV2Urls(j$, private$); - j$.HtmlReporterV2 = htmlRequire.HtmlReporterV2(j$, private$); - j$.QueryString = htmlRequire.QueryString(); private$.HtmlSpecFilterV2 = htmlRequire.HtmlSpecFilterV2(); + + for (const k of ['HtmlReporterV2Urls', 'HtmlReporterV2', 'QueryString']) { + Object.defineProperty(j$, k, { + enumerable: true, + configurable: false, + writable: false, + value: htmlRequire[k](j$, private$) + }); + } }; return getJasmineHtmlRequire; @@ -101,6 +107,7 @@ getJasmineHtmlRequireObj().QueryString = function() { */ constructor(options) { this.#getWindowLocation = options.getWindowLocation; + Object.freeze(this); } /** @@ -168,6 +175,7 @@ getJasmineHtmlRequireObj().QueryString = function() { return '?' + qStrPairs.join('&'); } + Object.freeze(QueryString.prototype); return QueryString; }; @@ -772,6 +780,8 @@ getJasmineHtmlRequireObj().HtmlReporterV2 = function(j$, private$) { ); this.#container.appendChild(this.#htmlReporterMain); this.#failures.show(); + + Object.freeze(this); } jasmineStarted(options) { @@ -949,6 +959,7 @@ getJasmineHtmlRequireObj().HtmlReporterV2 = function(j$, private$) { } } + Object.freeze(HtmlReporterV2.prototype); return HtmlReporterV2; }; @@ -972,6 +983,8 @@ getJasmineHtmlRequireObj().HtmlReporterV2Urls = function(j$, private$) { return window.location; } }); + + Object.freeze(this); } /** @@ -1022,6 +1035,7 @@ getJasmineHtmlRequireObj().HtmlReporterV2Urls = function(j$, private$) { } } + Object.freeze(HtmlReporterV2Urls.prototype); return HtmlReporterV2Urls; }; diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 95b3d2d7..b9612e63 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -141,6 +141,28 @@ const getJasmineRequireObj = (function() { private$.loadedAsBrowserEsm = loadedAsBrowserEsm; + // Prevent monkey patching of existing properties but allow adding new ones. + // jasmine-html.js needs to be able to add to the jasmine namespace. + // jasmine-ajax also installs itself this way. + const writeable = [ + 'DEFAULT_TIMEOUT_INTERVAL', + 'MAX_PRETTY_PRINT_ARRAY_LENGTH', + 'MAX_PRETTY_PRINT_CHARS', + 'MAX_PRETTY_PRINT_DEPTH' + ]; + const descriptors = Object.getOwnPropertyDescriptors(j$); + + for (const [k, d] of Object.entries(descriptors)) { + if (!writeable.includes(k)) { + Object.defineProperty(j$, k, { + value: d.value, + enumerable: d.enumerable, + configurable: false, + writable: false + }); + } + } + return { jasmine: j$, private: private$ }; }; @@ -241,6 +263,7 @@ getJasmineRequireObj().base = function(j$, private$, jasmineGlobal) { */ let DEFAULT_TIMEOUT_INTERVAL = 5000; Object.defineProperty(j$, 'DEFAULT_TIMEOUT_INTERVAL', { + enumerable: true, get: function() { return DEFAULT_TIMEOUT_INTERVAL; }, @@ -2077,6 +2100,8 @@ getJasmineRequireObj().Env = function(j$, private$) { this.cleanup_ = function() { uninstallGlobalErrors(); }; + + Object.freeze(this); } function indirectCallerFilename(depth) { @@ -2087,6 +2112,8 @@ getJasmineRequireObj().Env = function(j$, private$) { return frames[depth] && frames[depth].file; } + Object.freeze(Env); + Object.freeze(Env.prototype); return Env; }; @@ -3009,6 +3036,8 @@ callbacks to execute _before_ running the next one. setInterval[IsMockClockTimingFn] = true; clearInterval[IsMockClockTimingFn] = true; + Object.freeze(this); + return this; // Advances the Clock's time until the mode changes. @@ -3162,6 +3191,8 @@ callbacks to execute _before_ running the next one. }; Clock.IsMockClockTimingFn = IsMockClockTimingFn; + Object.freeze(Clock); + Object.freeze(Clock.prototype); return Clock; }; @@ -8038,9 +8069,12 @@ getJasmineRequireObj().ParallelReportDispatcher = function(j$, private$) { for (const eventName of private$.reporterEvents) { this[eventName] = dispatcher[eventName].bind(dispatcher); } + + Object.freeze(this); } } + Object.freeze(ParallelReportDispatcher.prototype); return ParallelReportDispatcher; }; @@ -11698,8 +11732,12 @@ getJasmineRequireObj().Timer = function() { this.elapsed = function() { return now() - startTime; }; + + Object.freeze(this); } + Object.freeze(Timer); + Object.freeze(Timer.prototype); return Timer; }; diff --git a/spec/core/ClockSpec.js b/spec/core/ClockSpec.js index a4d92f85..7a5ad19f 100644 --- a/spec/core/ClockSpec.js +++ b/spec/core/ClockSpec.js @@ -1247,4 +1247,8 @@ describe('Clock (acceptance)', function() { clock.tick(400); }); + + isNonMonkeyPatchableClass(privateUnderTest.Clock, function() { + return new privateUnderTest.Clock({}, function() {}, {}); + }); }); diff --git a/spec/core/EnvSpec.js b/spec/core/EnvSpec.js index 3da94a51..ec6c9a66 100644 --- a/spec/core/EnvSpec.js +++ b/spec/core/EnvSpec.js @@ -874,4 +874,8 @@ describe('Env', function() { }).toThrowError('Jasmine cannot be configured via Env in parallel mode'); }); }); + + isNonMonkeyPatchableClass(privateUnderTest.Env, function() { + return new privateUnderTest.Env(); + }); }); diff --git a/spec/core/ParallelReportDispatcherSpec.js b/spec/core/ParallelReportDispatcherSpec.js index b4d2e137..e7eaf352 100644 --- a/spec/core/ParallelReportDispatcherSpec.js +++ b/spec/core/ParallelReportDispatcherSpec.js @@ -160,6 +160,13 @@ describe('ParallelReportDispatcher', function() { ); }); + isNonMonkeyPatchableClass( + jasmineUnderTest.ParallelReportDispatcher, + function() { + return new jasmineUnderTest.ParallelReportDispatcher(); + } + ); + function mockGlobalErrors() { const globalErrors = jasmine.createSpyObj('globalErrors', [ 'install', diff --git a/spec/core/TimerSpec.js b/spec/core/TimerSpec.js index 7d23d10a..1944eec5 100644 --- a/spec/core/TimerSpec.js +++ b/spec/core/TimerSpec.js @@ -30,4 +30,8 @@ describe('Timer', function() { expect(timer.elapsed()).toEqual(jasmine.any(Number)); }); }); + + isNonMonkeyPatchableClass(jasmineUnderTest.Timer, function() { + return new jasmineUnderTest.Timer(); + }); }); diff --git a/spec/core/jasmineNamespaceSpec.js b/spec/core/jasmineNamespaceSpec.js index 93ae2c3b..bec7311b 100644 --- a/spec/core/jasmineNamespaceSpec.js +++ b/spec/core/jasmineNamespaceSpec.js @@ -13,13 +13,61 @@ describe('The jasmine namespace', function() { expect(setDifference(actualKeys, expectedKeys())).toEqual(new Set()); }); + describe('Preventing monkey patching', function() { + const mutable = mutableKeys(); + + for (const key of expectedKeys()) { + if (mutable.includes(key)) { + it(`allows overwriting of jasmine.${key}`, function() { + const existingVal = jasmineUnderTest[key]; + + try { + jasmineUnderTest[key] = 'new value'; + expect(jasmineUnderTest[key]).toEqual('new value'); + } finally { + jasmineUnderTest[key] = existingVal; + } + }); + } else { + it(`prevents overwriting of jasmine.${key}`, function() { + const existingVal = jasmineUnderTest[key]; + + try { + jasmineUnderTest[key] = 'monkey patch'; + expect(jasmineUnderTest[key]).toBe(existingVal); + } finally { + // This will be a no-op if the test passed, but will prevent state + // leakage if it failed. + jasmineUnderTest[key] = existingVal; + } + }); + } + } + + it('allows additions', function() { + try { + jasmineUnderTest.Ajax = 'it worked'; + expect(jasmineUnderTest.Ajax).toEqual('it worked'); + } finally { + delete jasmineUnderTest.Ajax; + } + }); + }); + + function mutableKeys() { + return [ + 'MAX_PRETTY_PRINT_ARRAY_LENGTH', + 'MAX_PRETTY_PRINT_CHARS', + 'MAX_PRETTY_PRINT_DEPTH', + 'DEFAULT_TIMEOUT_INTERVAL' + ]; + } + function expectedKeys() { // Does not include properties added by requireInterface(), since that isn't // called by defineJasmineUnderTest.js/nodeDefineJasmineUnderTest.js. const result = new Set([ - 'MAX_PRETTY_PRINT_ARRAY_LENGTH', - 'MAX_PRETTY_PRINT_CHARS', - 'MAX_PRETTY_PRINT_DEPTH', + ...mutableKeys(), 'debugLog', 'getEnv', 'isSpy', diff --git a/spec/helpers/monkeyPatchingSpecs.js b/spec/helpers/monkeyPatchingSpecs.js new file mode 100644 index 00000000..af06f8cf --- /dev/null +++ b/spec/helpers/monkeyPatchingSpecs.js @@ -0,0 +1,67 @@ +globalThis.isNonMonkeyPatchableClass = function(ctor, makeInstance) { + describe('Monkey patching prevention', function() { + it(`prevents overwriting ${ctor.name}.prototype`, function() { + const existing = ctor.prototype; + + try { + ctor.prototype = {}; + expect(ctor.prototype).toBe(existing); + } finally { + // This will be a no-op if the test passed, but will prevent state + // leakage if it failed. + ctor.prototype = existing; + } + }); + + it("prevents overwriting an instance's prototype", function() { + const instance = makeInstance(); + let thrown; + + // The message varies from browser to browser, so we can't rely on it + try { + instance.__proto__ = {}; + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(TypeError); + }); + + it('prevents overwriting prototype properties', function() { + let any = false; + + for (const k of Object.getOwnPropertyNames(ctor.prototype)) { + any = true; + const existingValue = ctor.prototype[k]; + + try { + ctor.prototype[k] = {}; + expect(ctor.prototype[k]) + .withContext(k) + .toBe(existingValue); + } finally { + // This will be a no-op if the test passed, but will prevent state + // leakage if it failed. + ctor.prototype[k] = existingValue; + } + } + + expect(any).toBe(true); + }); + + it('prevents overriding prototype properties', function() { + const instance = makeInstance(); + let any = false; + + for (const k of Object.getOwnPropertyNames(ctor.prototype)) { + any = true; + instance[k] = {}; + expect(instance[k]) + .withContext(k) + .toBe(ctor.prototype[k]); + } + + expect(any).toBe(true); + }); + }); +}; diff --git a/spec/html/HtmlReporterV2Spec.js b/spec/html/HtmlReporterV2Spec.js index dcc583a0..d4167f87 100644 --- a/spec/html/HtmlReporterV2Spec.js +++ b/spec/html/HtmlReporterV2Spec.js @@ -1396,4 +1396,6 @@ describe('HtmlReporterV2', function() { }); }); }); + + isNonMonkeyPatchableClass(jasmineUnderTest.HtmlReporterV2, setup); }); diff --git a/spec/html/HtmlReporterV2UrlsSpec.js b/spec/html/HtmlReporterV2UrlsSpec.js index e2b99900..64c78436 100644 --- a/spec/html/HtmlReporterV2UrlsSpec.js +++ b/spec/html/HtmlReporterV2UrlsSpec.js @@ -63,4 +63,8 @@ describe('HtmlReporterV2Urls', function() { return qs; } }); + + isNonMonkeyPatchableClass(jasmineUnderTest.HtmlReporterV2Urls, function() { + return new jasmineUnderTest.HtmlReporterV2Urls({}); + }); }); diff --git a/spec/html/QueryStringSpec.js b/spec/html/QueryStringSpec.js index bc8d1770..00ca656a 100644 --- a/spec/html/QueryStringSpec.js +++ b/spec/html/QueryStringSpec.js @@ -77,4 +77,12 @@ describe('QueryString', function() { expect(queryString.getParam('baz')).toBeFalsy(); }); }); + + isNonMonkeyPatchableClass(jasmineUnderTest.QueryString, function() { + return new jasmineUnderTest.QueryString({ + getWindowLocation: function() { + return { search: '' }; + } + }); + }); }); diff --git a/spec/support/jasmine-browser.js b/spec/support/jasmine-browser.js index e19d9f1a..0771e7fc 100644 --- a/spec/support/jasmine-browser.js +++ b/spec/support/jasmine-browser.js @@ -25,6 +25,7 @@ module.exports = { 'helpers/domHelpers.js', 'helpers/integrationMatchers.js', 'helpers/callerFilenameShim.js', + 'helpers/monkeyPatchingSpecs.js', 'helpers/defineJasmineUnderTest.js', 'helpers/resetEnv.js' ], diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index e9094d74..3be76276 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -10,6 +10,7 @@ "helpers/integrationMatchers.js", "helpers/callerFilenameShim.js", "helpers/overrideConsoleLogForCircleCi.js", + "helpers/monkeyPatchingSpecs.js", "helpers/nodeDefineJasmineUnderTest.js", "helpers/resetEnv.js" ], diff --git a/src/core/Clock.js b/src/core/Clock.js index 883e84df..907fbad6 100644 --- a/src/core/Clock.js +++ b/src/core/Clock.js @@ -192,6 +192,8 @@ callbacks to execute _before_ running the next one. setInterval[IsMockClockTimingFn] = true; clearInterval[IsMockClockTimingFn] = true; + Object.freeze(this); + return this; // Advances the Clock's time until the mode changes. @@ -345,5 +347,7 @@ callbacks to execute _before_ running the next one. }; Clock.IsMockClockTimingFn = IsMockClockTimingFn; + Object.freeze(Clock); + Object.freeze(Clock.prototype); return Clock; }; diff --git a/src/core/Env.js b/src/core/Env.js index 6e66a76d..d44d6e7c 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -835,6 +835,8 @@ getJasmineRequireObj().Env = function(j$, private$) { this.cleanup_ = function() { uninstallGlobalErrors(); }; + + Object.freeze(this); } function indirectCallerFilename(depth) { @@ -845,5 +847,7 @@ getJasmineRequireObj().Env = function(j$, private$) { return frames[depth] && frames[depth].file; } + Object.freeze(Env); + Object.freeze(Env.prototype); return Env; }; diff --git a/src/core/ParallelReportDispatcher.js b/src/core/ParallelReportDispatcher.js index 360478d6..72d29c8c 100644 --- a/src/core/ParallelReportDispatcher.js +++ b/src/core/ParallelReportDispatcher.js @@ -87,8 +87,11 @@ getJasmineRequireObj().ParallelReportDispatcher = function(j$, private$) { for (const eventName of private$.reporterEvents) { this[eventName] = dispatcher[eventName].bind(dispatcher); } + + Object.freeze(this); } } + Object.freeze(ParallelReportDispatcher.prototype); return ParallelReportDispatcher; }; diff --git a/src/core/Timer.js b/src/core/Timer.js index a65064ba..b1726393 100644 --- a/src/core/Timer.js +++ b/src/core/Timer.js @@ -39,7 +39,11 @@ getJasmineRequireObj().Timer = function() { this.elapsed = function() { return now() - startTime; }; + + Object.freeze(this); } + Object.freeze(Timer); + Object.freeze(Timer.prototype); return Timer; }; diff --git a/src/core/base.js b/src/core/base.js index 9f1fa540..523bf1c2 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -43,6 +43,7 @@ getJasmineRequireObj().base = function(j$, private$, jasmineGlobal) { */ let DEFAULT_TIMEOUT_INTERVAL = 5000; Object.defineProperty(j$, 'DEFAULT_TIMEOUT_INTERVAL', { + enumerable: true, get: function() { return DEFAULT_TIMEOUT_INTERVAL; }, diff --git a/src/core/requireCore.js b/src/core/requireCore.js index 1d153dc8..dd9b5cc4 100644 --- a/src/core/requireCore.js +++ b/src/core/requireCore.js @@ -116,6 +116,28 @@ const getJasmineRequireObj = (function() { private$.loadedAsBrowserEsm = loadedAsBrowserEsm; + // Prevent monkey patching of existing properties but allow adding new ones. + // jasmine-html.js needs to be able to add to the jasmine namespace. + // jasmine-ajax also installs itself this way. + const writeable = [ + 'DEFAULT_TIMEOUT_INTERVAL', + 'MAX_PRETTY_PRINT_ARRAY_LENGTH', + 'MAX_PRETTY_PRINT_CHARS', + 'MAX_PRETTY_PRINT_DEPTH' + ]; + const descriptors = Object.getOwnPropertyDescriptors(j$); + + for (const [k, d] of Object.entries(descriptors)) { + if (!writeable.includes(k)) { + Object.defineProperty(j$, k, { + value: d.value, + enumerable: d.enumerable, + configurable: false, + writable: false + }); + } + } + return { jasmine: j$, private: private$ }; }; diff --git a/src/html/HtmlReporterV2.js b/src/html/HtmlReporterV2.js index c0a0515d..5fa038e0 100644 --- a/src/html/HtmlReporterV2.js +++ b/src/html/HtmlReporterV2.js @@ -98,6 +98,8 @@ getJasmineHtmlRequireObj().HtmlReporterV2 = function(j$, private$) { ); this.#container.appendChild(this.#htmlReporterMain); this.#failures.show(); + + Object.freeze(this); } jasmineStarted(options) { @@ -275,5 +277,6 @@ getJasmineHtmlRequireObj().HtmlReporterV2 = function(j$, private$) { } } + Object.freeze(HtmlReporterV2.prototype); return HtmlReporterV2; }; diff --git a/src/html/HtmlReporterV2Urls.js b/src/html/HtmlReporterV2Urls.js index c6ef4825..41271170 100644 --- a/src/html/HtmlReporterV2Urls.js +++ b/src/html/HtmlReporterV2Urls.js @@ -18,6 +18,8 @@ getJasmineHtmlRequireObj().HtmlReporterV2Urls = function(j$, private$) { return window.location; } }); + + Object.freeze(this); } /** @@ -68,5 +70,6 @@ getJasmineHtmlRequireObj().HtmlReporterV2Urls = function(j$, private$) { } } + Object.freeze(HtmlReporterV2Urls.prototype); return HtmlReporterV2Urls; }; diff --git a/src/html/QueryString.js b/src/html/QueryString.js index 9946c19a..7db5ba37 100644 --- a/src/html/QueryString.js +++ b/src/html/QueryString.js @@ -14,6 +14,7 @@ getJasmineHtmlRequireObj().QueryString = function() { */ constructor(options) { this.#getWindowLocation = options.getWindowLocation; + Object.freeze(this); } /** @@ -81,5 +82,6 @@ getJasmineHtmlRequireObj().QueryString = function() { return '?' + qStrPairs.join('&'); } + Object.freeze(QueryString.prototype); return QueryString; }; diff --git a/src/html/requireHtml.js b/src/html/requireHtml.js index 13ee40a2..fe99507c 100644 --- a/src/html/requireHtml.js +++ b/src/html/requireHtml.js @@ -25,10 +25,16 @@ const getJasmineHtmlRequireObj = (function() { private$.FailuresView = htmlRequire.FailuresView(j$, private$); private$.PerformanceView = htmlRequire.PerformanceView(j$, private$); private$.TabBar = htmlRequire.TabBar(j$, private$); - j$.HtmlReporterV2Urls = htmlRequire.HtmlReporterV2Urls(j$, private$); - j$.HtmlReporterV2 = htmlRequire.HtmlReporterV2(j$, private$); - j$.QueryString = htmlRequire.QueryString(); private$.HtmlSpecFilterV2 = htmlRequire.HtmlSpecFilterV2(); + + for (const k of ['HtmlReporterV2Urls', 'HtmlReporterV2', 'QueryString']) { + Object.defineProperty(j$, k, { + enumerable: true, + configurable: false, + writable: false, + value: htmlRequire[k](j$, private$) + }); + } }; return getJasmineHtmlRequire;