pp-test-kit
v0.5.1
Published
A test kit for detecting prototype pollution and gadgets
Downloads
865
Readme
Prototype Pollution Test Kit
A testing utility library to help write tests related to prototype pollution such as behavior under pollution and missing property accesses.
Introduction | Documentation | Changelog | Contributing Guidelines | Security Policy | LICENSE
Introduction
Prototype pollution allows attackers to manipulate the properties available on the prototype of an object. Such properties might be accessed in code when you look up a property that's not defined directly on the object.
For example:
// What pollution looks like in simplified terms:
Object.prototype.foo = "bar";
// The result prototype pollution might have on your code:
const obj = { hello: "world!" };
if (obj.foo === "bar") {
// This will get executed because foo is set to bar on the prototype
console.log("The object has the property 'foo' with the value 'bar'");
}
To mitigate this you can (among other strategies) check if the property is present on the object itself before accessing it. This would look like:
const obj = { hello: "world!" };
if (Object.hasOwn(obj, "foo") && obj.foo === "bar") {
// This won't get executed because obj does not have an own property named foo
console.log("The object has the property 'foo' with the value 'bar'");
}
However, it's pretty easy to forget to write this check...
Solution of this Project
To help protect against the effects of prototype pollution this project offers testing utilities that you can use in your tests to simulate pollution or detect missing property accesses.
The philosophy behind doing this is that any such missing lookup could result in unexpected behavior due to prototype pollution. Because unexpected behavior is unwanted, you want them either to not occur or ensure that the behavior is still as expected.
Additionally, tests can give you confidence to remove unnecessary or duplicate checks that protect against prototype pollution - which improves performance.
Documentation
There are two categories of test utilities in this library. Each help you write different types of tests. It is recommended to read the introduction for each category to understand which you should use.
Missing Property Access | Simulated Pollution
Missing Property Access
A missing property access occurs when your code tried to access a property that
the object does not have, for example: {}.foo
. If this happens, the behavior
of that code is affected by prototype pollution. If foo
occurs is present in
the prototype as "bar"
the example will return "bar"
instead of undefined
.
Because this can have negative consequences this library includes utilities to test for missing property accesses.
throwing.wrap(subject[, options])
subject
(function | object): The function or object to wrap.options
(object):[strict]
(boolean, defaultfalse
): Set totrue
to disallow lookups of properties currently in the prototype. This allows for use of the prototype chain for inheritance purposes. Since this is generally okay and expected this is not enabled by default, though it should be noted that such properties can be overridden which may have unexpected consequences.
- Returns: A wrapped object.
Wrap a function or object so that the first missing property access results in an error being thrown. For functions it covers all its arguments. The error will include the name of the property being accessed as well as the code location where the access occurred.
This more or less requires that you don't expect the function to error in the first place. It may also succeed unexpectedly if the missing property access happens inside a try-catch statement. Alternatively, consider using the manual API instead.
For example for functions:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/throwing";
import { fn } from "./my-file.js";
test("no missing properties are accessed", () => {
// Wrap the function under test using the test kit so that any missing
// property accesses on any of its arguments is detected.
const fut = ppTestKit.wrap(fn);
// Run the wrapped function under test. This will throw an error if a missing
// property is accessed.
fut({});
});
and for objects:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/throwing";
import { fn } from "./my-file.js";
test("no missing properties are accessed", () => {
// Wrap at least one input to the function under test. The first missing
// property access on any wrapped object will result in an error.
const options = ppTestKit.wrap({});
// Run the function under test. This will throw an error if a missing
// property is accessed.
fn(options);
});
This will error on the first missing property access detected when running fn
.
Subsequent missing property accesses won't be detected.
A more comprehensive approach is to use property testing to generate varied input arguments to the function under test. See the examples to get an idea of how this can be done.
manual.wrap(subject[, options])
subject
(function | object): The function or object to wrap.options
(object):[strict]
(boolean, defaultfalse
): Set totrue
to disallow lookups of properties currently in the prototype. This allows for use of the prototype chain for inheritance purposes. Since this is generally okay and expected this is not enabled by default, though it should be noted that such properties can be overridden which may have unexpected consequences.
- Returns: A wrapped object.
Wrap a function or object so that all missing property accesses are recorded and
can later be checked with the manual.check(...wrapped)
API. For functions it
covers all its arguments. If any missing property accesses occurred the check
will error with the name and corresponding code location of the access for each
detected access.
This requires that you manually check the wrapped object(s) after running the function under test. If you don't do this nothing will happen. Alternatively, consider using the throwing API instead.
For example for functions:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/manual";
import { fn } from "./my-file.js";
test("no missing properties are accessed", () => {
// Wrap the function under test using the test kit so that any missing
// property accesses on any of its arguments is detected.
const fut = ppTestKit.wrap(fn);
// Run the wrapped function under test. This should succeed.
fut({});
// Check the wrapped object(s) for any missing property accesses. If any
// missing property access occurred this will throw an error.
ppTestKit.check(fut);
});
and for objects:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/manual";
import { fn } from "./my-file.js";
test("no missing properties are accessed", () => {
// Wrap at least one input to the function under test. Each missing property
// access on any wrapped object will be recorded and must be checked later.
const options = ppTestKit.wrap({});
// Run the function under test. This should succeed.
fn(options);
// Check the wrapped object(s) for any missing property accesses. If any
// missing property access occurred this will throw an error.
ppTestKit.check(options);
});
This will error on all missing property accesses detected when running fn
.
A more comprehensive approach is to use property testing to generate varied input arguments to the function under test. See the examples to get an idea of how this can be done.
Simulated Pollution
Looking up values in the prototype might not always be bad. To be sure that this is the case these testing utilities help you simulate pollution so that you can test that this is indeed the case.
invariant.test(fn)
fn
(function) A test function. Accepts one argument, the test contextctx
, which provides further utilities.
Run a test function exposed to pollution of detected missing property accesses. Useful when avoiding missing property accesses isn't an option.
The test function will always be called at least once. This initial run occurs without any pollution to see if the test succeeds and detect an missing property accesses. Subsequent runs simulate pollution of detected missing property. There will also be a run simulating pollution of an enumerable property which may affect iterations.
The be able to detect missing property accesses you must wrap at least one input to the function under test.
For example:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/invariant";
import { fn } from "./my-file.js";
test("polluted properties don't affect the function", () => {
// Run the test kit's test function inside your test frameworks test function.
ppTestKit.test((ctx) => {
// Wrap at least one input to the function under test. This serves both to
// monitor for missing property accesses as well as simulating pollution.
const options = ctx.wrap({});
// Run the function under test.
const result = fn(options);
// Assert some invariant that should hold even when the function under test
// is exposed to prototype pollution.
assert.ok(result);
});
});
This will error if the invariant (assert.ok
in this case) does not hold. If
this happens in the initial unpolluted run it will indicate so in the error
message. If this happens when exposed to pollution the error will include the
property and code location of first access.
A more comprehensive approach is to use property testing to generate varied input arguments to the function under test. However, this may be slow if many missing property accesses occur.
ctx.wrap(subject[, options])
subject
(object): The object to wrap.options
(object):[strict]
(boolean, defaultfalse
): Set totrue
to disallow lookups of properties currently in the prototype. This allows for use of the prototype chain for inheritance purposes. Since this is generally okay and expected this is not enabled by default, though it should be noted that such properties can be overridden which may have unexpected consequences.
- Returns: A wrapped object.
simulate.simulatePollution(subject, ...optionsList)
subject
(object): The object to simulate pollution on....optionsList
(object):[enumerable]
(boolean, default:false
): Whether or not the property should be enumerable.property
(any): The property to pollute.[value]
(any, default: random string): The value to pollute with.
- Returns: An object affected by pollution.
Simulate the effect of prototype pollution of specific properties on a single object. Useful for regression testing or when a property is known to be security sensitive.
This affects only one object, subject
, and no other objects. Alternatively,
consider using the withPollution
API
to have pollution affect all objects.
For example:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/simulate";
import { fn } from "./my-file.js";
test("polluting 'foo' doesn't affect the function", () => {
const options = {};
// Simulate pollution on at least one input to the function under test.
const pollutedOptions = ppTestKit.simulatePollution(options, {
property: "foo",
value: "bar",
});
// Run the function under test.
const actual = fn(pollutedOptions);
const expected = fn(options);
// Assert that pollution does not affect the result.
assert.equal(actual, expected);
});
This asserts that the function under tests has the same result regardless of
whether the foo
property is polluted.
A more comprehensive approach is to use property testing to generate varied input arguments to the function under test. See the examples to get an idea of how this can be done.
simulate.withPollution(fn, ...optionsList)
fn
(Function): The function to run with pollution simulated....optionsList
(object):[enumerable]
(boolean, default:false
): Whether or not the property should be enumerable.property
(any): The property to pollute.[value]
(any, default: random string): The value to pollute with.
- Returns: The return value of
fn
.
Simulate the effect of prototype pollution of specific properties in general. Useful for regression testing or when a property is known to be security sensitive.
This affects the entire program for the duration of fn
, including your test
framework and anything else that might execute. Alternatively, consider using
the simulatePollution
API to
have pollution affect only a single object.
For example:
import { test } from "node:test";
import * as ppTestKit from "pp-test-kit/simulate";
import { fn } from "./my-file.js";
test("polluting 'foo' doesn't affect the function", () => {
const options = {};
// Run the function under test under the effect of prototype pollution.
ppTestKit.withPollution(() => fn(options), {
property: "foo",
value: "bar",
});
// Assert that pollution does not affect the result.
const expected = fn(options);
assert.equal(actual, expected);
});
This asserts that the function under tests has the same result regardless of
whether the foo
property is polluted.
A more comprehensive approach is to use property testing to generate varied input arguments to the function under test. See the examples to get an idea of how this can be done.
Concepts
Property Testing
Property testing (or generative testing or QuickCheck testing) is a automated testing technique where the function under test is exposed to a wide variety of inputs and the same property is asserted for each. In the context of this library the property would relate to the effect of prototype pollution on the function under test.
In JavaScript you can write property tests using fast-check.
Prototype Pollution
Prototype pollution is a vulnerability category affecting JavaScript that allows attackers to modify prototype objects. Since inheritance in JavaScript relies on prototypes, this means that when a missing property is accessed on an object it might return the polluted value. Depending on how the value is used, the consequences can range from minor issues to severe security risks.
You can learn more at https://portswigger.net/web-security/prototype-pollution.