Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 126 additions & 34 deletions src/compile/set.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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<string, unknown>)[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<string, unknown>)[key];
}

return { blocked: false, rootRef, shouldSetRoot };
}

/**
* parse #set
*/
Expand All @@ -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);
Comment thread
shepherdwind marked this conversation as resolved.
let setPath = this.resolveSetPath(context, ref, pathKeys);
Comment thread
shepherdwind marked this conversation as resolved.

if (setPath.blocked) {
return;
}

const valAst = ast.equal[1];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let val: any;
Expand All @@ -48,42 +161,21 @@ export class SetValue extends Compile {
return;
}

let baseRef = (context as Record<string, unknown>)[ref.id];
if (typeof baseRef !== 'object') {
baseRef = {};
}

(context as Record<string, unknown>)[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<string, unknown>)[key] = val;
return true;
}

baseRef = (baseRef as Record<string, unknown>)[key] as Record<string, unknown>;
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<string, unknown>)[ref.id] = setPath.rootRef;
}

return false;
});
if (setPath.key !== undefined) {
(setPath.baseRef as Record<string, unknown>)[setPath.key] = val;
}
}
}

Expand Down
102 changes: 102 additions & 0 deletions test/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,108 @@ describe('Set && Expression', function () {
});
});

describe('prototype pollution protection', function () {
afterEach(function () {
delete (Object.prototype as Record<string, unknown>).polluted;
delete (Object.prototype as Record<string, unknown>).isAdmin;
});

it('does not set values through __proto__ references', function () {
render('#set($__proto__.polluted = "hacked")', {});

assert.equal(({} as Record<string, unknown>).polluted, undefined);
});

it('does not set values through __proto__ index keys', function () {
render('#set($target["__proto__"].polluted = "hacked")', { target: {} });

assert.equal(({} as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).name, 'Car');
assert.equal((model.constructor.prototype as Record<string, unknown>).kind, 'vehicle');
assert.equal((model.prototype as Record<string, unknown>).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<string, unknown>).polluted, undefined);
});
});

it('set with foreach', function () {
const tpl = `
#foreach($item in [1..2])
Expand Down
Loading