Skip to content
Closed
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
23 changes: 22 additions & 1 deletion src/compile/set.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { SetAST, VELOCITY_AST } from '../type';
import type { ReferencesAST, 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
*/
Expand All @@ -20,11 +27,20 @@ export class SetValue extends Compile {
// not find local variable, return global context
return this.context;
}
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;
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve safe own constructor/prototype fields

When a template writes through an ordinary data object that has an own field named constructor or prototype (for example #set($model.constructor.name = 'Car') with { model: { constructor: {} } }), this early check skips the whole assignment even though it never traverses Object.prototype; the dangerous case is the inherited constructor.prototype chain, not every data property with these names. This silently drops valid template updates for contexts that legitimately expose those keys.

Useful? React with 👍 / 👎.

}

let context = this.getContext(ref.id) as object;

// @see #25
Expand Down Expand Up @@ -68,6 +84,11 @@ export class SetValue extends Compile {
key = '';
}

if (isPollutingKey(key)) {
// Return true to stop Array.prototype.some traversal without assigning.
return true;
}

if (isEnd) {
(baseRef as Record<string, unknown>)[key] = val;
return true;
Expand Down
39 changes: 39 additions & 0 deletions test/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,45 @@ 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 constructor.prototype paths', function () {
render('#set($target.constructor.prototype.isAdmin = true)', { target: {} });

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

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

assert.equal(({} as Record<string, unknown>).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<string, unknown>).polluted, undefined);
});
});

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