Skip to content

Commit b61111f

Browse files
authored
Merge pull request #197 from dfop02/fix-CVE-2026-1615
Attempt to fix CVE-2026-1615 vulnerability
2 parents 3640316 + 491e2e0 commit b61111f

2 files changed

Lines changed: 338 additions & 2 deletions

File tree

lib/handlers.js

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,152 @@ var slice = require('./slice');
33
var _evaluate = require('static-eval');
44
var _uniq = require('underscore').uniq;
55

6+
// Property names that must never be accessible in expressions.
7+
// Mitigates prototype pollution and constructor escape attacks.
8+
var UNSAFE_PROPERTY_NAMES = Object.create(null);
9+
10+
/* jshint -W069: true */
11+
UNSAFE_PROPERTY_NAMES['constructor'] = true;
12+
UNSAFE_PROPERTY_NAMES['__proto__'] = true;
13+
UNSAFE_PROPERTY_NAMES['prototype'] = true;
14+
/* jshint -W069: false */
15+
16+
function isUnsafePropertyName(name) {
17+
return typeof name === 'string' && UNSAFE_PROPERTY_NAMES[name] === true;
18+
}
19+
20+
function isSafeAst(ast) {
21+
if (!ast || typeof ast !== 'object') return false;
22+
23+
function walk(node) {
24+
if (!node || typeof node !== 'object' || !node.type) {
25+
return false;
26+
}
27+
28+
switch (node.type) {
29+
30+
// ===== SAFE TERMINALS =====
31+
32+
case 'Literal':
33+
return true;
34+
35+
case 'Identifier':
36+
// Only allow the special scope identifier
37+
return node.name === '@';
38+
39+
40+
// ===== PROPERTY ACCESS =====
41+
42+
case 'MemberExpression': {
43+
if (!walk(node.object)) {
44+
return false;
45+
}
46+
47+
// Non-computed: obj.property
48+
if (!node.computed && node.property.type === 'Identifier') {
49+
if (isUnsafePropertyName(node.property.name)) {
50+
return false;
51+
}
52+
return true;
53+
}
54+
55+
// Computed: obj["property"]
56+
if (node.computed) {
57+
if (!walk(node.property)) {
58+
return false;
59+
}
60+
61+
if (
62+
node.property.type === 'Literal' &&
63+
isUnsafePropertyName(String(node.property.value))
64+
) {
65+
return false;
66+
}
67+
68+
return true;
69+
}
70+
71+
return false;
72+
}
73+
74+
75+
// ===== EXPRESSIONS =====
76+
77+
case 'UnaryExpression':
78+
return walk(node.argument);
79+
80+
case 'BinaryExpression':
81+
case 'LogicalExpression':
82+
return walk(node.left) && walk(node.right);
83+
84+
case 'ConditionalExpression':
85+
return (
86+
walk(node.test) &&
87+
walk(node.consequent) &&
88+
walk(node.alternate)
89+
);
90+
91+
case 'ArrayExpression':
92+
for (var i = 0; i < node.elements.length; i++) {
93+
if (!walk(node.elements[i])) {
94+
return false;
95+
}
96+
}
97+
return true;
98+
99+
case 'ObjectExpression':
100+
for (var j = 0; j < node.properties.length; j++) {
101+
var prop = node.properties[j];
102+
103+
// Reject unsafe keys
104+
if (
105+
prop.key &&
106+
(
107+
(prop.key.type === 'Identifier' &&
108+
isUnsafePropertyName(prop.key.name)) ||
109+
(prop.key.type === 'Literal' &&
110+
isUnsafePropertyName(String(prop.key.value)))
111+
)
112+
) {
113+
return false;
114+
}
115+
116+
if (!walk(prop.value)) {
117+
return false;
118+
}
119+
}
120+
return true;
121+
122+
123+
// ===== EXPLICITLY REJECT DANGEROUS TYPES =====
124+
// Security: do not rely on default deny; list each code-execution / escape vector.
125+
126+
case 'CallExpression':
127+
case 'NewExpression':
128+
case 'FunctionExpression':
129+
case 'ArrowFunctionExpression':
130+
case 'ThisExpression':
131+
case 'AssignmentExpression':
132+
case 'UpdateExpression':
133+
case 'SequenceExpression':
134+
case 'TemplateLiteral':
135+
case 'TemplateElement':
136+
case 'TaggedTemplateExpression':
137+
case 'ReturnStatement':
138+
case 'ExpressionStatement':
139+
return false;
140+
141+
142+
// ===== DEFAULT DENY =====
143+
144+
default:
145+
return false;
146+
}
147+
}
148+
149+
return walk(ast);
150+
}
151+
6152
var Handlers = function() {
7153
return this.initialize.apply(this, arguments);
8154
}
@@ -239,8 +385,11 @@ function _traverse(passable) {
239385
}
240386
}
241387

