Skip to content

Commit d03b6ec

Browse files
committed
feat: access control to prototype properties via whitelist
Disallow access to prototype properties and methods by default. Access to properties is always checked via `Object.prototype.hasOwnProperty.call(parent, propertyName)`. New runtime options: - **allowedProtoMethods**: a string-to-boolean map of property-names that are allowed if they are methods of the parent object. - **allowedProtoProperties**: a string-to-boolean map of property-names that are allowed if they are properties but not methods of the parent object. ```js const template = handlebars.compile('{{aString.trim}}') const result = template({ aString: ' abc ' }) // result is empty, because trim is defined at String prototype ``` ```js const template = handlebars.compile('{{aString.trim}}') const result = template({ aString: ' abc ' }, { allowedProtoMethods: { trim: true } }) // result = 'abc' ``` Implementation details: The method now "container.lookupProperty" handles the prototype-checks and the white-lists. It is used in - JavaScriptCompiler#nameLookup - The "lookup"-helper (passed to all helpers as "options.lookupProperty") - The "lookup" function at the container, which is used for recursive lookups in "compat" mode Compatibility: - **Old precompiled templates work with new runtimes**: The "options.lookupPropery"-function is passed to the helper by a wrapper, not by the compiled templated. - **New templates work with old runtimes**: The template contains a function that is used as fallback if the "lookupProperty"-function cannot be found at the container. However, the runtime-options "allowedProtoProperties" and "allowedProtoMethods" only work with the newest runtime. BREAKING CHANGE: - access to prototype properties is forbidden completely by default
1 parent 164b7ff commit d03b6ec

12 files changed

+354
-73
lines changed

lib/handlebars/compiler/javascript-compiler.js

+31-23
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { COMPILER_REVISION, REVISION_CHANGES } from '../base';
22
import Exception from '../exception';
33
import { isArray } from '../utils';
44
import CodeGen from './code-gen';
5-
import { dangerousPropertyRegex } from '../helpers/lookup';
65

