diff --git a/src/compile/set.ts b/src/compile/set.ts index 1cde584..f29673f 100644 --- a/src/compile/set.ts +++ b/src/compile/set.ts @@ -1,6 +1,26 @@ -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'; + +const PROTO_KEY = '__proto__'; +const PROTOTYPE_CHAIN_KEYS = new Set(['constructor', 'prototype']); + +type SetPathTarget = { + blocked: boolean; + rootRef?: unknown; + shouldSetRoot?: boolean; + baseRef?: unknown; + 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 */ @@ -20,6 +40,90 @@ 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 getPathKeys(ref: ReferencesAST): string[] { + return (ref.path || []).map((exp) => this.getPathKey(exp)); + } + + 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); + } + + 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)); + }); + } + + private resolveSetPath(context: object, ref: ReferencesAST, pathKeys: string[]): SetPathTarget { + if (pathKeys.length === 0) { + return { blocked: ref.id === PROTO_KEY }; + } + + if (ref.id === PROTO_KEY) { + return { blocked: true }; + } + + let rootRef = (context as Record)[ref.id]; + let shouldSetRoot = false; + + if (typeof rootRef !== 'object') { + rootRef = {}; + shouldSetRoot = true; + } + + let baseRef = rootRef; + + for (let i = 0; i < pathKeys.length; i++) { + if (baseRef === undefined || baseRef === null) { + return { blocked: this.hasBlockedUnknownPath(pathKeys, i), rootRef, shouldSetRoot }; + } + + const key = pathKeys[i]; + const isEnd = pathKeys.length === i + 1; + + if (this.isBlockedPathKey(baseRef, key, isEnd)) { + return { blocked: true }; + } + + if (isEnd) { + return { blocked: false, rootRef, shouldSetRoot, baseRef, key }; + } + + baseRef = (baseRef as Record)[key]; + } + + return { blocked: false, rootRef, shouldSetRoot }; + } + /** * parse #set */ @@ -33,6 +137,15 @@ export class SetValue extends Compile { // fix #129 } + // Resolve the left-hand path before evaluating RHS so blocked prototype + // paths cannot trigger user callbacks or other RHS side effects. + const pathKeys = this.getPathKeys(ref); + let setPath = this.resolveSetPath(context, ref, pathKeys); + + if (setPath.blocked) { + return; + } + const valAst = ast.equal[1]; // eslint-disable-next-line @typescript-eslint/no-explicit-any let val: any; @@ -48,42 +161,21 @@ export class SetValue extends Compile { return; } - let baseRef = (context as Record)[ref.id]; - if (typeof baseRef !== 'object') { - baseRef = {}; - } - - (context as Record)[ref.id] = baseRef; - const len = ref.path ? ref.path.length : 0; - - ref.path.some((exp, i) => { - const isEnd = len === i + 1; - let key: string; + // Resolve again after RHS evaluation to preserve legacy behavior where RHS + // side effects create a previously missing parent object. + setPath = this.resolveSetPath(context, ref, pathKeys); - 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 (isEnd) { - (baseRef as Record)[key] = val; - return true; - } - - baseRef = (baseRef as Record)[key] as Record; + if (setPath.blocked) { + return; + } - // such as - // #set($a.d.c2 = 2) - // but $a.d is undefined, value set fail - if (baseRef === undefined) { - return true; - } + if (setPath.shouldSetRoot) { + (context as Record)[ref.id] = setPath.rootRef; + } - return false; - }); + if (setPath.key !== undefined) { + (setPath.baseRef as Record)[setPath.key] = val; + } } } diff --git a/test/set.test.ts b/test/set.test.ts index 866d8d6..1e3c919 100644 --- a/test/set.test.ts +++ b/test/set.test.ts @@ -190,6 +190,108 @@ 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 __proto__ index keys', function () { + render('#set($target["__proto__"].polluted = "hacked")', { target: {} }); + + assert.equal(({} as Record).polluted, undefined); + }); + + it('does not set values through inherited constructor.prototype paths', function () { + render('#set($target.constructor.prototype.isAdmin = true)', { target: {} }); + + assert.equal(({} as Record).isAdmin, undefined); + }); + + it('does not evaluate assigned values for blocked dynamic index keys', function () { + let evaluated = false; + + render('#set($key = "__proto__") #set($target[$key].polluted = $markEvaluated())', { + target: {}, + markEvaluated() { + evaluated = true; + return 'hacked'; + }, + }); + + assert.equal(evaluated, false); + assert.equal(({} as Record).polluted, undefined); + }); + + it('does not create root objects for blocked paths', function () { + const context = getContext('#set($target["__proto__"].polluted = "hacked")'); + + expect(context).not.toHaveProperty('target'); + assert.equal(({} as Record).polluted, undefined); + }); + + it('preserves assignments after assigned values create missing parents', function () { + const target: { child?: { name?: string } } = {}; + const context = { + target, + ensureChild() { + target.child = {}; + return 'Car'; + }, + }; + + render('#set($target.child.name = $ensureChild())', context); + + assert.equal(target.child?.name, 'Car'); + }); + + it('preserves top-level constructor and prototype variables', function () { + const context = getContext('#set($constructor = "ctor") #set($prototype = "proto")'); + + assert.equal(context.constructor, 'ctor'); + assert.equal(context.prototype, 'proto'); + }); + + it('preserves own constructor and prototype data fields', function () { + const model = { + constructor: { + prototype: {}, + }, + prototype: {}, + }; + + render( + '#set($model.constructor.name = "Car") #set($model.constructor.prototype.kind = "vehicle") #set($model.prototype.label = "draft")', + { model } + ); + + assert.equal((model.constructor as Record).name, 'Car'); + assert.equal((model.constructor.prototype as Record).kind, 'vehicle'); + assert.equal((model.prototype as Record).label, 'draft'); + }); + + it('does not set values through function prototype paths', function () { + let evaluated = false; + + render('#set($target.constructor.prototype.polluted = $markEvaluated())', { + target: { constructor: Object }, + 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])