Skip to content

Commit

Permalink
Seal native prototypes on the server (#190716)
Browse files Browse the repository at this point in the history
## Summary

This PR
[seals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal)
`Object.prototype`, `String.prototype`, `Number.prototype`, and
`Function.prototype` on the Kibana server, which provides some measure
of protection against prototype pollution.

<details>
<summary>The Object.seal() static method seals an object.</summary>
**note** I currently have this marked as `backport:skip` to reduce the
risk of regressions in patch releases.

> The Object.seal() static method seals an object. Sealing an object
[prevents
extensions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/preventExtensions)
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.
seal() returns the same object that was passed in.
</details>

-----

## Help, this broke something!

Please let us know by opening an issue. If you need to get your
environment up and running quickly, you can disable these protections by
setting the `KBN_UNSAFE_DISABLE_PROTOTYPE_HARDENING` environment
variable to any truthy value.

This may be interfering with normal functionality if you encounter an
error similar to:
> Cannot add property foo, object is not extensible

Where `foo` is some arbitrary string.

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
legrego and kibanamachine authored Aug 26, 2024
1 parent 5fc5b99 commit 2ca2f2a
Show file tree
Hide file tree
Showing 18 changed files with 433 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/setup_node_env/harden/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}
29 changes: 29 additions & 0 deletions src/setup_node_env/harden/prototype.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions test/harden/child_process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions test/harden/lodash_template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions test/harden/prototype.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
1 change: 1 addition & 0 deletions test/plugin_functional/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
13 changes: 13 additions & 0 deletions test/plugin_functional/plugins/hardening/kibana.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"type": "plugin",
"id": "@kbn/hardening-plugin",
"owner": "@elastic/kibana-security",
"plugin": {
"id": "hardeningPlugin",
"server": true,
"browser": false,
"configPath": [
"hardening_plugin"
]
}
}
14 changes: 14 additions & 0 deletions test/plugin_functional/plugins/hardening/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions test/plugin_functional/plugins/hardening/server/index.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading

0 comments on commit 2ca2f2a

Please sign in to comment.