diff --git a/src/compile/set.ts b/src/compile/set.ts index 1cde584..ead769e 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,6 +1,13 @@ -import type { SetAST, VELOCITY_AST } from '../type'; +import type { ReferencesAST, SetAST, VELOCITY_AST } from '../type'; import { applyMixins } from '../utils'; import { Compile } from './base-compile'; + +const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isPollutingKey(key: string): boolean { + return POLLUTING_KEYS.has(key); +} + /** * #set value */ @@ -20,11 +27,20 @@ export class SetValue extends Compile { // not find local variable, return global context return this.context; } + private hasPollutingPropertyPath(ref: ReferencesAST): boolean { + return Boolean(ref.path?.some((exp) => exp.type === 'property' && isPollutingKey(exp.id))); + } + /** * parse #set */ setValue(ast: SetAST): void { const ref = ast.equal[0]; + + if (isPollutingKey(ref.id) || this.hasPollutingPropertyPath(ref)) { + return; + } + let context = this.getContext(ref.id) as object; // @see #25 @@ -68,6 +84,11 @@ export class SetValue extends Compile { key = ''; } + if (isPollutingKey(key)) { + // Return true to stop Array.prototype.some traversal without assigning. + return true; + } + if (isEnd) { (baseRef as Record)[key] = val; return true; diff --git a/test/set.test.ts b/test/set.test.ts index 866d8d6..1d0efb3 100644 --- a/test/set.test.ts +++ b/test/set.test.ts @@ -190,6 +190,45 @@ describe('Set && Expression', function () { }); }); + describe('prototype pollution protection', function () { + afterEach(function () { + delete (Object.prototype as Record).polluted; + delete (Object.prototype as Record).isAdmin; + }); + + it('does not set values through __proto__ references', function () { + render('#set($__proto__.polluted = "hacked")', {}); + + assert.equal(({} as Record).polluted, undefined); + }); + + it('does not set values through constructor.prototype paths', function () { + render('#set($target.constructor.prototype.isAdmin = true)', { target: {} }); + + assert.equal(({} as Record).isAdmin, undefined); + }); + + it('does not set prototype-polluting index keys', function () { + render('#set($target["__proto__"].polluted = "hacked")', { target: {} }); + + assert.equal(({} as Record).polluted, undefined); + }); + + it('does not evaluate assigned values for prototype-polluting paths', function () { + let evaluated = false; + + render('#set($__proto__.polluted = $markEvaluated())', { + markEvaluated() { + evaluated = true; + return 'hacked'; + }, + }); + + assert.equal(evaluated, false); + assert.equal(({} as Record).polluted, undefined); + }); + }); + it('set with foreach', function () { const tpl = ` #foreach($item in [1..2])