Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 151 additions & 2 deletions lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,152 @@ var slice = require('./slice');
var _evaluate = require('static-eval');
var _uniq = require('underscore').uniq;

// Property names that must never be accessible in expressions.
// Mitigates prototype pollution and constructor escape attacks.
var UNSAFE_PROPERTY_NAMES = Object.create(null);

/* jshint -W069: true */
UNSAFE_PROPERTY_NAMES['constructor'] = true;
UNSAFE_PROPERTY_NAMES['__proto__'] = true;
UNSAFE_PROPERTY_NAMES['prototype'] = true;
/* jshint -W069: false */

function isUnsafePropertyName(name) {
return typeof name === 'string' && UNSAFE_PROPERTY_NAMES[name] === true;
}

function isSafeAst(ast) {
if (!ast || typeof ast !== 'object') return false;

function walk(node) {
if (!node || typeof node !== 'object' || !node.type) {
return false;
}

switch (node.type) {

// ===== SAFE TERMINALS =====

case 'Literal':
return true;

case 'Identifier':
// Only allow the special scope identifier
return node.name === '@';


// ===== PROPERTY ACCESS =====

case 'MemberExpression': {
if (!walk(node.object)) {
return false;
}

// Non-computed: obj.property
if (!node.computed && node.property.type === 'Identifier') {
if (isUnsafePropertyName(node.property.name)) {
return false;
}
return true;
}

// Computed: obj["property"]
if (node.computed) {
if (!walk(node.property)) {
return false;
}

if (
node.property.type === 'Literal' &&
isUnsafePropertyName(String(node.property.value))
) {
return false;
}

return true;
}

return false;
}


// ===== EXPRESSIONS =====

case 'UnaryExpression':
return walk(node.argument);

case 'BinaryExpression':
case 'LogicalExpression':
return walk(node.left) && walk(node.right);

case 'ConditionalExpression':
return (
walk(node.test) &&
walk(node.consequent) &&
walk(node.alternate)
);

case 'ArrayExpression':
for (var i = 0; i < node.elements.length; i++) {
if (!walk(node.elements[i])) {
return false;
}
}
return true;

case 'ObjectExpression':
for (var j = 0; j < node.properties.length; j++) {
var prop = node.properties[j];

// Reject unsafe keys
if (
prop.key &&
(
(prop.key.type === 'Identifier' &&
isUnsafePropertyName(prop.key.name)) ||
(prop.key.type === 'Literal' &&
isUnsafePropertyName(String(prop.key.value)))
)
) {
return false;
}

if (!walk(prop.value)) {
return false;
}
}
return true;


// ===== EXPLICITLY REJECT DANGEROUS TYPES =====
// Security: do not rely on default deny; list each code-execution / escape vector.

case 'CallExpression':
case 'NewExpression':
case 'FunctionExpression':
case 'ArrowFunctionExpression':
case 'ThisExpression':
case 'AssignmentExpression':
case 'UpdateExpression':
case 'SequenceExpression':
case 'TemplateLiteral':
case 'TemplateElement':
case 'TaggedTemplateExpression':
case 'ReturnStatement':
case 'ExpressionStatement':
return false;


// ===== DEFAULT DENY =====

default:
return false;
}
}

return walk(ast);
}

var Handlers = function() {
return this.initialize.apply(this, arguments);
}
Expand Down Expand Up @@ -239,8 +385,11 @@ function _traverse(passable) {
}
}

function evaluate() {
try { return _evaluate.apply(this, arguments) }
function evaluate(ast, scope) {
if (!isSafeAst(ast)) {
throw new Error('Unsafe expression: script and filter expressions may only access the current node (@) with safe property names');
}
try { return _evaluate(ast, scope) }
catch (e) { }
}

Expand Down
187 changes: 187 additions & 0 deletions test/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,191 @@ suite('security', function() {
}, /Unsafe key/);
assert.equal(({}).polluted, undefined);
});