76
function Literal(value) {
87
this.value = value;
@@ -13,27 +12,8 @@ function JavaScriptCompiler() {}
1312
JavaScriptCompiler.prototype = {
1413
// PUBLIC API: You can override these methods in a subclass to provide
1514
// alternative compiled forms for name lookup and buffering semantics
16-
nameLookup: function(parent, name /* , type*/) {
17-
if (dangerousPropertyRegex.test(name)) {
18-
const isEnumerable = [
19-
this.aliasable('container.propertyIsEnumerable'),
20-
'.call(',
21-
parent,
22-
',',
23-
JSON.stringify(name),
24-
')'
25-
];
26-
return ['(', isEnumerable, '?', _actualLookup(), ' : undefined)'];
27-
}
28-
return _actualLookup();
29-
30-
function _actualLookup() {
31-
if (JavaScriptCompiler.isValidJavaScriptVariableName(name)) {
32-
return [parent, '.', name];
33-
} else {
34-
return [parent, '[', JSON.stringify(name), ']'];
35-
}
36-
}
15+
nameLookup: function(parent, name /*, type */) {
16+
return this.internalNameLookup(parent, name);
3717
},
3818
depthedLookup: function(name) {
3919
return [this.aliasable('container.lookup'), '(depths, "', name, '")'];
@@ -69,6 +49,12 @@ JavaScriptCompiler.prototype = {
6949
return this.quotedString('');
7050
},
7151
// END PUBLIC API
52+
internalNameLookup: function(parent, name) {
53+
this.lookupPropertyFunctionIsUsed = true;
54+
return ['lookupProperty(', parent, ',', JSON.stringify(name), ')'];
55+
},
56+
57+
lookupPropertyFunctionIsUsed: false,
7258

7359
compile: function(environment, options, context, asObject) {
7460
this.environment = environment;
@@ -131,7 +117,11 @@ JavaScriptCompiler.prototype = {
131117
if (!this.decorators.isEmpty()) {
132118
this.useDecorators = true;
133119

134-
this.decorators.prepend('var decorators = container.decorators;\n');
120+
this.decorators.prepend([
121+
'var decorators = container.decorators, ',
122+
this.lookupPropertyFunctionVarDeclaration(),
123+
';\n'
124+
]);
135125
this.decorators.push('return fn;');
136126

137127
if (asObject) {
@@ -248,6 +238,10 @@ JavaScriptCompiler.prototype = {
248238
}
249239
});
250240

241+
if (this.lookupPropertyFunctionIsUsed) {
242+
varDeclarations += ', ' + this.lookupPropertyFunctionVarDeclaration();
243+
}
244+
251245
let params = ['container', 'depth0', 'helpers', 'partials', 'data'];
252246

253247
if (this.useBlockParams || this.useDepths) {
@@ -335,6 +329,17 @@ JavaScriptCompiler.prototype = {
335329
return this.source.merge();
336330
},
337331

332+
lookupPropertyFunctionVarDeclaration: function() {
333+
return `
334+
lookupProperty = container.lookupProperty || function(parent, propertyName) {
335+
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
336+
return parent[propertyName];
337+
}
338+
return undefined
339+
}
340+
`.trim();
341+
},
342+
338343
// [blockValue]
339344
//
340345
// On stack, before: hash, inverse, program, value
@@ -1241,6 +1246,9 @@ JavaScriptCompiler.prototype = {
12411246
}
12421247
})();
12431248

1249+
/**
1250+
* @deprecated May be removed in the next major version
1251+
*/
12441252
JavaScriptCompiler.isValidJavaScriptVariableName = function(name) {
12451253
return (
12461254
!JavaScriptCompiler.RESERVED_WORDS[name] &&

lib/handlebars/helpers/lookup.js

+3-10
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
export const dangerousPropertyRegex = /^(constructor|__defineGetter__|__defineSetter__|__lookupGetter__|__proto__)$/;
2-
31
export default function(instance) {
4-
instance.registerHelper('lookup', function(obj, field) {
2+
instance.registerHelper('lookup', function(obj, field, options) {
53
if (!obj) {
4+
// Note for 5.0: Change to "obj == null" in 5.0
65
return obj;
76
}
8-
if (
9-
dangerousPropertyRegex.test(String(field)) &&
10-
!Object.prototype.propertyIsEnumerable.call(obj, field)
11-
) {
12-
return undefined;
13-
}
14-
return obj[field];
7+
return options.lookupProperty(obj, field);
158
});
169
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { extend } from '../utils';
2+
3+
/**
4+
* Create a new object with "null"-prototype to avoid truthy results on prototype properties.
5+
* The resulting object can be used with "object[property]" to check if a property exists
6+
* @param {...object} sources a varargs parameter of source objects that will be merged
7+
* @returns {object}
8+
*/
9+
export function createNewLookupObject(...sources) {
10+
return extend(Object.create(null), ...sources);
11+
}

lib/handlebars/internal/wrapHelper.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function wrapHelper(helper, transformOptionsFn) {
2+
let wrapper = function(/* dynamic arguments */) {
3+
const options = arguments[arguments.length - 1];
4+
arguments[arguments.length - 1] = transformOptionsFn(options);
5+
return helper.apply(this, arguments);
6+
};
7+
return wrapper;
8+
}

lib/handlebars/runtime.js

+51-5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
REVISION_CHANGES
88
} from './base';
99
import { moveHelperToHooks } from './helpers';
10+
import { wrapHelper } from './internal/wrapHelper';
11+
import { createNewLookupObject } from './internal/createNewLookupObject';
1012

1113
export function checkRevision(compilerInfo) {
1214
const compilerRevision = (compilerInfo && compilerInfo[0]) || 1,
@@ -69,13 +71,17 @@ export function template(templateSpec, env) {
6971
}
7072
partial = env.VM.resolvePartial.call(this, partial, context, options);
7173

72-
let optionsWithHooks = Utils.extend({}, options, { hooks: this.hooks });
74+
let extendedOptions = Utils.extend({}, options, {
75+
hooks: this.hooks,
76+
allowedProtoMethods: this.allowedProtoMethods,
77+
allowedProtoProperties: this.allowedProtoProperties
78+
});
7379

7480
let result = env.VM.invokePartial.call(
7581
this,
7682
partial,
7783
context,
78-
optionsWithHooks
84+
extendedOptions
7985
);
8086

8187
if (result == null && env.compile) {
@@ -84,7 +90,7 @@ export function template(templateSpec, env) {
8490
templateSpec.compilerOptions,
8591
env
8692
);
87-
result = options.partials[options.name](context, optionsWithHooks);
93+
result = options.partials[options.name](context, extendedOptions);
8894
}
8995
if (result != null) {
9096
if (options.indent) {
@@ -118,10 +124,26 @@ export function template(templateSpec, env) {
118124
}
119125
return obj[name];
120126
},
127+
lookupProperty: function(parent, propertyName) {
128+
let result = parent[propertyName];
129+
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
130+
return result;
131+
}
132+
const whitelist =
133+
typeof result === 'function'
134+
? container.allowedProtoMethods
135+
: container.allowedProtoProperties;
136+
137+
if (whitelist[propertyName] === true) {
138+
return result;
139+
}
140+
return undefined;
141+
},
121142
lookup: function(depths, name) {
122143
const len = depths.length;
123144
for (let i = 0; i < len; i++) {
124-
if (depths[i] && depths[i][name] != null) {
145+
let result = depths[i] && container.lookupProperty(depths[i], name);
146+
if (result != null) {
125147
return depths[i][name];
126148
}
127149
}
@@ -229,7 +251,9 @@ export function template(templateSpec, env) {
229251

230252
ret._setup = function(options) {
231253
if (!options.partial) {
232-
container.helpers = Utils.extend({}, env.helpers, options.helpers);
254+
let mergedHelpers = Utils.extend({}, env.helpers, options.helpers);
255+
wrapHelpersToPassLookupProperty(mergedHelpers, container);
256+
container.helpers = mergedHelpers;
233257

234258
if (templateSpec.usePartial) {
235259
// Use mergeIfNeeded here to prevent compiling global partials multiple times
@@ -247,13 +271,21 @@ export function template(templateSpec, env) {
247271
}
248272

249273
container.hooks = {};
274+
container.allowedProtoProperties = createNewLookupObject(
275+
options.allowedProtoProperties
276+
);
277+
container.allowedProtoMethods = createNewLookupObject(
278+
options.allowedProtoMethods
279+
);
250280

251281
let keepHelperInHelpers =
252282
options.allowCallsToHelperMissing ||
253283
templateWasPrecompiledWithCompilerV7;
254284
moveHelperToHooks(container, 'helperMissing', keepHelperInHelpers);
255285
moveHelperToHooks(container, 'blockHelperMissing', keepHelperInHelpers);
256286
} else {
287+
container.allowedProtoProperties = options.allowedProtoProperties;
288+
container.allowedProtoMethods = options.allowedProtoMethods;
257289
container.helpers = options.helpers;
258290
container.partials = options.partials;
259291
container.decorators = options.decorators;
@@ -405,3 +437,17 @@ function executeDecorators(fn, prog, container, depths, data, blockParams) {
405437
}
406438
return prog;
407439
}
440+
441+
function wrapHelpersToPassLookupProperty(mergedHelpers, container) {
442+
Object.keys(mergedHelpers).forEach(helperName => {
443+
let helper = mergedHelpers[helperName];
444+
mergedHelpers[helperName] = passLookupPropertyOption(helper, container);
445+
});
446+
}
447+
448+
function passLookupPropertyOption(helper, container) {
449+
const lookupProperty = container.lookupProperty;
450+
return wrapHelper(helper, options => {
451+
return Utils.extend({ lookupProperty }, options);
452+
});
453+
}

spec/blocks.js

+1
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ describe('blocks', function() {
275275
'Goodbye cruel OMG!'
276276
);
277277
});
278+
278279
it('block with deep recursive pathed lookup', function() {
279280
var string =
280281
'{{#outer}}Goodbye {{#inner}}cruel {{omg.yes}}{{/inner}}{{/outer}}';

spec/helpers.js

+11
Original file line numberDiff line numberDiff line change
@@ -1328,4 +1328,15 @@ describe('helpers', function() {
13281328
);
13291329
});
13301330
});
1331+
1332+
describe('the lookupProperty-option', function() {
1333+
it('should be passed to custom helpers', function() {
1334+
expectTemplate('{{testHelper}}')
1335+
.withHelper('testHelper', function testHelper(options) {
1336+
return options.lookupProperty(this, 'testProperty');
1337+
})
1338+
.withInput({ testProperty: 'abc' })
1339+
.toCompileTo('abc');
1340+
});
1341+
});
13311342
});

spec/javascript-compiler.js

+25
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,29 @@ describe('javascript-compiler api', function() {
8181
shouldCompileTo('{{foo}}', { foo: 'food' }, 'food_foo');
8282
});
8383
});
84+
85+
describe('#isValidJavaScriptVariableName', function() {
86+
// It is there and accessible and could be used by someone. That's why we don't remove it
87+
// it 4.x. But if we keep it, we add a test
88+
// This test should not encourage you to use the function. It is not needed any more
89+
// and might be removed in 5.0
90+
['test', 'abc123', 'abc_123'].forEach(function(validVariableName) {
91+
it("should return true for '" + validVariableName + "'", function() {
92+
expect(
93+
handlebarsEnv.JavaScriptCompiler.isValidJavaScriptVariableName(
94+
validVariableName
95+
)
96+
).to.be.true();
97+
});
98+
});
99+
[('123test', 'abc()', 'abc.cde')].forEach(function(invalidVariableName) {
100+
it("should return true for '" + invalidVariableName + "'", function() {
101+
expect(
102+
handlebarsEnv.JavaScriptCompiler.isValidJavaScriptVariableName(
103+
invalidVariableName
104+
)
105+
).to.be.false();
106+
});
107+
});
108+
});
84109
});

spec/regressions.js

+48-6
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ describe('Regressions', function() {
390390
it('should compile and execute templates', function() {
391391
var newHandlebarsInstance = Handlebars.create();
392392

393-
registerTemplate(newHandlebarsInstance);
393+
registerTemplate(newHandlebarsInstance, compiledTemplateVersion7());
394394
newHandlebarsInstance.registerHelper('loud', function(value) {
395395
return value.toUpperCase();
396396
});
@@ -405,19 +405,39 @@ describe('Regressions', function() {
405405

406406
shouldThrow(
407407
function() {
408-
registerTemplate(newHandlebarsInstance);
408+
registerTemplate(newHandlebarsInstance, compiledTemplateVersion7());
409409
newHandlebarsInstance.templates['test.hbs']({});
410410
},
411411
Handlebars.Exception,
412412
'Missing helper: "loud"'
413413
);
414414
});
415415

416-
// This is a only slightly modified precompiled templated from compiled with 4.2.1
417-
function registerTemplate(Handlebars) {
416+
it('should pass "options.lookupProperty" to "lookup"-helper, even with old templates', function() {
417+
var newHandlebarsInstance = Handlebars.create();
418+
registerTemplate(
419+
newHandlebarsInstance,
420+
compiledTemplateVersion7_usingLookupHelper()
421+
);
422+
423+
newHandlebarsInstance.templates['test.hbs']({});
424+
425+
expect(
426+
newHandlebarsInstance.templates['test.hbs']({
427+
property: 'a',
428+
test: { a: 'b' }
429+
})
430+
).to.equal('b');
431+
});
432+
433+
function registerTemplate(Handlebars, compileTemplate) {
418434
var template = Handlebars.template,
419435
templates = (Handlebars.templates = Handlebars.templates || {});
420-
templates['test.hbs'] = template({
436+
templates['test.hbs'] = template(compileTemplate);
437+
}
438+
439+
function compiledTemplateVersion7() {
440+
return {
421441
compiler: [7, '>= 4.0.0'],
422442
main: function(container, depth0, helpers, partials, data) {
423443
return (
@@ -435,7 +455,29 @@ describe('Regressions', function() {
435455
);
436456
},
437457
useData: true
438-
});
458+
};
459+
}
460+
461+
function compiledTemplateVersion7_usingLookupHelper() {
462+
// This is the compiled version of "{{lookup test property}}"
463+
return {
464+
compiler: [7, '>= 4.0.0'],
465+
main: function(container, depth0, helpers, partials, data) {
466+
return container.escapeExpression(
467+
helpers.lookup.call(
468+
depth0 != null ? depth0 : container.nullContext || {},
469+
depth0 != null ? depth0.test : depth0,
470+
depth0 != null ? depth0.property : depth0,
471+
{
472+
name: 'lookup',
473+
hash: {},
474+
data: data
475+
}
476+
)
477+
);
478+
},
479+
useData: true
480+
};
439481
}
440482
});
441483

0 commit comments

Comments
 (0)