From 9bf03f1370b36514508c1e3aca6b9f658cfd26ba Mon Sep 17 00:00:00 2001 From: Eward Date: Wed, 6 May 2026 14:01:47 +0800 Subject: [PATCH 1/4] fix: block prototype-polluting set paths --- src/compile/set.ts | 15 +++++++++++++++ test/set.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/compile/set.ts b/src/compile/set.ts index 1cde584..ee499ae 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,6 +1,13 @@ import type { 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 */ @@ -43,6 +50,10 @@ export class SetValue extends Compile { val = this.config.valueMapper?.(this.getLiteral(ast.equal[1])); } + if (isPollutingKey(ref.id)) { + return; + } + if (!ref.path) { (context as Record)[ref.id] = val; return; @@ -68,6 +79,10 @@ export class SetValue extends Compile { key = ''; } + if (isPollutingKey(key)) { + 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..034e793 100644 --- a/test/set.test.ts +++ b/test/set.test.ts @@ -190,6 +190,31 @@ 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('set with foreach', function () { const tpl = ` #foreach($item in [1..2]) From 7be7881df48df2177f60bbd5a97457e1197590b8 Mon Sep 17 00:00:00 2001 From: Eward Date: Wed, 6 May 2026 14:13:18 +0800 Subject: [PATCH 2/4] fix: validate set paths before assignment values --- src/compile/set.ts | 37 +++++++++++++++++++++++-------------- test/set.test.ts | 14 ++++++++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/compile/set.ts b/src/compile/set.ts index ee499ae..922cad9 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,4 +1,4 @@ -import type { SetAST, VELOCITY_AST } from '../type'; +import type { Attribute, ReferencesAST, SetAST, VELOCITY_AST } from '../type'; import { applyMixins } from '../utils'; import { Compile } from './base-compile'; @@ -27,11 +27,32 @@ export class SetValue extends Compile { // not find local variable, return global context return this.context; } + private getPathKey(exp: Attribute): string { + if (exp.type === 'property') { + return exp.id; + } + + if (exp.type === 'index' && exp.id) { + return String(this.getLiteral(exp.id as VELOCITY_AST)); + } + + return ''; + } + + 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 @@ -50,10 +71,6 @@ export class SetValue extends Compile { val = this.config.valueMapper?.(this.getLiteral(ast.equal[1])); } - if (isPollutingKey(ref.id)) { - return; - } - if (!ref.path) { (context as Record)[ref.id] = val; return; @@ -68,16 +85,8 @@ export class SetValue extends Compile { const len = ref.path ? ref.path.length : 0; ref.path.some((exp, i) => { + const key = this.getPathKey(exp); const isEnd = len === i + 1; - let key: string; - - if (exp.type === 'property') { - key = exp.id; - } else if (exp.type === 'index' && exp.id) { - key = String(this.getLiteral(exp.id as VELOCITY_AST)); - } else { - key = ''; - } if (isPollutingKey(key)) { return true; diff --git a/test/set.test.ts b/test/set.test.ts index 034e793..1d0efb3 100644 --- a/test/set.test.ts +++ b/test/set.test.ts @@ -213,6 +213,20 @@ describe('Set && Expression', function () { 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 () { From 13c374980e697a371e7550ee65c6b7af00289dd1 Mon Sep 17 00:00:00 2001 From: Eward Date: Wed, 6 May 2026 14:21:40 +0800 Subject: [PATCH 3/4] refactor: clarify set path traversal exit --- src/compile/set.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/compile/set.ts b/src/compile/set.ts index 922cad9..6365fca 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -84,17 +84,18 @@ export class SetValue extends Compile { (context as Record)[ref.id] = baseRef; const len = ref.path ? ref.path.length : 0; - ref.path.some((exp, i) => { + for (let i = 0; i < len; i++) { + const exp = ref.path[i]; const key = this.getPathKey(exp); const isEnd = len === i + 1; if (isPollutingKey(key)) { - return true; + return; } if (isEnd) { (baseRef as Record)[key] = val; - return true; + return; } baseRef = (baseRef as Record)[key] as Record; @@ -103,11 +104,9 @@ export class SetValue extends Compile { // #set($a.d.c2 = 2) // but $a.d is undefined, value set fail if (baseRef === undefined) { - return true; + return; } - - return false; - }); + } } } From 09e050dbdb08b2e3e012b008d9507157e28d9a0e Mon Sep 17 00:00:00 2001 From: Eward Date: Wed, 6 May 2026 14:22:10 +0800 Subject: [PATCH 4/4] refactor: keep set path traversal minimal --- src/compile/set.ts | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/compile/set.ts b/src/compile/set.ts index 6365fca..ead769e 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,4 +1,4 @@ -import type { Attribute, ReferencesAST, SetAST, VELOCITY_AST } from '../type'; +import type { ReferencesAST, SetAST, VELOCITY_AST } from '../type'; import { applyMixins } from '../utils'; import { Compile } from './base-compile'; @@ -27,18 +27,6 @@ export class SetValue extends Compile { // not find local variable, return global context return this.context; } - private getPathKey(exp: Attribute): string { - if (exp.type === 'property') { - return exp.id; - } - - if (exp.type === 'index' && exp.id) { - return String(this.getLiteral(exp.id as VELOCITY_AST)); - } - - return ''; - } - private hasPollutingPropertyPath(ref: ReferencesAST): boolean { return Boolean(ref.path?.some((exp) => exp.type === 'property' && isPollutingKey(exp.id))); } @@ -84,18 +72,26 @@ export class SetValue extends Compile { (context as Record)[ref.id] = baseRef; const len = ref.path ? ref.path.length : 0; - for (let i = 0; i < len; i++) { - const exp = ref.path[i]; - const key = this.getPathKey(exp); + ref.path.some((exp, i) => { const isEnd = len === i + 1; + let key: string; + + if (exp.type === 'property') { + key = exp.id; + } else if (exp.type === 'index' && exp.id) { + key = String(this.getLiteral(exp.id as VELOCITY_AST)); + } else { + key = ''; + } if (isPollutingKey(key)) { - return; + // Return true to stop Array.prototype.some traversal without assigning. + return true; } if (isEnd) { (baseRef as Record)[key] = val; - return; + return true; } baseRef = (baseRef as Record)[key] as Record; @@ -104,9 +100,11 @@ export class SetValue extends Compile { // #set($a.d.c2 = 2) // but $a.d is undefined, value set fail if (baseRef === undefined) { - return; + return true; } - } + + return false; + }); } }