From f8e47a6c4607249b9c967d3a1ced959b4dd64dba Mon Sep 17 00:00:00 2001 From: Eward Date: Tue, 14 Jul 2026 10:59:00 +0800 Subject: [PATCH 1/2] fix: harden prototype path guards --- src/compile/prototype-guard.ts | 37 ++++++++++++++++++++++++ src/compile/references.ts | 24 ++++++++++++++-- src/compile/set.ts | 39 ++++---------------------- test/references.test.ts | 51 ++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 src/compile/prototype-guard.ts diff --git a/src/compile/prototype-guard.ts b/src/compile/prototype-guard.ts new file mode 100644 index 0000000..a4bc6ed --- /dev/null +++ b/src/compile/prototype-guard.ts @@ -0,0 +1,37 @@ +const PROTO_KEY = '__proto__'; +const PROTOTYPE_CHAIN_KEYS = new Set(['constructor', 'prototype']); + +export function hasOwnProperty(baseRef: unknown, key: string): boolean { + return ( + (typeof baseRef === 'object' || typeof baseRef === 'function') && + baseRef !== null && + Object.prototype.hasOwnProperty.call(baseRef, key) + ); +} + +export function isBlockedPrototypeKey(baseRef: unknown, key: string, isEnd = false): boolean { + if (key === PROTO_KEY) { + return true; + } + + // Function.prototype is a shared prototype object; reading or assigning + // through it can expose or affect objects created by that constructor. + if (key === 'prototype' && typeof baseRef === 'function') { + return true; + } + + // `constructor` and `prototype` are valid own data fields. They are only + // dangerous when traversal would fall through to inherited prototype-chain + // properties such as Object.constructor.prototype. + return !isEnd && PROTOTYPE_CHAIN_KEYS.has(key) && !hasOwnProperty(baseRef, key); +} + +export function hasBlockedUnknownPrototypePath(keys: string[], startIndex: number): boolean { + // If an earlier safe segment is missing, the RHS may create it. Before + // evaluating RHS we can only allow the path when the remaining unresolved + // suffix does not contain keys that could later traverse prototypes. + return keys.slice(startIndex).some((key, i) => { + const isEnd = startIndex + i === keys.length - 1; + return key === PROTO_KEY || (!isEnd && PROTOTYPE_CHAIN_KEYS.has(key)); + }); +} diff --git a/src/compile/references.ts b/src/compile/references.ts index ab32a30..5d1eee4 100644 --- a/src/compile/references.ts +++ b/src/compile/references.ts @@ -3,6 +3,7 @@ import { getRefText } from '../helper'; import { Compile } from './base-compile'; import { Attribute, IndexAttribute, Method, ReferencesAST, VELOCITY_AST } from '../type'; import { applyMixins, convert } from '../utils'; +import { hasOwnProperty, isBlockedPrototypeKey } from './prototype-guard'; const debug = debugBase('velocity'); const posUnknown = { first_line: 'unknown', first_column: 'unknown' }; @@ -31,12 +32,12 @@ export class References extends Compile { const isSilent = this.silence || ast.leader === '$!'; const isFunction = ast.args !== undefined; const context = this.context; - let ret = context[ast.id]; + let ret = this.isBlockedPathKey(context, ast.id) ? undefined : context[ast.id]; const local = this.getLocal(ast); const text = getRefText(ast); - if (text in context) { + if (hasOwnProperty(context, text)) { return ast.prue && escape ? convert(context[text]) : context[text]; } @@ -75,7 +76,7 @@ export class References extends Compile { let ret: unknown = false; const isLocal = this.conditions.some((contextId: string) => { - const hasData = id in (local[contextId] as object); + const hasData = hasOwnProperty(local[contextId], id); if (hasData) { const contextObj = local[contextId] as Record; ret = contextObj[id]; @@ -110,11 +111,19 @@ export class References extends Compile { } if (property.type === 'property') { + if (this.isBlockedPathKey(baseRef, property.id)) { + return undefined; + } + return (baseRef as Record)[property.id]; } return this.getPropIndex(property, baseRef as object); } + private isBlockedPathKey(baseRef: unknown, key: string): boolean { + return isBlockedPrototypeKey(baseRef, key); + } + /** * $foo.bar[1] index evaluation * @private @@ -122,6 +131,10 @@ export class References extends Compile { getPropIndex(property: IndexAttribute, baseRef: object) { const ast = property.id; const key = ast.type === 'references' ? this.getReferences(ast) : ast.value; + if (this.isBlockedPathKey(baseRef, String(key))) { + return undefined; + } + return (baseRef as Record)[key as string]; } @@ -131,6 +144,11 @@ export class References extends Compile { // eslint-disable-next-line @typescript-eslint/no-explicit-any getPropMethod(property: Method, baseRef: any, ast: ReferencesAST) { const id = property.id; + if (this.isBlockedPathKey(baseRef, id)) { + this._throw(ast, property, 'TypeError'); + return; + } + let ret = baseRef[id]; const args = (property.args || []).map((exp) => this.getLiteral(exp as VELOCITY_AST)) || []; diff --git a/src/compile/set.ts b/src/compile/set.ts index f29673f..f17c791 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,9 +1,7 @@ import type { Attribute, ReferencesAST, SetAST, VELOCITY_AST } from '../type'; import { applyMixins } from '../utils'; import { Compile } from './base-compile'; - -const PROTO_KEY = '__proto__'; -const PROTOTYPE_CHAIN_KEYS = new Set(['constructor', 'prototype']); +import { hasBlockedUnknownPrototypePath, isBlockedPrototypeKey } from './prototype-guard'; type SetPathTarget = { blocked: boolean; @@ -13,14 +11,6 @@ type SetPathTarget = { key?: string; }; -function hasOwnProperty(baseRef: unknown, key: string): boolean { - return ( - (typeof baseRef === 'object' || typeof baseRef === 'function') && - baseRef !== null && - Object.prototype.hasOwnProperty.call(baseRef, key) - ); -} - /** * #set value */ @@ -57,38 +47,19 @@ export class SetValue extends Compile { } private isBlockedPathKey(baseRef: unknown, key: string, isEnd: boolean): boolean { - if (key === PROTO_KEY) { - return true; - } - - // Function.prototype is a shared prototype object; assigning through it can - // affect every object created by that constructor. - if (key === 'prototype' && typeof baseRef === 'function') { - return true; - } - - // `constructor` and `prototype` are valid own data fields. They are only - // dangerous when traversal would fall through to inherited prototype-chain - // properties such as Object.constructor.prototype. - return !isEnd && PROTOTYPE_CHAIN_KEYS.has(key) && !hasOwnProperty(baseRef, key); + return isBlockedPrototypeKey(baseRef, key, isEnd); } private hasBlockedUnknownPath(keys: string[], startIndex: number): boolean { - // If an earlier safe segment is missing, the RHS may create it. Before - // evaluating RHS we can only allow the path when the remaining unresolved - // suffix does not contain keys that could later traverse prototypes. - return keys.slice(startIndex).some((key, i) => { - const isEnd = startIndex + i === keys.length - 1; - return key === PROTO_KEY || (!isEnd && PROTOTYPE_CHAIN_KEYS.has(key)); - }); + return hasBlockedUnknownPrototypePath(keys, startIndex); } private resolveSetPath(context: object, ref: ReferencesAST, pathKeys: string[]): SetPathTarget { if (pathKeys.length === 0) { - return { blocked: ref.id === PROTO_KEY }; + return { blocked: this.isBlockedPathKey(context, ref.id, true) }; } - if (ref.id === PROTO_KEY) { + if (this.isBlockedPathKey(context, ref.id, false)) { return { blocked: true }; } diff --git a/test/references.test.ts b/test/references.test.ts index 28cbaf2..2e076a1 100644 --- a/test/references.test.ts +++ b/test/references.test.ts @@ -259,6 +259,57 @@ describe('References', function () { assert.equal('123', ret.trim()); }); + describe('prototype chain protection', function () { + it('does not expose dangerous top-level prototype references', function () { + (Object.prototype as Record).polluted = 'hacked'; + + try { + assert.equal('$__proto__.polluted', render('$__proto__.polluted', {}).trim()); + assert.equal('$constructor.name', render('$constructor.name', {}).trim()); + } finally { + delete (Object.prototype as Record).polluted; + } + }); + + it('does not expose __proto__ references', function () { + assert.equal( + '$foo.__proto__.polluted', + render('$foo.__proto__.polluted', { foo: {}, polluted: 'safe' }).trim() + ); + assert.equal( + '$foo["__proto__"].polluted', + render('$foo["__proto__"].polluted', { foo: {}, polluted: 'safe' }).trim() + ); + }); + + it('does not expose inherited constructor or prototype references', function () { + assert.equal('$foo.constructor.name', render('$foo.constructor.name', { foo: {} })); + assert.equal( + '$foo.constructor.constructor.name', + render('$foo.constructor.constructor.name', { foo: {} }) + ); + }); + + it('preserves own constructor and prototype data fields', function () { + const model = { + constructor: { name: 'Car' }, + prototype: { label: 'draft' }, + }; + + assert.equal( + 'Car draft', + render('$model.constructor.name $model.prototype.label', { model }) + ); + }); + + it('does not expose function prototype paths', function () { + assert.equal( + '$target.constructor.prototype', + render('$target.constructor.prototype', { target: { constructor: Object } }) + ); + }); + }); + describe('env', function () { it('should throw on property when parent is null', function () { const vm = '$foo.bar'; From f2cd2c69214df80b3d50476b77cec9956f6bdd99 Mon Sep 17 00:00:00 2001 From: Eward Date: Tue, 14 Jul 2026 16:36:48 +0800 Subject: [PATCH 2/2] fix: guard default method prototype access --- src/compile/methods.ts | 27 ++++++++++++++++++++++++--- test/references.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/compile/methods.ts b/src/compile/methods.ts index f1e66dc..5ef51ed 100644 --- a/src/compile/methods.ts +++ b/src/compile/methods.ts @@ -1,3 +1,5 @@ +import { isBlockedPrototypeKey } from './prototype-guard'; + function hasProperty(context: object, field: string) { if (typeof context === 'number' || typeof context === 'string') { return context[field] || Object.prototype.hasOwnProperty.call(context, field); @@ -25,6 +27,10 @@ function getter(base: any, property: number | string) { return base[property]; } + if (isBlockedPrototypeKey(base, property)) { + return undefined; + } + const letter = property.charCodeAt(0); const isUpper = letter < 91; const ret = base[property]; @@ -43,9 +49,24 @@ function getter(base: any, property: number | string) { property = String.fromCharCode(letter).toUpperCase() + property.slice(1); } + if (isBlockedPrototypeKey(base, property)) { + return undefined; + } + return base[property]; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setter(context: any, property: unknown, value: unknown) { + const key = String(property); + if (isBlockedPrototypeKey(context, key, true)) { + return ''; + } + + context[key] = value; + return value; +} + function getSize(obj: unknown) { if (Array.isArray(obj)) { return obj.length; @@ -76,7 +97,7 @@ const handlers = { set: { match: matchProperty('set', true), resolve: ({ context, params }: IHandlerParams) => { - context[params[0]] = params[1]; + setter(context, params[0], params[1]); return ''; }, }, @@ -93,7 +114,7 @@ const handlers = { setValue: { match: matchStartWith('set'), resolve: ({ context, property, params }: IHandlerParams) => { - context[property.slice(3)] = params[0]; + setter(context, property.slice(3), params[0]); // set value will not output anything context.toString = () => ''; return context; @@ -117,7 +138,7 @@ const handlers = { }, put: { match: matchProperty('put', true), - resolve: ({ context, params }: IHandlerParams) => (context[params[0]] = params[1]), + resolve: ({ context, params }: IHandlerParams) => setter(context, params[0], params[1]), }, add: { match: matchProperty('add', true), diff --git a/test/references.test.ts b/test/references.test.ts index 2e076a1..19a6507 100644 --- a/test/references.test.ts +++ b/test/references.test.ts @@ -308,6 +308,40 @@ describe('References', function () { render('$target.constructor.prototype', { target: { constructor: Object } }) ); }); + + it('does not expose prototype paths through default getter methods', function () { + assert.equal( + '$foo.get("__proto__").polluted', + render('$foo.get("__proto__").polluted', { foo: {} }) + ); + assert.equal( + '$foo.get("constructor").name', + render('$foo.get("constructor").name', { foo: {} }) + ); + assert.equal('$foo.getConstructor().name', render('$foo.getConstructor().name', { foo: {} })); + }); + + it('preserves own constructor and prototype fields through default getter methods', function () { + const model = { + constructor: { name: 'Car' }, + prototype: { label: 'draft' }, + }; + + assert.equal( + 'Car draft', + render('$model.get("constructor").name $model.get("prototype").label', { model }) + ); + }); + + it('does not mutate prototypes through default setter methods', function () { + const target: Record = {}; + + render('$target.set("__proto__", {"polluted": "hacked"})', { target }); + render('$target.put("__proto__", {"polluted": "hacked"})', { target }); + + assert.equal(target.polluted, undefined); + assert.equal(({} as Record).polluted, undefined); + }); }); describe('env', function () {