Skip to content

feat: add allowedVariables compiler option #2074

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 4.x
Choose a base branch
from
Open
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
26 changes: 26 additions & 0 deletions lib/handlebars/compiler/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ Compiler.prototype = {
name = path.parts[0],
isBlock = program != null || inverse != null;

this.validateAllowedVariables(name, sexpr);

this.opcode('getContext', path.depth);

this.opcode('pushProgram', program);
Expand All @@ -256,6 +258,10 @@ Compiler.prototype = {

simpleSexpr: function(sexpr) {
let path = sexpr.path;
let name = path.parts[0];

this.validateAllowedVariables(name, sexpr);

path.strict = true;
this.accept(path);
this.opcode('resolvePossibleLambda');
Expand Down Expand Up @@ -304,6 +310,12 @@ Compiler.prototype = {
this.options.data = true;
this.opcode('lookupData', path.depth, path.parts, path.strict);
} else {
// Validate allowed variables for paths that aren't part of helper/expression processing
// (those would have path.strict set)
if (!path.strict) {
this.validateAllowedVariables(name, path);
}

this.opcode(
'lookupOnContext',
path.parts,
Expand Down Expand Up @@ -480,6 +492,20 @@ Compiler.prototype = {
return [depth, param];
}
}
},

validateAllowedVariables: function(name, node) {
if (
this.options.allowedVariables &&
name &&
name !== '.' &&
name !== '..' &&
!this.blockParamIndex(name)
) {
if (this.options.allowedVariables.indexOf(name) === -1) {
throw new Exception('Variable "' + name + '" is not allowed', node);
}
}
}
};

Expand Down
300 changes: 300 additions & 0 deletions spec/allowed-variables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
describe('allowedVariables', function() {
it('should compile when all variables are allowed', function() {
expectTemplate('{{foo}} {{bar}}')
.withInput({ foo: 'a', bar: 'b' })
.withCompileOptions({ allowedVariables: ['foo', 'bar'] })
.toCompileTo('a b');
});

it('should throw if a variable is not allowed', function() {
expectTemplate('{{foo}} {{baz}}')
.withInput({ foo: 'a', baz: 'c' })
.withCompileOptions({ allowedVariables: ['foo'] })
.toThrow(/baz/);
});

it('should not require known helpers to be allowed', function() {
expectTemplate('{{#if var}}ok{{/if}}')
.withInput({ var: true })
.withCompileOptions({
allowedVariables: ['var']
})
.toCompileTo('ok');
});

// Test cases for object property access
describe('object variables', function() {
it('should allow access to object properties when root variable is allowed', function() {
expectTemplate('{{user.name}} {{user.email}}')
.withInput({
user: { name: 'John', email: 'john@example.com' }
})
.withCompileOptions({ allowedVariables: ['user'] })
.toCompileTo('John john@example.com');
});

it('should throw if root object variable is not allowed', function() {
expectTemplate('{{user.name}}')
.withInput({
user: { name: 'John' }
})
.withCompileOptions({ allowedVariables: [] })
.toThrow(/user/);
});

it('should allow deep nested property access when root is allowed', function() {
expectTemplate('{{person.address.city}}')
.withInput({
person: {
address: {
city: 'New York'
}
}
})
.withCompileOptions({ allowedVariables: ['person'] })
.toCompileTo('New York');
});

it('should allow mixed simple and object variables', function() {
expectTemplate('{{title}}: {{user.name}} ({{age}})')
.withInput({
title: 'User',
user: { name: 'John' },
age: 30
})
.withCompileOptions({ allowedVariables: ['title', 'user', 'age'] })
.toCompileTo('User: John (30)');
});

it('should throw if any root variable is not allowed in mixed access', function() {
expectTemplate('{{title}}: {{user.name}} ({{age}})')
.withInput({
title: 'User',
user: { name: 'John' },
age: 30
})
.withCompileOptions({ allowedVariables: ['title', 'user'] })
.toThrow(/age/);
});

it('should work with array access notation', function() {
expectTemplate('{{users.[0].name}}')
.withInput({
users: [{ name: 'Alice' }, { name: 'Bob' }]
})
.withCompileOptions({ allowedVariables: ['users'] })
.toCompileTo('Alice');
});
});

// Test cases for comprehensive coverage
describe('decorators', function() {
it('should not require decorator names to be in allowedVariables', function() {
expectTemplate('{{#helper}}{{*decorator}}{{/helper}}')
.withHelper('helper', function(options) {
return options.fn.run;
})
.withDecorator('decorator', function(fn) {
fn.run = 'success';
return fn;
})
.withCompileOptions({
allowedVariables: [],
knownHelpers: { helper: true }
})
.toCompileTo('success');
});

it('should require decorator parameters to be in allowedVariables', function() {
expectTemplate('{{#helper}}{{*decorator param1}}{{/helper}}')
.withHelper('helper', function(options) {
return options.fn.result || 'default';
})
.withDecorator('decorator', function(fn, props, container, options) {
fn.result = options.args[0];
return fn;
})
.withInput({ param1: 'success' })
.withCompileOptions({
allowedVariables: ['param1'],
knownHelpers: { helper: true }
})
.toCompileTo('success');
});

it('should throw if decorator parameters are not allowed', function() {
expectTemplate('{{#helper}}{{*decorator forbiddenParam}}{{/helper}}')
.withHelper('helper', function(options) {
return options.fn.result || 'default';
})
.withDecorator('decorator', function(fn, props, container, options) {
fn.result = options.args[0];
return fn;
})
.withInput({ forbiddenParam: 'fail' })
.withCompileOptions({
allowedVariables: [],
knownHelpers: { helper: true }
})
.toThrow(/forbiddenParam/);
});

it('should handle block decorators', function() {
expectTemplate(
'{{#helper}}{{#*decorator}}content{{/decorator}}{{/helper}}'
)
.withHelper('helper', function(options) {
return options.fn.result || 'default';
})
.withDecorator('decorator', function(fn, props, container, options) {
fn.result = options.fn();
return fn;
})
.withCompileOptions({
allowedVariables: [],
knownHelpers: { helper: true }
})
.toCompileTo('content');
});

it('should allow object properties in decorator parameters', function() {
expectTemplate('{{#helper}}{{*decorator config.value}}{{/helper}}')
.withHelper('helper', function(options) {
return options.fn.result || 'default';
})
.withDecorator('decorator', function(fn, props, container, options) {
fn.result = options.args[0];
return fn;
})
.withInput({ config: { value: 'configured' } })
.withCompileOptions({
allowedVariables: ['config'],
knownHelpers: { helper: true }
})
.toCompileTo('configured');
});
});

describe('partial blocks', function() {
it('should handle partial block parameters', function() {
expectTemplate(
'{{#> partialBlock param1}}default content{{/partialBlock}}'
)
.withPartial('partialBlock', '{{> @partial-block}}')
.withInput({ param1: 'success' })
.withCompileOptions({ allowedVariables: ['param1'] })
.toCompileTo('default content');
});

it('should throw if partial block parameters are not allowed', function() {
expectTemplate(
'{{#> partialBlock forbiddenParam}}default content{{/partialBlock}}'
)
.withPartial('partialBlock', '{{> @partial-block}}')
.withInput({ forbiddenParam: 'fail' })
.withCompileOptions({ allowedVariables: [] })
.toThrow(/forbiddenParam/);
});

it('should handle partial blocks with programs', function() {
expectTemplate('{{#> partialBlock}}{{programVar}}{{/partialBlock}}')
.withPartial('partialBlock', '{{> @partial-block}}')
.withInput({ programVar: 'success' })
.withCompileOptions({ allowedVariables: ['programVar'] })
.toCompileTo('success');
});

it('should allow object properties in partial block parameters', function() {
expectTemplate(
'{{#> partialBlock settings.theme}}default content{{/partialBlock}}'
)
.withPartial('partialBlock', '{{> @partial-block}}')
.withInput({ settings: { theme: 'dark' } })
.withCompileOptions({ allowedVariables: ['settings'] })
.toCompileTo('default content');
});

it('should handle object properties in partial block programs', function() {
expectTemplate(
'{{#> partialBlock}}{{user.profile.displayName}}{{/partialBlock}}'
)
.withPartial('partialBlock', '{{> @partial-block}}')
.withInput({ user: { profile: { displayName: 'John Doe' } } })
.withCompileOptions({ allowedVariables: ['user'] })
.toCompileTo('John Doe');
});
});

// Test cases for built-in helpers
describe('built-in helpers', function() {
it('should allow if/else conditions with allowed variables', function() {
expectTemplate('{{#if isVisible}}{{content}}{{else}}{{fallback}}{{/if}}')
.withInput({
isVisible: true,
content: 'visible content',
fallback: 'hidden content'
})
.withCompileOptions({
allowedVariables: ['isVisible', 'content', 'fallback']
})
.toCompileTo('visible content');
});

it('should throw if variables in if condition are not allowed', function() {
expectTemplate('{{#if isVisible}}{{content}}{{/if}}')
.withInput({
isVisible: true,
content: 'visible content'
})
.withCompileOptions({ allowedVariables: ['content'] })
.toThrow(/isVisible/);
});

it('should throw if variables in if body are not allowed', function() {
expectTemplate('{{#if isVisible}}{{secretContent}}{{/if}}')
.withInput({
isVisible: true,
secretContent: 'secret'
})
.withCompileOptions({ allowedVariables: ['isVisible'] })
.toThrow(/secretContent/);
});

it('should throw if variables in else block are not allowed', function() {
expectTemplate(
'{{#if isVisible}}allowed{{else}}{{forbiddenContent}}{{/if}}'
)
.withInput({
isVisible: false,
forbiddenContent: 'forbidden'
})
.withCompileOptions({ allowedVariables: ['isVisible'] })
.toThrow(/forbiddenContent/);
});

it('should work with unless helper', function() {
expectTemplate('{{#unless isHidden}}{{content}}{{/unless}}')
.withInput({
isHidden: false,
content: 'visible content'
})
.withCompileOptions({ allowedVariables: ['isHidden', 'content'] })
.toCompileTo('visible content');
});

it('should work with nested if conditions', function() {
expectTemplate(
'{{#if user.isActive}}{{#if user.hasAccess}}{{user.name}}{{/if}}{{/if}}'
)
.withInput({
user: {
isActive: true,
hasAccess: true,
name: 'John'
}
})
.withCompileOptions({ allowedVariables: ['user'] })
.toCompileTo('John');
});
});
});
1 change: 1 addition & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ interface CompileOptions {
preventIndent?: boolean;
ignoreStandalone?: boolean;
explicitPartialContext?: boolean;
allowedVariables?: string[];
}

type KnownHelpers = {
Expand Down