diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 23ebc16c8d922..41b3617cc52b9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -493,6 +493,7 @@ examples/guided_onboarding_example @elastic/appex-sharedux src/plugins/guided_onboarding @elastic/appex-sharedux packages/kbn-handlebars @elastic/kibana-security packages/kbn-hapi-mocks @elastic/kibana-core +test/plugin_functional/plugins/hardening @elastic/kibana-security packages/kbn-health-gateway-server @elastic/kibana-core examples/hello_world @elastic/kibana-core src/plugins/home @elastic/kibana-core @@ -1351,7 +1352,9 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib /packages/kbn-std/src/parse_next_url.ts @elastic/kibana-core @elastic/kibana-security /test/interactive_setup_api_integration/ @elastic/kibana-security /test/interactive_setup_functional/ @elastic/kibana-security +/test/plugin_functional/plugins/hardening @elastic/kibana-security /test/plugin_functional/test_suites/core_plugins/rendering.ts @elastic/kibana-security +/test/plugin_functional/test_suites/hardening @elastic/kibana-security /x-pack/test/accessibility/apps/group1/login_page.ts @elastic/kibana-security /x-pack/test/accessibility/apps/group1/roles.ts @elastic/kibana-security /x-pack/test/accessibility/apps/group1/spaces.ts @elastic/kibana-security diff --git a/package.json b/package.json index cf7781fd6a2f0..05f8c52540129 100644 --- a/package.json +++ b/package.json @@ -539,6 +539,7 @@ "@kbn/guided-onboarding-plugin": "link:src/plugins/guided_onboarding", "@kbn/handlebars": "link:packages/kbn-handlebars", "@kbn/hapi-mocks": "link:packages/kbn-hapi-mocks", + "@kbn/hardening-plugin": "link:test/plugin_functional/plugins/hardening", "@kbn/health-gateway-server": "link:packages/kbn-health-gateway-server", "@kbn/hello-world-plugin": "link:examples/hello_world", "@kbn/home-plugin": "link:src/plugins/home", diff --git a/src/setup_node_env/harden/index.js b/src/setup_node_env/harden/index.js index 7c3c1528ec2d9..170821a7da21e 100644 --- a/src/setup_node_env/harden/index.js +++ b/src/setup_node_env/harden/index.js @@ -9,6 +9,7 @@ var ritm = require('require-in-the-middle'); var lodashPatch = require('./lodash_template'); var patchChildProcess = require('./child_process'); +var hardenPrototypes = require('./prototype'); // the performance cost of using require-in-the-middle is atm directly related to the number of // registered hooks (as require is patched once for EACH hook) @@ -39,3 +40,9 @@ new ritm.Hook( return module; } ); + +// Use of the `KBN_UNSAFE_DISABLE_PROTOTYPE_HARDENING` environment variable is discouraged, and should only be set to facilitate testing +// specific scenarios. This should never be set in production. +if (!process.env.KBN_UNSAFE_DISABLE_PROTOTYPE_HARDENING) { + hardenPrototypes(); +} diff --git a/src/setup_node_env/harden/prototype.js b/src/setup_node_env/harden/prototype.js new file mode 100644 index 0000000000000..664697af7a34b --- /dev/null +++ b/src/setup_node_env/harden/prototype.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +function hardenPrototypes() { + // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal + // > The Object.seal() static method seals an object. + // > Sealing an object prevents extensions and makes existing properties non-configurable. + // > A sealed object has a fixed set of properties: new properties cannot be added, existing properties cannot be removed, + // > their enumerability and configurability cannot be changed, and its prototype cannot be re-assigned. + // > Values of existing properties can still be changed as long as they are writable. + // Object.freeze would take this one step further, and prevent the values of the properties from being changed as well. + // This is not currently feasible for Kibana, as this functionality is required for some of the libraries that we use, such as react-dom/server. + // While Object.seal() is not a silver bullet, it does provide a good balance between security and compatibility. + // The goal is to prevent a majority of prototype pollution vulnerabilities that can be exploited by an attacker. + + Object.seal(Object.prototype); + Object.seal(Number.prototype); + Object.seal(String.prototype); + Object.seal(Function.prototype); + + // corejs currently manipulates Array.prototype, so we cannot seal it. +} + +module.exports = hardenPrototypes; diff --git a/test/harden/child_process.js b/test/harden/child_process.js index 029b1a038fcbf..2f33a1b0172dc 100644 --- a/test/harden/child_process.js +++ b/test/harden/child_process.js @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// We must disable prototype hardening to test the pollution +process.env.KBN_UNSAFE_DISABLE_PROTOTYPE_HARDENING = 'true'; + require('../../src/setup_node_env'); const cp = require('child_process'); diff --git a/test/harden/lodash_template.js b/test/harden/lodash_template.js index 49cf7351972e8..c6dc240ffbb63 100644 --- a/test/harden/lodash_template.js +++ b/test/harden/lodash_template.js @@ -6,6 +6,9 @@ * Side Public License, v 1. */ +// We must disable prototype hardening to test the pollution +process.env.KBN_UNSAFE_DISABLE_PROTOTYPE_HARDENING = 'true'; + require('../../src/setup_node_env'); const _ = require('lodash'); // eslint-disable-next-line no-restricted-modules diff --git a/test/harden/prototype.js b/test/harden/prototype.js new file mode 100644 index 0000000000000..dcd5386a17af3 --- /dev/null +++ b/test/harden/prototype.js @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +require('../../src/setup_node_env'); + +const test = require('tape'); + +test('Object.prototype', (t) => { + t.test('Prevents new properties from being added to the prototype', (t) => { + Object.prototype.test = 'whoops'; // eslint-disable-line no-extend-native + t.equal({}.test, undefined); + t.end(); + }); + + t.test('Permits overriding Object.prototype.toString', (t) => { + let originalToString; + t.test('setup', (t) => { + originalToString = Object.prototype.toString; + t.end(); + }); + + t.test('test', (t) => { + // Assert native toString behavior + t.equal({}.toString(), '[object Object]'); + + const { + writable: originalWritable, + enumerable: originalEnumerable, + configurable: originalConfigurable, + } = Object.getOwnPropertyDescriptor(Object.prototype, 'toString'); + + // eslint-disable-next-line no-extend-native + Object.prototype.toString = function toString() { + return 'my new toString function'; + }; + t.equal({}.toString(), 'my new toString function'); + + const toStringDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, 'toString'); + + // Overwriting a property should not change its descriptor. + t.equal(toStringDescriptor.writable, originalWritable); + t.equal(toStringDescriptor.enumerable, originalEnumerable); + t.equal(toStringDescriptor.configurable, originalConfigurable); + + t.end(); + }); + + t.test('teardown', (t) => { + // eslint-disable-next-line no-extend-native + Object.prototype.toString = originalToString; + t.end(); + }); + }); +}); + +test('Number.prototype', (t) => { + t.test('Prevents new properties from being added to the prototype', (t) => { + Number.prototype.test = 'whoops'; // eslint-disable-line no-extend-native + t.equal((12).test, undefined); + t.end(); + }); + + t.test('Permits overriding Number.prototype.toString', (t) => { + let originalToString; + t.test('setup', (t) => { + originalToString = Number.prototype.toString; + t.end(); + }); + + t.test('test', (t) => { + // Assert native toString behavior + t.equal((1).toString(), '1'); + + const { + writable: originalWritable, + enumerable: originalEnumerable, + configurable: originalConfigurable, + } = Object.getOwnPropertyDescriptor(Number.prototype, 'toString'); + + // eslint-disable-next-line no-extend-native + Number.prototype.toString = function toString() { + return 'my new Number.toString function'; + }; + t.equal((12).toString(), 'my new Number.toString function'); + + const toStringDescriptor = Object.getOwnPropertyDescriptor(Number.prototype, 'toString'); + + // Overwriting a property should not change its descriptor. + t.equal(toStringDescriptor.writable, originalWritable); + t.equal(toStringDescriptor.enumerable, originalEnumerable); + t.equal(toStringDescriptor.configurable, originalConfigurable); + + t.end(); + }); + + t.test('teardown', (t) => { + // eslint-disable-next-line no-extend-native + Number.prototype.toString = originalToString; + t.end(); + }); + }); +}); + +test('String.prototype', (t) => { + t.test('Prevents new properties from being added to the prototype', (t) => { + String.prototype.test = 'whoops'; // eslint-disable-line no-extend-native + t.equal('hello'.test, undefined); + t.end(); + }); + + t.test('Permits overriding String.prototype.toString', (t) => { + let originalToString; + t.test('setup', (t) => { + originalToString = String.prototype.toString; + t.end(); + }); + + t.test('test', (t) => { + // Assert native toString behavior + t.equal((1).toString(), '1'); + + const { + writable: originalWritable, + enumerable: originalEnumerable, + configurable: originalConfigurable, + } = Object.getOwnPropertyDescriptor(String.prototype, 'toString'); + + // eslint-disable-next-line no-extend-native + String.prototype.toString = function toString() { + return 'my new String.toString function'; + }; + t.equal('test'.toString(), 'my new String.toString function'); + + const toStringDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'toString'); + + // Overwriting a property should not change its descriptor. + t.equal(toStringDescriptor.writable, originalWritable); + t.equal(toStringDescriptor.enumerable, originalEnumerable); + t.equal(toStringDescriptor.configurable, originalConfigurable); + + t.end(); + }); + + t.test('teardown', (t) => { + // eslint-disable-next-line no-extend-native + String.prototype.toString = originalToString; + t.end(); + }); + }); +}); + +test('Function.prototype', (t) => { + t.test('Prevents new properties from being added to the prototype', (t) => { + Function.prototype.test = 'whoops'; // eslint-disable-line no-extend-native + const fn = function testFn() {}; + t.equal(fn.test, undefined); + t.end(); + }); + + t.test('Permits overriding Function.prototype.toString', (t) => { + let originalToString; + t.test('setup', (t) => { + originalToString = Function.prototype.toString; + t.end(); + }); + + t.test('test', (t) => { + // Assert native toString behavior + const fn = function testFn() {}; + t.equal(fn.toString(), 'function testFn() {}'); + + const { + writable: originalWritable, + enumerable: originalEnumerable, + configurable: originalConfigurable, + } = Object.getOwnPropertyDescriptor(Function.prototype, 'toString'); + + // eslint-disable-next-line no-extend-native + Function.prototype.toString = function toString() { + return 'my new Function.toString function'; + }; + t.equal(fn.toString(), 'my new Function.toString function'); + + const toStringDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, 'toString'); + + // Overwriting a property should not change its descriptor. + t.equal(toStringDescriptor.writable, originalWritable); + t.equal(toStringDescriptor.enumerable, originalEnumerable); + t.equal(toStringDescriptor.configurable, originalConfigurable); + + t.end(); + }); + + t.test('teardown', (t) => { + // eslint-disable-next-line no-extend-native + Function.prototype.toString = originalToString; + t.end(); + }); + }); +}); diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index 7b967b8591bf4..3f59cc4ff3ac5 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -19,6 +19,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./test_suites/telemetry'), require.resolve('./test_suites/core'), require.resolve('./test_suites/custom_visualizations'), + require.resolve('./test_suites/hardening'), require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/core_plugins'), require.resolve('./test_suites/management'), diff --git a/test/plugin_functional/plugins/hardening/kibana.jsonc b/test/plugin_functional/plugins/hardening/kibana.jsonc new file mode 100644 index 0000000000000..c5680af0dd35e --- /dev/null +++ b/test/plugin_functional/plugins/hardening/kibana.jsonc @@ -0,0 +1,13 @@ +{ + "type": "plugin", + "id": "@kbn/hardening-plugin", + "owner": "@elastic/kibana-security", + "plugin": { + "id": "hardeningPlugin", + "server": true, + "browser": false, + "configPath": [ + "hardening_plugin" + ] + } +} diff --git a/test/plugin_functional/plugins/hardening/package.json b/test/plugin_functional/plugins/hardening/package.json new file mode 100644 index 0000000000000..c33feeb7a4f52 --- /dev/null +++ b/test/plugin_functional/plugins/hardening/package.json @@ -0,0 +1,14 @@ +{ + "name": "@kbn/hardening-plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/hardening", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/hardening/server/index.ts b/test/plugin_functional/plugins/hardening/server/index.ts new file mode 100644 index 0000000000000..cafadea796f5e --- /dev/null +++ b/test/plugin_functional/plugins/hardening/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { HardeningPlugin } from './plugin'; + +export const plugin = async () => new HardeningPlugin(); diff --git a/test/plugin_functional/plugins/hardening/server/plugin.ts b/test/plugin_functional/plugins/hardening/server/plugin.ts new file mode 100644 index 0000000000000..451f4e233169f --- /dev/null +++ b/test/plugin_functional/plugins/hardening/server/plugin.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Plugin, CoreSetup } from '@kbn/core/server'; + +export class HardeningPlugin implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.http.createRouter().get( + { + path: '/api/hardening/_pollute_prototypes', + validate: false, + }, + async (context, request, response) => { + const result: Record; error?: string }> = { + object: {}, + number: {}, + string: {}, + fn: {}, + array: {}, + }; + // Attempt to pollute Object.prototype + try { + (({}) as any).__proto__.polluted = true; + } catch (e) { + result.object.error = e.message; + } finally { + result.object.prototype = { ...Object.keys(Object.getPrototypeOf({})) }; + } + + // Attempt to pollute String.prototype + try { + ('asdf' as any).__proto__.polluted = true; + } catch (e) { + result.string.error = e.message; + } finally { + result.string.prototype = { ...Object.keys(Object.getPrototypeOf('asf')) }; + } + + // Attempt to pollute Number.prototype + try { + (12 as any).__proto__.polluted = true; + } catch (e) { + result.number.error = e.message; + } finally { + result.number.prototype = { ...Object.keys(Object.getPrototypeOf(12)) }; + } + + // Attempt to pollute Function.prototype + const fn = function fn() {}; + try { + (fn as any).__proto__.polluted = true; + } catch (e) { + result.fn.error = e.message; + } finally { + result.fn.prototype = { ...Object.keys(Object.getPrototypeOf(fn)) }; + } + + // Attempt to pollute Array.prototype + try { + ([] as any).__proto__.polluted = true; + } catch (e) { + result.array.error = e.message; + } finally { + result.array.prototype = { ...Object.keys(Object.getPrototypeOf([])) }; + } + + return response.ok({ body: result }); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/hardening/tsconfig.json b/test/plugin_functional/plugins/hardening/tsconfig.json new file mode 100644 index 0000000000000..bf146797a42ee --- /dev/null +++ b/test/plugin_functional/plugins/hardening/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + "@kbn/core" + ] +} diff --git a/test/plugin_functional/snapshots/baseline/hardening/prototype.json b/test/plugin_functional/snapshots/baseline/hardening/prototype.json new file mode 100644 index 0000000000000..4e3a9d8219492 --- /dev/null +++ b/test/plugin_functional/snapshots/baseline/hardening/prototype.json @@ -0,0 +1 @@ +{"object":{"error":"Cannot add property polluted, object is not extensible","prototype":{}},"number":{"error":"Cannot add property polluted, object is not extensible","prototype":{}},"string":{"error":"Cannot add property polluted, object is not extensible","prototype":{}},"fn":{"error":"Cannot add property polluted, object is not extensible","prototype":{}},"array":{"prototype":{"0":"polluted"}}} \ No newline at end of file diff --git a/test/plugin_functional/test_suites/hardening/index.ts b/test/plugin_functional/test_suites/hardening/index.ts new file mode 100644 index 0000000000000..b4eedb16495fe --- /dev/null +++ b/test/plugin_functional/test_suites/hardening/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('hardening', function () { + loadTestFile(require.resolve('./prototype')); + }); +} diff --git a/test/plugin_functional/test_suites/hardening/prototype.ts b/test/plugin_functional/test_suites/hardening/prototype.ts new file mode 100644 index 0000000000000..823667a009544 --- /dev/null +++ b/test/plugin_functional/test_suites/hardening/prototype.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const snapshots = getService('snapshots'); + + describe('prototype', function () { + it('does not allow polluting most prototypes', async () => { + const response = await supertest + .get('/api/hardening/_pollute_prototypes') + .set('kbn-xsrf', 'true') + .expect(200); + + await snapshots.compareAgainstBaseline('hardening/prototype', response.body); + }); + }); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index c80b6419fa026..b8a8baf855e88 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -980,6 +980,8 @@ "@kbn/handlebars/*": ["packages/kbn-handlebars/*"], "@kbn/hapi-mocks": ["packages/kbn-hapi-mocks"], "@kbn/hapi-mocks/*": ["packages/kbn-hapi-mocks/*"], + "@kbn/hardening-plugin": ["test/plugin_functional/plugins/hardening"], + "@kbn/hardening-plugin/*": ["test/plugin_functional/plugins/hardening/*"], "@kbn/health-gateway-server": ["packages/kbn-health-gateway-server"], "@kbn/health-gateway-server/*": ["packages/kbn-health-gateway-server/*"], "@kbn/hello-world-plugin": ["examples/hello_world"], diff --git a/yarn.lock b/yarn.lock index 9ed40dbc3d931..00bbba839f394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5215,6 +5215,10 @@ version "0.0.0" uid "" +"@kbn/hardening-plugin@link:test/plugin_functional/plugins/hardening": + version "0.0.0" + uid "" + "@kbn/health-gateway-server@link:packages/kbn-health-gateway-server": version "0.0.0" uid ""