242-
function evaluate() {
243-
try { return _evaluate.apply(this, arguments) }
388+
function evaluate(ast, scope) {
389+
if (!isSafeAst(ast)) {
390+
throw new Error('Unsafe expression: script and filter expressions may only access the current node (@) with safe property names');
391+
}
392+
try { return _evaluate(ast, scope) }
244393
catch (e) { }
245394
}
246395

test/security.js

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,191 @@ suite('security', function() {
4848
}, /Unsafe key/);
4949
assert.equal(({}).polluted, undefined);
5050
});
51+
52+
suite('CVE-2026-1615: blocks code injection in filter/script expressions', function() {
53+
var data = { a: {}, b: [1, 2, 3] };
54+
55+
test('rejects constructor access in filter expression', function() {
56+
assert.throws(function() {
57+
jp.query(data, '$[?(@.constructor)]');
58+
}, /Unsafe expression/);
59+
});
60+
61+
test('rejects constructor.constructor in filter expression', function() {
62+
assert.throws(function() {
63+
jp.query(data, '$[?(@.constructor.constructor)]');
64+
}, /Unsafe expression/);
65+
});
66+
67+
test('rejects chained constructor.constructor call: @.foo["constructor"]["constructor"](...)()', function() {
68+
assert.throws(function() {
69+
jp.query(data, '$[?(@.foo["constructor"]["constructor"]("return process")())]');
70+
}, /Unsafe expression/);
71+
});
72+
73+
test('rejects __proto__ access in filter expression', function() {
74+
assert.throws(function() {
75+
jp.query(data, '$[?(@.__proto__)]');
76+
}, /Unsafe expression/);
77+
});
78+
79+
test('rejects function call in filter expression', function() {
80+
assert.throws(function() {
81+
jp.query(data, '$[?(process.exit(1))]');
82+
}, /Unsafe expression/);
83+
});
84+
85+
test('rejects constructor access in script expression', function() {
86+
var scriptData = { a: [1, 2, 3] };
87+
assert.throws(function() {
88+
jp.query(scriptData, '$[(@.constructor)]');
89+
}, /Unsafe expression/);
90+
});
91+
92+
test('allows safe filter expressions', function() {
93+
var storeData = { store: { book: [ { price: 5 }, { price: 15 } ] } };
94+
var results = jp.query(storeData, '$..book[?(@.price<10)]');
95+
assert.deepEqual(results, [ { price: 5 } ]);
96+
});
97+
98+
test('allows safe script expressions', function() {
99+
var bookData = { book: [ { id: 1 }, { id: 2 }, { id: 3 } ] };
100+
var results = jp.nodes(bookData, '$..book[(@.length-1)]');
101+
assert.deepEqual(results[0].value, { id: 3 });
102+
});
103+
104+
test('rejects bracket notation constructor: @["constructor"]', function() {
105+
assert.throws(function() { jp.query(data, '$[?(@["constructor"])]'); }, /Unsafe expression/);
106+
});
107+
108+
test('rejects bracket notation __proto__: @["__proto__"]', function() {
109+
assert.throws(function() { jp.query(data, '$[?(@["__proto__"])]'); }, /Unsafe expression/);
110+
});
111+
112+
test('rejects bracket notation prototype: @["prototype"]', function() {
113+
assert.throws(function() { jp.query(data, '$[?(@["prototype"])]'); }, /Unsafe expression/);
114+
});
115+
116+
test('rejects ObjectExpression with unsafe key: { "__proto__": @ }', function() {
117+
assert.throws(function() { jp.query(data, '$[?({ "__proto__": @ })]'); }, /Unsafe expression|Unexpected token/);
118+
});
119+
120+
test('rejects ObjectExpression with unsafe key: { "constructor": @ }', function() {
121+
assert.throws(function() { jp.query(data, '$[?({ "constructor": @ })]'); }, /Unsafe expression|Unexpected token/);
122+
});
123+
124+
test('rejects ObjectExpression with unsafe key: { "prototype": @ }', function() {
125+
assert.throws(function() { jp.query(data, '$[?({ "prototype": @ })]'); }, /Unsafe expression|Unexpected token/);
126+
});
127+
128+
test('rejects unicode escape constructor in bracket: @["\\u0063onstructor"]', function() {
129+
assert.throws(function() { jp.query(data, '$[?(@["\\u0063onstructor"])]'); }, /Unsafe expression/);
130+
});
131+
132+
test('rejects unicode escape __proto__ in bracket', function() {
133+
var path = '$[?(@["\\u005f\\u005fproto\\u005f\\u005f"])]';
134+
assert.throws(function() { jp.query(data, path); }, /Unsafe expression/);
135+
});
136+
137+
test('rejects IIFE: (function(){return 1})()', function() {
138+
assert.throws(function() { jp.query(data, '$[?((function(){return 1})())]'); }, /Unsafe expression/);
139+
});
140+
141+
test('rejects direct function call: process.exit(1)', function() {
142+
assert.throws(function() { jp.query(data, '$[?(process.exit(1))]'); }, /Unsafe expression/);
143+
});
144+
145+
test('rejects require() call', function() {
146+
assert.throws(function() { jp.query(data, '$[?(require("fs"))]'); }, /Unsafe expression/);
147+
});
148+
149+
test('rejects eval() call', function() {
150+
assert.throws(function() { jp.query(data, '$[?(eval("1"))]'); }, /Unsafe expression/);
151+
});
152+
153+
test('rejects globalThis / global identifier', function() {
154+
assert.throws(function() { jp.query(data, '$[?(globalThis)]'); }, /Unsafe expression/);
155+
assert.throws(function() { jp.query(data, '$[?(global)]'); }, /Unsafe expression/);
156+
});
157+
158+
test('rejects NewExpression: new Function("return 1")()', function() {
159+
assert.throws(function() { jp.query(data, '$[?(new Function("return 1")())]'); }, /Unsafe expression/);
160+
});
161+
162+
test('rejects JSFuck-style: [] ["filter"]["constructor"]', function() {
163+
assert.throws(function() { jp.query(data, '$[?([]["filter"]["constructor"])]'); }, /Unsafe expression/);
164+
});
165+
166+
test('rejects JSFuck-style constructor call (no @)', function() {
167+
assert.throws(function() { jp.query(data, '$[?([]["filter"]["constructor"]("return 1")())]'); }, /Unsafe expression/);
168+
});
169+
170+
test('rejects sequence expression: (1, process.exit)(1)', function() {
171+
assert.throws(function() { jp.query(data, '$[?((1, process.exit)(1))]'); }, /Unsafe expression/);
172+
});
173+
174+
test('rejects method call on @: @.valueOf()', function() {
175+
assert.throws(function() { jp.query(data, '$[?(@.valueOf())]'); }, /Unsafe expression/);
176+
});
177+
178+
test('rejects method call: @.toString()', function() {
179+
assert.throws(function() { jp.query(data, '$[?(@.toString())]'); }, /Unsafe expression/);
180+
});
181+
182+
test('rejects template literal in computed: @[`constructor`]', function() {
183+
assert.throws(function() { jp.query(data, '$[?(@[`constructor`])]'); }, /Unsafe expression|Unexpected token|ILLEGAL/);
184+
});
185+
186+
test('rejects tagged template (code execution vector)', function() {
187+
assert.throws(function() { jp.query(data, '$[?(String.raw`x`)]'); }, /Unsafe expression|Unexpected token|ILLEGAL/);
188+
});
189+
190+
test('rejects ArrowFunctionExpression', function() {
191+
assert.throws(function() { jp.query(data, '$[?((()=>1)())]'); }, /Unsafe expression|Unexpected token/);
192+
});
193+
194+
test('rejects ThisExpression (this)', function() {
195+
assert.throws(function() { jp.query(data, '$[?(this)]'); }, /Unsafe expression/);
196+
});
197+
198+
test('rejects script expression with constructor', function() {
199+
assert.throws(function() { jp.query(data, '$[(@.constructor)]'); }, /Unsafe expression/);
200+
});
201+
202+
test('rejects script expression with call', function() {
203+
assert.throws(function() { jp.query(data, '$[((function(){return 0})())]'); }, /Unsafe expression/);
204+
});
205+
206+
test('allows @.length (no call)', function() {
207+
var r = jp.query(data, '$[?(@.length)]');
208+
assert.ok(Array.isArray(r));
209+
});
210+
211+
test('allows bracket with safe key: @["length"]', function() {
212+
var r = jp.query(data, '$[?(@["length"])]');
213+
assert.ok(Array.isArray(r));
214+
});
215+
216+
test('allows @["@class"] (existing test pattern)', function() {
217+
var d = { DIV: [{ '@class': 'value', val: 5 }] };
218+
var r = jp.query(d, '$..DIV[?(@["@class"]=="value")]');
219+
assert.deepEqual(r, d.DIV);
220+
});
221+
222+
test('rejects prototype access in filter', function() {
223+
assert.throws(function() { jp.query(data, '$[?(@.prototype)]'); }, /Unsafe expression/);
224+
});
225+
226+
test('rejects comma/sequence that could hide call', function() {
227+
assert.throws(function() { jp.query(data, '$[?((0, eval)("1"))]'); }, /Unsafe expression/);
228+
});
229+
230+
test('rejects AssignmentExpression', function() {
231+
assert.throws(function() { jp.query(data, '$[?((x=1)==1)]'); }, /Unsafe expression/);
232+
});
233+
234+
test('rejects UpdateExpression (++, --)', function() {
235+
assert.throws(function() { jp.query(data, '$[?(@.x++)]'); }, /Unsafe expression/);
236+
});
237+
});
51238
});

0 commit comments

Comments
 (0)