Skip to content

Instantly share code, notes, and snippets.

@sc0ttj
Last active February 5, 2024 13:23
Show Gist options
  • Save sc0ttj/57d0ce46d73cec040a723226858df33d to your computer and use it in GitHub Desktop.
Save sc0ttj/57d0ce46d73cec040a723226858df33d to your computer and use it in GitHub Desktop.
Javascript - safer objects, with "prototype pollution" prevention, and schema validation
// NOTE:
// ----------------------------------------------------------------------------
//
// ** When a key/value structure is needed, Map should be preferred to Object. **
//
// It’s possible to create Objects in JavaScript that don’t have a prototype.
// It requires the usage of Object.create(). Objects created through this
// API won’t have the __proto__ and constructor attributes.
// "Prototype pollution" attacks
// Creating Objects in this way can help prevent "prototype pollution" attacks:
const obj = Object.create(null);
obj.__proto__ // undefined
obj.constructor // undefined
// Using Object.freeze() can help further prevent "prototype pollution" attacks:
Object.freeze(Object.prototype);
Object.freeze(Object);
// You can then test if the freezing above worked, like so:
({}).__proto__.test = 123;
({}).test // remains undefined
// The Object.seal() method is similar, but still allows changing the values
// of existing properties. Use it on your own Objects after adding the desired
// or required properties.
const myObj = { foo: 0 }; // has all the properties we need now..
Object.seal(myObj); // so seal it
// The `noop` package (https://github.com/snyk-labs/nopp) uses this to help protect you:
[
Object,
Object.prototype,
Function,
Function.prototype,
Array,
Array.prototype,
String,
String.prototype,
Number,
Number.prototype,
Boolean,
Boolean.prototype,
].forEach(Object.freeze);
// ----------------------------------------------------------------------------
// Get the value of `key`, unless `key` is "__proto__" or "constructor"
function safeGet(object, key) {
if (key === 'constructor' && typeof object[key] === 'function') {
return;
}
if (key == '__proto__') {
return;
}
return object[key];
}
// Validate the given object against the given schema.
// Returns an array of errors if the object fails validation.
//
// Usage:
//
// const obj = {
// name: 'bob',
// age: 20,
// list: [1, 2, 3],
// };
// const schema = {
// name: 'string', // expect typeof === 'string'
// age: val => typeof val === 'number' && val > 17, // expect n is a number over 17
// list: arr => arr.every(val => typeof val === 'number'), // expect array containing numbers only
// };
// const errs = validate(obj, schema); // errs.length === 0
//
const validate = (obj, schema) => {
const errs = [];
Object.keys(schema).forEach(key => {
// dont parse any of these keys
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return;
}
const val = obj[key];
const type = typeof val;
const expectedType = schema[key];
// if we have a function, it's a custom validator function, which should return true/false
if (typeof expectedType === 'function') {
if (schema[key](val) !== true) errs.push({ key, expected: true, got: schema[key](val) });
}
else if (expectedType === 'array' && !Array.isArray(val)) {
errs.push({ key, expected: 'array', got: type });
}
// if we have a string, it should be the name of the expected type in the schema
else if (type !== expectedType.toLowerCase()) {
errs.push({ key, expected: expectedType.toLowerCase(), got: type });
}
// if we have object, call validator on it
else if (type === 'object' && !Array.isArray(val)) {
errs = [...errs, ...validate(obj[key], schema[key])];
}
});
return errs;
}
// Create a "safe" object, that is protected against prototype pollution,
// sealed by default (no new props can be added), and that can optionally
// validate any changes to itself against its given schema - changes are
// only allowed to the object if valid, according to the schema.
//
// Usage:
//
// const obj = { name: 'bob', age: 20 };
// const schema = {
// name: 'string',
// age: val => typeof val === 'number' && val > 17,
// };
// const myObj = safeObject(obj, schema);
//
// myObj.age = 'foo'; // throws Error - wrong type according to the schema.
// myObj.baz = 'foo'; // throws Error - the property 'baz' is unknown to the schema.
//
const safeObject = (data = {}, schema = undefined, sealed = true, frozen = false) => {
// create an object to return, with a null prototype, and also prevent
// any changes to its prototype and constructor.
const obj = Object.create(null);
Object.freeze(obj.prototype);
Object.freeze(obj.__proto__);
Object.freeze(obj.constructor);
if (typeof schema !== 'object') {
// no valid schema was provided, so just add stuff from `data` into `obj`
for (const [key, value] of Object.entries(data)) {
// dont parse any of these keys
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return;
}
try {
obj[key] = value;
} catch (err) {
throw Error(err.msg);
}
}
}
else if (typeof schema === 'object') {
// for each property in the schema
for (const [key, val] of Object.entries(schema)) {
// dont parse any of these keys
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return;
}
// 1. Whenever `obj[key]` changes, re-run a built-in validator that checks
// the value against what is expected in `schema[key]`.
// 2. Only update `obj` if the new property or value is valid, according to
// `schema`, else, throw an error.
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
return safeGet(obj, key);
},
set(value) {
// let's validate `value` against its entry in the schema.
const errs = validate({ [key]: value }, schema[key]);
if (errs.length > 0) {
errs.forEach(err => console.error(err));
throw Error(`Failed validation: ${key}`);
}
// we passed validation OK, so try to set the prop
try {
obj[key] = value;
} catch (err) {
throw Error(err.msg);
}
},
});
}
}
// "Seal" the object
// Attempting to add or delete properties to a "sealed" object, or to convert a data property
// to an accessor (getter/setter), or vice versa, will fail.
// The values of sealed "data properties" (regular properties) can still be changed as normal.
if (sealed === true) Object.seal(obj);
// "Freeze" the object
// Prevent extensions (new properties), deletions, and make existing properties non-writable and
// non-configurable. More simply - a frozen object cannot be changed at all.
if (frozen === true) Object.freeze(obj);
return obj;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment