Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make escapeXML work when the Function prototype is frozen #719

Merged
merged 2 commits into from
Mar 12, 2023

Conversation

jportner
Copy link
Contributor

@jportner jportner commented Mar 10, 2023

Background

Note: examples use the Node.js REPL with strict mode: node --use_strict.

Inheritance and Shadowing

Objects in JavaScript inherit properties from their prototype chain. For example, the "toString" property can be accessed on all objects, but it doesn't actually exist on each object, it exists on the global Object prototype:

let obj = {};
obj.toString();
// '[object Object]'
Object.getOwnPropertyDescriptor(obj, 'toString');
// undefined
Object.getOwnPropertyDescriptor(Object.prototype, 'toString');
// { value: [Function: toString], writable: true, enumerable: false, configurable: true }

Under normal circumstances, you can assign a property to an object using the = operator, and any property of the same name in the object's prototype chain will not be modified, but will be "shadowed" by the new property:

obj.toString = () => 'foo';
obj.toString();
// 'foo'
Object.getOwnPropertyDescriptor(obj, 'toString');
// { value: [Function: toString], writable: true, enumerable: true, configurable: true }

Prototype Pollution

From Snyk:

Prototype pollution is an injection attack that targets JavaScript runtimes. With prototype pollution, an attacker might control the default values of an object's properties. This allows the attacker to tamper with the logic of the application and can also lead to denial of service or, in extreme cases, remote code execution.

There are a few different ways to mitigate Prototype Pollution, and one way to do it across the board is to freeze the global "root" objects and their prototypes (Object, Function, Array, etc.)

From MDN:

The Object.freeze() static method freezes an object. Freezing an object prevents extensions and makes existing properties non-writable and non-configurable. A frozen object can no longer be changed: new properties cannot be added, existing properties cannot be removed, their enumerability, configurability, writability, or value cannot be changed, and the object's prototype cannot be re-assigned.

This means that any attempt to change the Object prototype will fail. If using strict mode, it will throw an error; otherwise, it will be silently ignored.

If the Object prototype becomes frozen, all of its properties are no longer writable or configurable:

Object.freeze(Object.prototype);
Object.getOwnPropertyDescriptor(Object.prototype, 'toString');
// { value: [Function: toString], writable: false, enumerable: false, configurable: false }

This also prevents shadowing properties with assignment. If an object doesn't already have a property defined (such as "toString"), and it inherits a non-writable property of that name from its prototype chain, any attempt to assign the property on that object will fail:

let obj2 = {};
obj2.toString = () => 'bar';
// Uncaught TypeError: Cannot assign to read only property 'toString' of object '#<Object>'
obj2.toString();
// '[object Object]'

This behavior is described in the ECMAScript 2016 specification:

Assignment to an undeclared identifier or otherwise unresolvable reference does not create a property in the global object. When a simple assignment occurs within strict mode code, its LeftHandSideExpression must not evaluate to an unresolvable Reference. If it does a ReferenceError exception is thrown (6.2.3.2). The LeftHandSideExpression also may not be a reference to a data property with the attribute value {[[Writable]]: false}, to an accessor property with the attribute value {[[Set]]: undefined}, nor to a non-existent property of an object whose [[Extensible]] internal slot has the value false. In these cases a TypeError exception is thrown (12.15).

The Problem

Unfortunately, this package uses assignment to shadow the "toString" function on the escapeXML function:

ejs/lib/utils.js

Lines 102 to 104 in f818bce

exports.escapeXML.toString = function () {
return Function.prototype.toString.call(this) + ';\n' + escapeFuncStr;
};

This means that projects cannot require this package if they have frozen the global Function prototype.

The Solution

You can still shadow non-writable prototype properties by explicitly defining a new data property on the object:

Object.defineProperty(obj2, 'toString', { value: () => 'bar' });
obj2.toString();
// 'bar'
Object.getOwnPropertyDescriptor(obj2, 'toString');
// { value: [Function: toString], writable: false, enumerable: false, configurable: false }

The escapeXML function can be changed to use this method of shadowing so it is compatible with this approach of mitigating Prototype Pollution 🎉

@mde
Copy link
Owner

mde commented Mar 11, 2023

Really nice work here. However, in the spirit of "don't break the Web," I think it's worth checking for the existence of defineProperty before attempting to use it here. It's been around a fairly long time (Node 0.10 according to MDN), but EJS does still run on some pretty old runtimes. Would you be willing to add a capabilities check here?

@jportner
Copy link
Contributor Author

Really nice work here.

Thanks!

However, in the spirit of "don't break the Web," I think it's worth checking for the existence of defineProperty before attempting to use it here. It's been around a fairly long time (Node 0.10 according to MDN), but EJS does still run on some pretty old runtimes. Would you be willing to add a capabilities check here?

Oh, sure. I have tested this approach with Node 0.10 and it does work, but it can't hurt to be cautious here.

I just pushed 181a537 to fall back to the assignment operator, and also to update the unit test.

@@ -37,6 +37,6 @@
"node": ">=0.10.0"
},
"scripts": {
"test": "mocha"
"test": "mocha -u tdd"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying to run the test suite, I kept getting ReferenceError: suite is not defined. Adding this argument forces Mocha to use the TDD interface and it's able to execute the tests.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. I think Mocha has either changed the location of the config file, or deprecated its use completely.

@mde mde merged commit 9ea36ba into mde:main Mar 12, 2023
@mde
Copy link
Owner

mde commented Mar 12, 2023

Awesome work, thank you very much for this.

@jportner jportner deleted the frozen-prototype-fix branch March 12, 2023 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants