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
27 changes: 24 additions & 3 deletions src/compile/methods.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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];
Expand All @@ -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;
Expand Down Expand Up @@ -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 '';
},
},
Expand All @@ -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;
Expand All @@ -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),
Expand Down
37 changes: 37 additions & 0 deletions src/compile/prototype-guard.ts
Original file line number Diff line number Diff line change
@@ -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));
});
}
24 changes: 21 additions & 3 deletions src/compile/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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<string, unknown>;
ret = contextObj[id];
Expand Down Expand Up @@ -110,18 +111,30 @@ export class References extends Compile {
}

if (property.type === 'property') {
if (this.isBlockedPathKey(baseRef, property.id)) {
return undefined;
}

return (baseRef as Record<string, unknown>)[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
*/
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<string, unknown>)[key as string];
}

Expand All @@ -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)) {
Comment thread
shepherdwind marked this conversation as resolved.
this._throw(ast, property, 'TypeError');
return;
}

let ret = baseRef[id];
const args = (property.args || []).map((exp) => this.getLiteral(exp as VELOCITY_AST)) || [];

Expand Down
39 changes: 5 additions & 34 deletions src/compile/set.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
*/
Expand Down Expand Up @@ -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 };
}

Expand Down
85 changes: 85 additions & 0 deletions test/references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,91 @@ 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<string, unknown>).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<string, unknown>).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 } })
);
});

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<string, unknown> = {};

render('$target.set("__proto__", {"polluted": "hacked"})', { target });
render('$target.put("__proto__", {"polluted": "hacked"})', { target });

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

describe('env', function () {
it('should throw on property when parent is null', function () {
const vm = '$foo.bar';
Expand Down
Loading