Repository navigation
fix(amplify-velocity-template): consistent handling of reserved property names in reference resolution #14992
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
Merged
sharonyajain
merged 4 commits into
aws-amplify:dev
from
sarayev:fix/velocity-template-reserved-property-names-v2
Nov 2, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
424a20a
fix(amplify-velocity-template): consistent handling of reserved prope…
sarayev d211dab
refactor(amplify-velocity-template): share reserved-name denylist and…
sharonyajain fd51c3d
refactor(amplify-velocity-template): route property access through gu…
sharonyajain eb1a8dd
fix(amplify-velocity-template): Guard reserved root identifiers and r…
sharonyajain File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
packages/amplify-velocity-template/src/compile/reserved-properties.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| 'use strict'; | ||
|
|
||
| // Prototype-chain names that templates must not read or write, shared by | ||
| // reference resolution (references.js) and #set (set.js). Keeping one list | ||
| // stops the copies drifting apart when a name is added to only one of them. | ||
| var BLOCKED_PROPERTIES = [ | ||
| 'constructor', | ||
| '__proto__', | ||
| 'prototype', | ||
| '__defineGetter__', | ||
| '__defineSetter__', | ||
| '__lookupGetter__', | ||
| '__lookupSetter__', | ||
| ]; | ||
|
|
||
| /** | ||
| * Determine whether a property name traverses the JavaScript prototype | ||
| * chain (e.g. constructor/__proto__/prototype) and must be blocked so it | ||
| * cannot be used to reach the Function constructor and escape the template | ||
| * sandbox. | ||
| * @param {string} name property name being resolved | ||
| * @returns {boolean} true when the name is disallowed | ||
| */ | ||
| function isBlockedProperty(name) { | ||
| return typeof name === 'string' && BLOCKED_PROPERTIES.indexOf(name) !== -1; | ||
| } | ||
|
|
||
| /** | ||
| * Guarded read: route template-controlled property reads through this so a | ||
| * new read path is protected by construction instead of by remembering to | ||
| * call isBlockedProperty. | ||
| * @param {object} base object being read | ||
| * @param {string|number} key property name or index from the template | ||
| * @returns {*} base[key], or undefined when the key is blocked | ||
| */ | ||
| function safeGetProperty(base, key) { | ||
| return isBlockedProperty(key) ? undefined : base[key]; | ||
| } | ||
|
|
||
| /** | ||
| * Guarded write: route template-controlled property writes through this. | ||
| * @param {object} base object being written | ||
| * @param {string|number} key property name from the template | ||
| * @param {*} value value to assign | ||
| * @returns {boolean} true if written, false if the key was blocked | ||
| */ | ||
| function safeSetProperty(base, key, value) { | ||
| if (isBlockedProperty(key)) { | ||
| return false; | ||
| } | ||
| base[key] = value; | ||
| return true; | ||
| } | ||
|
|
||
| module.exports = { | ||
| BLOCKED_PROPERTIES: BLOCKED_PROPERTIES, | ||
| isBlockedProperty: isBlockedProperty, | ||
| safeGetProperty: safeGetProperty, | ||
| safeSetProperty: safeSetProperty, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
127 changes: 127 additions & 0 deletions
127
packages/amplify-velocity-template/tests/reserved-property-names.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| var Velocity = require('../src/velocity'); | ||
| require('should'); | ||
|
|
||
| describe('reserved property names in reference resolution', function () { | ||
| var render = Velocity.render; | ||
|
|
||
| afterEach(function () { | ||
| delete global.__vtlReserved; | ||
| delete Object.prototype.polluted; | ||
| delete Object.prototype.y; | ||
| delete Object.prototype.p; | ||
| }); | ||
|
|
||
| it('does not resolve constructor via getConstructor() accessor', function () { | ||
| render('$!ctx.getConstructor()', { ctx: { name: 'foo' } }).should.eql(''); | ||
| }); | ||
|
|
||
| it('does not invoke a resolved constructor via getConstructor() chained call', function () { | ||
| global.__vtlReserved = false; | ||
| var vm = '$!ctx.getConstructor().getConstructor("globalThis.__vtlReserved = true").call()'; | ||
| render(vm, { ctx: { name: 'foo' } }).should.eql(''); | ||
| global.__vtlReserved.should.eql(false); | ||
| }); | ||
|
|
||
| it('does not resolve constructor via isConstructor() accessor', function () { | ||
| render('$!ctx.isConstructor()', { ctx: { name: 'foo' } }).should.eql(''); | ||
| }); | ||
|
|
||
| it('does not resolve constructor via get("constructor")', function () { | ||
| render('$!ctx.get("constructor")', { ctx: { name: 'foo' } }).should.eql(''); | ||
| }); | ||
|
|
||
| it('does not resolve __proto__ via get("__proto__")', function () { | ||
| render('$!ctx.get("__proto__")', { ctx: { name: 'foo' } }).should.eql(''); | ||
| }); | ||
|
|
||
| it('does not write via #set on __proto__', function () { | ||
| render('#set($ctx.__proto__.polluted = "yes")', { ctx: { name: 'foo' } }); | ||
| (({}).polluted === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not write via #set on constructor.prototype', function () { | ||
| render('#set($ctx.constructor.prototype.y = "yes")', { ctx: { name: 'foo' } }); | ||
| (({}).y === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not write a reserved key via put("constructor", ...)', function () { | ||
| // Discriminating: without the guard, put() assigns an own "constructor" | ||
| // key (hasOwnProperty becomes true). put("__proto__", <string>) would be a | ||
| // JS no-op guarded or not, so "constructor" is used to actually exercise it. | ||
| var ctx = { name: 'foo' }; | ||
| render('$!ctx.put("constructor", "yes")', { ctx: ctx }); | ||
| ctx.hasOwnProperty('constructor').should.eql(false); | ||
| }); | ||
|
|
||
| it('does not write a reserved key via set("constructor", ...)', function () { | ||
| var ctx = { name: 'foo' }; | ||
| render('$!ctx.set("constructor", "yes")', { ctx: ctx }); | ||
| ctx.hasOwnProperty('constructor').should.eql(false); | ||
| }); | ||
|
|
||
| it('does not write a reserved key via setconstructor(...) accessor', function () { | ||
| // Discriminating: the setter path takes id.slice(3) verbatim with no | ||
| // re-casing, so the lowercase "setconstructor" produces the reserved key | ||
| // "constructor" that the guard actually matches. (Capitalized | ||
| // "setConstructor" slices to "Constructor", which is not reserved and never | ||
| // exercises the guard.) Without the guard this writes an own "constructor". | ||
| var ctx = { name: 'foo' }; | ||
| render('$!ctx.setconstructor("yes")', { ctx: ctx }); | ||
| ctx.hasOwnProperty('constructor').should.eql(false); | ||
| }); | ||
|
|
||
| it('renders a rejected setconstructor(...) like other rejected writes', function () { | ||
| render('$ctx.setconstructor("yes")', { ctx: { name: 'foo' } }).should.eql(''); | ||
| }); | ||
|
|
||
| it('does not write through a reserved root identifier via #set', function () { | ||
| render('#set($__proto__.p = "x")', { ctx: { name: 'foo' } }); | ||
| (({}).p === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not assign a reserved root identifier via #set', function () { | ||
| var context = { name: 'foo' }; | ||
| render('#set($__proto__ = {"p": "x"})', context); | ||
| (Object.getPrototypeOf(context) === Object.prototype).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not resolve a reserved root identifier in a reference', function () { | ||
| render('$!constructor.assign($__proto__, {"p":"x"})', { ctx: { name: 'foo' } }); | ||
| (({}).p === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not resolve a reserved root identifier from a #foreach scope', function () { | ||
| render('#foreach($i in [1])$!constructor.assign($__proto__, {"p":"x"})#end', {}); | ||
| (({}).p === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('does not read or delete a reserved key via remove()', function () { | ||
| var vm = '$!ctx.remove("constructor").assign($ctx.remove("__proto__"), {"p":"x"})'; | ||
| render(vm, { ctx: { name: 'foo' } }); | ||
| (({}).p === undefined).should.eql(true); | ||
| }); | ||
|
|
||
| it('still removes a normal key via remove()', function () { | ||
| var ctx = { name: 'foo', k: 'v' }; | ||
| render('$ctx.remove("k")', { ctx: ctx }).should.eql('v'); | ||
| ctx.hasOwnProperty('k').should.eql(false); | ||
| }); | ||
|
|
||
| it('still resolves a normal root identifier, including inside #foreach', function () { | ||
| render('$a#foreach($i in [1])$a$i#end', { a: 'x' }).should.eql('xx1'); | ||
| }); | ||
|
|
||
| it('still resolves a normal getter accessor', function () { | ||
| render('$obj.getName()', { obj: { name: 'foo' } }).should.eql('foo'); | ||
| }); | ||
|
|
||
| it('still writes a normal property via #set', function () { | ||
| render('#set($ctx.bar = "baz")$ctx.bar', { ctx: { name: 'foo' } }).should.eql('baz'); | ||
| }); | ||
|
|
||
| it('still writes a normal property via put()', function () { | ||
| var map = {}; | ||
| render('$!map.put("k", "v")', { map: map }); | ||
| map.k.should.eql('v'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.