suite('CVE-2026-1615: blocks code injection in filter/script expressions', function() {
var data = { a: {}, b: [1, 2, 3] };

test('rejects constructor access in filter expression', function() {
assert.throws(function() {
jp.query(data, '$[?(@.constructor)]');
}, /Unsafe expression/);
});

test('rejects constructor.constructor in filter expression', function() {
assert.throws(function() {
jp.query(data, '$[?(@.constructor.constructor)]');
}, /Unsafe expression/);
});

test('rejects chained constructor.constructor call: @.foo["constructor"]["constructor"](...)()', function() {
assert.throws(function() {
jp.query(data, '$[?(@.foo["constructor"]["constructor"]("return process")())]');
}, /Unsafe expression/);
});

test('rejects __proto__ access in filter expression', function() {
assert.throws(function() {
jp.query(data, '$[?(@.__proto__)]');
}, /Unsafe expression/);
});

test('rejects function call in filter expression', function() {
assert.throws(function() {
jp.query(data, '$[?(process.exit(1))]');
}, /Unsafe expression/);
});

test('rejects constructor access in script expression', function() {
var scriptData = { a: [1, 2, 3] };
assert.throws(function() {
jp.query(scriptData, '$[(@.constructor)]');
}, /Unsafe expression/);
});

test('allows safe filter expressions', function() {
var storeData = { store: { book: [ { price: 5 }, { price: 15 } ] } };
var results = jp.query(storeData, '$..book[?(@.price<10)]');
assert.deepEqual(results, [ { price: 5 } ]);
});

test('allows safe script expressions', function() {
var bookData = { book: [ { id: 1 }, { id: 2 }, { id: 3 } ] };
var results = jp.nodes(bookData, '$..book[(@.length-1)]');
assert.deepEqual(results[0].value, { id: 3 });
});

test('rejects bracket notation constructor: @["constructor"]', function() {
assert.throws(function() { jp.query(data, '$[?(@["constructor"])]'); }, /Unsafe expression/);
});

test('rejects bracket notation __proto__: @["__proto__"]', function() {
assert.throws(function() { jp.query(data, '$[?(@["__proto__"])]'); }, /Unsafe expression/);
});

test('rejects bracket notation prototype: @["prototype"]', function() {
assert.throws(function() { jp.query(data, '$[?(@["prototype"])]'); }, /Unsafe expression/);
});

test('rejects ObjectExpression with unsafe key: { "__proto__": @ }', function() {
assert.throws(function() { jp.query(data, '$[?({ "__proto__": @ })]'); }, /Unsafe expression|Unexpected token/);
});

test('rejects ObjectExpression with unsafe key: { "constructor": @ }', function() {
assert.throws(function() { jp.query(data, '$[?({ "constructor": @ })]'); }, /Unsafe expression|Unexpected token/);
});

test('rejects ObjectExpression with unsafe key: { "prototype": @ }', function() {
assert.throws(function() { jp.query(data, '$[?({ "prototype": @ })]'); }, /Unsafe expression|Unexpected token/);
});

test('rejects unicode escape constructor in bracket: @["\\u0063onstructor"]', function() {
assert.throws(function() { jp.query(data, '$[?(@["\\u0063onstructor"])]'); }, /Unsafe expression/);
});

test('rejects unicode escape __proto__ in bracket', function() {
var path = '$[?(@["\\u005f\\u005fproto\\u005f\\u005f"])]';
assert.throws(function() { jp.query(data, path); }, /Unsafe expression/);
});

test('rejects IIFE: (function(){return 1})()', function() {
assert.throws(function() { jp.query(data, '$[?((function(){return 1})())]'); }, /Unsafe expression/);
});

test('rejects direct function call: process.exit(1)', function() {
assert.throws(function() { jp.query(data, '$[?(process.exit(1))]'); }, /Unsafe expression/);
});

test('rejects require() call', function() {
assert.throws(function() { jp.query(data, '$[?(require("fs"))]'); }, /Unsafe expression/);
});

test('rejects eval() call', function() {
assert.throws(function() { jp.query(data, '$[?(eval("1"))]'); }, /Unsafe expression/);
});

test('rejects globalThis / global identifier', function() {
assert.throws(function() { jp.query(data, '$[?(globalThis)]'); }, /Unsafe expression/);
assert.throws(function() { jp.query(data, '$[?(global)]'); }, /Unsafe expression/);
});

test('rejects NewExpression: new Function("return 1")()', function() {
assert.throws(function() { jp.query(data, '$[?(new Function("return 1")())]'); }, /Unsafe expression/);
});

test('rejects JSFuck-style: [] ["filter"]["constructor"]', function() {
assert.throws(function() { jp.query(data, '$[?([]["filter"]["constructor"])]'); }, /Unsafe expression/);
});

test('rejects JSFuck-style constructor call (no @)', function() {
assert.throws(function() { jp.query(data, '$[?([]["filter"]["constructor"]("return 1")())]'); }, /Unsafe expression/);
});

test('rejects sequence expression: (1, process.exit)(1)', function() {
assert.throws(function() { jp.query(data, '$[?((1, process.exit)(1))]'); }, /Unsafe expression/);
});

test('rejects method call on @: @.valueOf()', function() {
assert.throws(function() { jp.query(data, '$[?(@.valueOf())]'); }, /Unsafe expression/);
});

test('rejects method call: @.toString()', function() {
assert.throws(function() { jp.query(data, '$[?(@.toString())]'); }, /Unsafe expression/);
});

test('rejects template literal in computed: @[`constructor`]', function() {
assert.throws(function() { jp.query(data, '$[?(@[`constructor`])]'); }, /Unsafe expression|Unexpected token|ILLEGAL/);
});

test('rejects tagged template (code execution vector)', function() {
assert.throws(function() { jp.query(data, '$[?(String.raw`x`)]'); }, /Unsafe expression|Unexpected token|ILLEGAL/);
});

test('rejects ArrowFunctionExpression', function() {
assert.throws(function() { jp.query(data, '$[?((()=>1)())]'); }, /Unsafe expression|Unexpected token/);
});

test('rejects ThisExpression (this)', function() {
assert.throws(function() { jp.query(data, '$[?(this)]'); }, /Unsafe expression/);
});

test('rejects script expression with constructor', function() {
assert.throws(function() { jp.query(data, '$[(@.constructor)]'); }, /Unsafe expression/);
});

test('rejects script expression with call', function() {
assert.throws(function() { jp.query(data, '$[((function(){return 0})())]'); }, /Unsafe expression/);
});

test('allows @.length (no call)', function() {
var r = jp.query(data, '$[?(@.length)]');
assert.ok(Array.isArray(r));
});

test('allows bracket with safe key: @["length"]', function() {
var r = jp.query(data, '$[?(@["length"])]');
assert.ok(Array.isArray(r));
});

test('allows @["@class"] (existing test pattern)', function() {
var d = { DIV: [{ '@class': 'value', val: 5 }] };
var r = jp.query(d, '$..DIV[?(@["@class"]=="value")]');
assert.deepEqual(r, d.DIV);
});

test('rejects prototype access in filter', function() {
assert.throws(function() { jp.query(data, '$[?(@.prototype)]'); }, /Unsafe expression/);
});

test('rejects comma/sequence that could hide call', function() {
assert.throws(function() { jp.query(data, '$[?((0, eval)("1"))]'); }, /Unsafe expression/);
});

test('rejects AssignmentExpression', function() {
assert.throws(function() { jp.query(data, '$[?((x=1)==1)]'); }, /Unsafe expression/);
});

test('rejects UpdateExpression (++, --)', function() {
assert.throws(function() { jp.query(data, '$[?(@.x++)]'); }, /Unsafe expression/);
});
});
});