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

[Fix] parse: Fix parsing when the global Object prototype is frozen #473

Merged
merged 1 commit into from
May 9, 2023

Conversation

jportner
Copy link
Contributor

@jportner jportner commented May 7, 2023

Background

Click to expand

Note: examples use the Node.js REPL with strict mode: NODE_REPL_MODE=strict node.

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

The allowPrototypes option is designed to strip any query parameters if they would shadow/overwrite properties on the global Object prototype (for example, "toString"). This logic is in the parseKeys() function:

qs/lib/parse.js

Lines 172 to 177 in 9dca37f

// If we aren't using plain objects, optionally prefix keys that would overwrite object prototype properties
if (!options.plainObjects && has.call(Object.prototype, parent)) {
if (!options.allowPrototypes) {
return;
}
}

However, before calling parseKeys(), the parse module first calls parseValues() to create a temporary object:

qs/lib/parse.js

Lines 246 to 256 in 9dca37f

var tempObj = typeof str === 'string' ? parseValues(str, options) : str;
var obj = options.plainObjects ? Object.create(null) : {};
// Iterate over the keys and setup the new object
var keys = Object.keys(tempObj);
for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
var newObj = parseKeys(key, tempObj[key], options, typeof str === 'string');
obj = utils.merge(obj, newObj, options);
}

Unfortunately, when you have frozen the global Object prototype, the parseValues() function will throw a TypeError when inherited properties are present. Example:

Object.freeze(Object.prototype);
const { parse } = require('./lib');
parse('foo=bar&toString=123');
// Uncaught TypeError: Cannot assign to read only property 'toString' of object '#<Object>'

This means that projects cannot use this package if they have frozen the global Object prototype.

The Solution

Since the parseValues() function returns a temporary object, the simple solution here is to create a "plain object" that does not have a prototype. This doesn't require any other logic changes, because the if the allowPrototypes option is set to false, the parseKeys() function will subsequently strip out any problematic properties.

With this change, this package will be compatible with the "frozen prototype" approach of mitigating Prototype Pollution 🎉

Now you can use the same example as above, and it works as expected:

Object.freeze(Object.prototype);
const { parse } = require('./lib');
parse('foo=bar&toString=123');
// { foo: 'bar' }

dist/qs.js Outdated Show resolved Hide resolved
Copy link
Owner

@ljharb ljharb left a comment

Choose a reason for hiding this comment

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

We'd need some tests to cover this.

One challenge, though, is that this would fail in an env where Object.create isn't available (if Object.prototype is frozen, violating the spec, then Object.create surely might be deleted), which hasn't been the case by default before.

However, the suggestion below would address that. Either way, we'd need tests to cover it.

lib/parse.js Outdated Show resolved Hide resolved
@jportner
Copy link
Contributor Author

jportner commented May 7, 2023

Unfortunately, when you have frozen the global Object prototype, the parseKeys() function will throw a TypeError when inherited properties are present. Example:

Whoops, I wrote parseKeys() here when I meant to write parseValues(), that is what was throwing the error. I edited the PR description accordingly.

@ljharb ljharb marked this pull request as draft May 7, 2023 19:57
@ljharb
Copy link
Owner

ljharb commented May 7, 2023

Note that node --use_strict is NOT "strict mode", is something v8-specific, and should never, ever be used. The only proper strict mode is with 'use strict'; or in a class body, or an ES Module.

@jportner
Copy link
Contributor Author

jportner commented May 7, 2023

Note that node --use_strict is NOT "strict mode", is something v8-specific, and should never, ever be used. The only proper strict mode is with 'use strict'; or in a class body, or an ES Module.

TIL, thanks for pointing this out!

Apparently the correct way to enable strict mode in the Node.js REPL is to use the NODE_REPL_MODE=strict env var.

@jportner jportner requested a review from ljharb May 8, 2023 03:46
@ljharb ljharb force-pushed the jportner/frozen-prototype-fix branch from 64d8894 to dce27b1 Compare May 8, 2023 17:25
Copy link
Owner

@ljharb ljharb left a comment

Choose a reason for hiding this comment

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

Refactored a bit to use mock-property and leave it configurable, since that isn't relevant to the test

@ljharb ljharb changed the title Frozen prototype fix [Fix] parse: Fix parsing when the global Object prototype is frozen May 8, 2023
@ljharb ljharb marked this pull request as ready for review May 8, 2023 17:26
@codecov
Copy link

codecov bot commented May 8, 2023

Codecov Report

Patch coverage: 100.00% and project coverage change: -0.80 ⚠️

Comparison is base (9dca37f) 99.79% compared to head (7895b94) 99.00%.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #473      +/-   ##
==========================================
- Coverage   99.79%   99.00%   -0.80%     
==========================================
  Files           8        8              
  Lines        1487     1501      +14     
  Branches      176      177       +1     
==========================================
+ Hits         1484     1486       +2     
- Misses          3       15      +12     
Impacted Files Coverage Δ
lib/parse.js 100.00% <100.00%> (ø)
test/parse.js 99.80% <100.00%> (+<0.01%) ⬆️

... and 1 file with indirect coverage changes

☔ View full report in Codecov by Sentry.
📢 Do you have feedback about the report comment? Let us know in this issue.

@ljharb
Copy link
Owner

ljharb commented May 8, 2023

node < 6 has a different error message; i fixed that locally with a regex, but node <= 0.8 actually doesn't throw when Object.prototype is frozen, so I'll make a package to detect that, and update this, and then land it.

@ljharb ljharb force-pushed the jportner/frozen-prototype-fix branch from dce27b1 to f5872dd Compare May 8, 2023 17:46
@joe-matsec
Copy link

Thank you!

@ljharb ljharb force-pushed the jportner/frozen-prototype-fix branch from f5872dd to 7895b94 Compare May 9, 2023 16:41
@ljharb ljharb merged commit 7895b94 into ljharb:main May 9, 2023
293 of 295 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants