Skip to content

Commit 947c175

Browse files
committed
Improve security mitigations
Instead of validating the AST in the parser, fix the compiler instead by handling the types in a safe way.
1 parent d069c1c commit 947c175

6 files changed

Lines changed: 239 additions & 203 deletions

File tree

lib/handlebars/compiler/base.js

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import parser from './parser';
22
import WhitespaceControl from './whitespace-control';
33
import * as Helpers from './helpers';
4-
import Exception from '../exception';
54
import { extend } from '../utils';
65

76
export { parser };
@@ -12,9 +11,6 @@ extend(yy, Helpers);
1211
export function parseWithoutProcessing(input, options) {
1312
// Just return if an already-compiled AST was passed in.
1413
if (input.type === 'Program') {
15-
// When a pre-parsed AST is passed in, validate all node values to prevent
16-
// code injection via type-confused literals.
17-
validateInputAst(input);
1814
return input;
1915
}
2016

@@ -36,66 +32,3 @@ export function parse(input, options) {
3632

3733
return strip.accept(ast);
3834
}
39-
40-
function validateInputAst(ast) {
41-
validateAstNode(ast);
42-
}
43-
44-
function validateAstNode(node) {
45-
if (node == null) {
46-
return;
47-
}
48-
49-
if (Array.isArray(node)) {
50-
node.forEach(validateAstNode);
51-
return;
52-
}
53-
54-
if (typeof node !== 'object') {
55-
return;
56-
}
57-
58-
if (node.type === 'PathExpression') {
59-
if (!isValidDepth(node.depth)) {
60-
throw new Exception(
61-
'Invalid AST: PathExpression.depth must be an integer'
62-
);
63-
}
64-
if (!Array.isArray(node.parts)) {
65-
throw new Exception('Invalid AST: PathExpression.parts must be an array');
66-
}
67-
for (let i = 0; i < node.parts.length; i++) {
68-
if (typeof node.parts[i] !== 'string') {
69-
throw new Exception(
70-
'Invalid AST: PathExpression.parts must only contain strings'
71-
);
72-
}
73-
}
74-
} else if (node.type === 'NumberLiteral') {
75-
if (typeof node.value !== 'number' || !isFinite(node.value)) {
76-
throw new Exception('Invalid AST: NumberLiteral.value must be a number');
77-
}
78-
} else if (node.type === 'BooleanLiteral') {
79-
if (typeof node.value !== 'boolean') {
80-
throw new Exception(
81-
'Invalid AST: BooleanLiteral.value must be a boolean'
82-
);
83-
}
84-
}
85-
86-
Object.keys(node).forEach(propertyName => {
87-
if (propertyName === 'loc') {
88-
return;
89-
}
90-
validateAstNode(node[propertyName]);
91-
});
92-
}
93-
94-
function isValidDepth(depth) {
95-
return (
96-
typeof depth === 'number' &&
97-
isFinite(depth) &&
98-
Math.floor(depth) === depth &&
99-
depth >= 0
100-
);
101-
}

lib/handlebars/compiler/compiler.js

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/* eslint-disable new-cap */
22

33
import Exception from '../exception';
4-
import { isArray, indexOf, extend } from '../utils';
4+
import {
5+
isArray,
6+
indexOf,
7+
extend,
8+
sanitizeDepth,
9+
sanitizeParts
10+
} from '../utils';
511
import AST from './ast';
612

713
const slice = [].slice;
@@ -288,29 +294,27 @@ Compiler.prototype = {
288294
},
289295

290296
PathExpression: function(path) {
291-
this.addDepth(path.depth);
292-
this.opcode('getContext', path.depth);
297+
// Sanitize to prevent code injection via type-confused AST nodes.
298+
const depth = sanitizeDepth(path.depth);
299+
const parts = sanitizeParts(path.parts);
300+
301+
this.addDepth(depth);
302+
this.opcode('getContext', depth);
293303

294-
let name = path.parts[0],
304+
let name = parts[0],
295305
scoped = AST.helpers.scopedId(path),
296-
blockParamId = !path.depth && !scoped && this.blockParamIndex(name);
306+
blockParamId = !depth && !scoped && this.blockParamIndex(name);
297307

298308
if (blockParamId) {
299-
this.opcode('lookupBlockParam', blockParamId, path.parts);
309+
this.opcode('lookupBlockParam', blockParamId, parts);
300310
} else if (!name) {
301311
// Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
302312
this.opcode('pushContext');
303313
} else if (path.data) {
304314
this.options.data = true;
305-
this.opcode('lookupData', path.depth, path.parts, path.strict);
315+
this.opcode('lookupData', depth, parts, path.strict);
306316
} else {
307-
this.opcode(
308-
'lookupOnContext',
309-
path.parts,
310-
path.falsy,
311-
path.strict,
312-
scoped
313-
);
317+
this.opcode('lookupOnContext', parts, path.falsy, path.strict, scoped);
314318
}
315319
},
316320

@@ -319,11 +323,11 @@ Compiler.prototype = {
319323
},
320324

321325
NumberLiteral: function(number) {
322-
this.opcode('pushLiteral', number.value);
326+
this.opcode('pushNumber', number.value);
323327
},
324328

325329
BooleanLiteral: function(bool) {
326-
this.opcode('pushLiteral', bool.value);
330+
this.opcode('pushBoolean', bool.value);
327331
},
328332

329333
UndefinedLiteral: function() {

lib/handlebars/compiler/javascript-compiler.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { COMPILER_REVISION, REVISION_CHANGES } from '../base';
22
import Exception from '../exception';
3-
import { isArray } from '../utils';
3+
import { isArray, sanitizeDepth } from '../utils';
44
import CodeGen from './code-gen';
55

66
function Literal(value) {
@@ -469,7 +469,7 @@ JavaScriptCompiler.prototype = {
469469
//
470470
// Set the value of the `lastContext` compiler value to the depth
471471
getContext: function(depth) {
472-
this.lastContext = depth;
472+
this.lastContext = sanitizeDepth(depth);
473473
},
474474

475475
// [pushContext]
@@ -513,7 +513,13 @@ JavaScriptCompiler.prototype = {
513513
lookupBlockParam: function(blockParamId, parts) {
514514
this.useBlockParams = true;
515515

516-
this.push(['blockParams[', blockParamId[0], '][', blockParamId[1], ']']);
516+
this.push([
517+
'blockParams[',
518+
Number(blockParamId[0]),
519+
'][',
520+
Number(blockParamId[1]),
521+
']'
522+
]);
517523
this.resolvePath('context', parts, 1);
518524
},
519525

@@ -527,7 +533,9 @@ JavaScriptCompiler.prototype = {
527533
if (!depth) {
528534
this.pushStackLiteral('data');
529535
} else {
530-
this.pushStackLiteral('container.data(data, ' + depth + ')');
536+
this.pushStackLiteral(
537+
'container.data(data, ' + sanitizeDepth(depth) + ')'
538+
);
531539
}
532540

533541
this.resolvePath('data', parts, 0, true, strict);
@@ -659,6 +667,28 @@ JavaScriptCompiler.prototype = {
659667
this.pushStackLiteral(value);
660668
},
661669

670+
// [pushNumber]
671+
//
672+
// On stack, before: ...
673+
// On stack, after: number, ...
674+
//
675+
// Pushes a numeric value onto the stack, coercing via Number()
676+
// to prevent code injection through type-confused AST nodes.
677+
pushNumber: function(value) {
678+
this.pushStackLiteral(Number(value));
679+
},
680+
681+
// [pushBoolean]
682+
//
683+
// On stack, before: ...
684+
// On stack, after: boolean, ...
685+
//
686+
// Pushes a boolean value onto the stack, strictly coercing
687+
// to prevent code injection through type-confused AST nodes.
688+
pushBoolean: function(value) {
689+
this.pushStackLiteral(value === true ? 'true' : 'false');
690+
},
691+
662692
// [pushProgram]
663693
//
664694
// On stack, before: ...

lib/handlebars/utils.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,30 @@ export function blockParams(params, ids) {
114114
export function appendContextPath(contextPath, id) {
115115
return (contextPath ? contextPath + '.' : '') + id;
116116
}
117+
118+
/**
119+
* Coerce an untrusted depth value to a safe non-negative integer.
120+
* Returns `0` for any value that is not a finite, non-negative number.
121+
*
122+
* @param {unknown} depth - The depth value to sanitize.
123+
* @returns {number} A non-negative integer.
124+
*/
125+
export function sanitizeDepth(depth) {
126+
let number = Number(depth);
127+
if (!Number.isFinite(number) || number < 0) {
128+
return 0;
129+
}
130+
return Math.floor(number);
131+
}
132+
133+
/**
134+
* Return a sanitized copy of a PathExpression AST node's parts array.
135+
* Coerces each element to a string, or returns an empty array if parts
136+
* is not an array.
137+
*
138+
* @param {unknown} parts - The parts value to sanitize.
139+
* @returns {string[]} A safe string array.
140+
*/
141+
export function sanitizeParts(parts) {
142+
return Array.isArray(parts) ? parts.map(String) : [];
143+
}

0 commit comments

Comments
 (0)