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
84 changes: 77 additions & 7 deletions src/view/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,27 +135,95 @@ export const clearExpressionCache = (): void => {
* This avoids subscribing to signals that aren't referenced in the expression.
* @internal
*/
/**
* Identifiers that must never resolve during `with`-scoped evaluation.
*
* `with` resolves a free identifier via `[[HasProperty]]`, walking the
* prototype chain. If the context proxy declines an inherited name, resolution
* falls through to the function's enclosing scope — and the global object
* itself inherits `constructor` from `Object.prototype` and exposes `Function`,
* `eval`, `globalThis`, `window`, etc. So `constructor.constructor('…')()` (or
* a bare `Function('…')()`) would still reach arbitrary code execution.
*
* These names are therefore *shadowed*: the proxy claims to own them (`has`
* returns true) but resolves them to `undefined` (`get` returns undefined),
* unless the context legitimately defines its own property of that name. Any
* member access on the resulting `undefined` throws and evaluates to
* `undefined`.
* @internal
*/
const SHADOWED_GLOBALS = new Set([
'constructor',
'__proto__',
'prototype',
'Function',
'eval',
'globalThis',
'global',
'window',
'self',
'top',
'parent',
]);

/**
* `has` trap for `with`-scoped evaluation proxies. Reports own string keys and
* the shadowed dangerous globals as present so neither resolves from an
* inherited prototype member or the enclosing (global) scope.
*
* Symbol keys keep default behaviour so `with`'s internal `Symbol.unscopables`
* probe still works.
* @internal
*/
const hardenedHas = (target: BindingContext, prop: string | symbol): boolean => {
if (typeof prop !== 'string') {
return Reflect.has(target, prop);
}
return Object.prototype.hasOwnProperty.call(target, prop) || SHADOWED_GLOBALS.has(prop);
};

/**
* Returns true when `prop` is a shadowed global the context does not itself own.
* @internal
*/
const isShadowedGlobal = (target: BindingContext, prop: string): boolean =>
SHADOWED_GLOBALS.has(prop) && !Object.prototype.hasOwnProperty.call(target, prop);

const createLazyContext = (context: BindingContext): BindingContext =>
new Proxy(context, {
get(target, prop: string | symbol) {
// Only handle string keys for BindingContext indexing
if (typeof prop !== 'string') {
return Reflect.get(target, prop);
}
if (isShadowedGlobal(target, prop)) {
return undefined;
}
const value = target[prop];
// Auto-unwrap signals/computed only when actually accessed
if (isSignal(value) || isComputed(value)) {
return (value as Signal<unknown>).value;
}
return value;
},
has(target, prop: string | symbol) {
// Required for `with` statement to resolve identifiers correctly
if (typeof prop !== 'string') {
return Reflect.has(target, prop);
has: hardenedHas,
});

/**
* Wraps a raw context so `with`-based evaluation cannot resolve inherited
* prototype members or dangerous globals, without unwrapping signals
* (unlike {@link createLazyContext}).
* @internal
*/
const createHardenedContext = (context: BindingContext): BindingContext =>
new Proxy(context, {
get(target, prop: string | symbol) {
if (typeof prop === 'string' && isShadowedGlobal(target, prop)) {
return undefined;
}
return prop in target;
return Reflect.get(target, prop);
},
has: hardenedHas,
});

/**
Expand Down Expand Up @@ -221,13 +289,15 @@ export const evaluateRaw = <T = unknown>(expression: string, context: BindingCon
let fn = evaluateRawCache.get(expression);
if (!fn) {
// Use `with` to enable direct property access from context scope.
// Unlike `evaluate`, we don't use a lazy proxy - values are accessed directly.
// Unlike `evaluate`, we don't unwrap signals — but we still wrap the
// context in a hardened proxy so `with` cannot resolve inherited
// prototype members (e.g. `constructor.constructor`).
fn = new Function('$ctx', `with($ctx) { return (${expression}); }`) as (
ctx: BindingContext
) => unknown;
evaluateRawCache.set(expression, fn);
}
return fn(context) as T;
return fn(createHardenedContext(context)) as T;
} catch (error) {
console.error(`bQuery view: Error evaluating "${expression}"`, error);
return undefined as T;
Expand Down
43 changes: 42 additions & 1 deletion tests/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { afterEach, beforeEach, describe, expect, it, spyOn, type Mock } from 'bun:test';
import { createForm, required } from '../src/forms/index';
import { computed, signal } from '../src/reactive/index';
import { parseObjectExpression } from '../src/view/evaluate';
import { evaluate, evaluateRaw, parseObjectExpression } from '../src/view/evaluate';
import { clearExpressionCache, createTemplate, mount, type View } from '../src/view/index';
import { getCustomDirective, registerCustomDirectiveResolver } from '../src/view/custom-directives';

Expand Down Expand Up @@ -1401,3 +1401,44 @@ describe('parseObjectExpression', () => {
expect(Object.keys(result)).toEqual(['safe']);
});
});

describe('evaluate — prototype-chain hardening (#168)', () => {
const originalError = console.error;
afterEach(() => {
console.error = originalError;
});

it('does not resolve inherited constructor via the with-scoped proxy', () => {
console.error = () => {};
const result = evaluate("constructor.constructor('return 1')()", {});
expect(result).toBeUndefined();
});

it('does not reach Function through evaluateRaw either', () => {
console.error = () => {};
const result = evaluateRaw("constructor.constructor('return 1')()", {});
expect(result).toBeUndefined();
});

it('shadows the bare Function and eval globals', () => {
console.error = () => {};
expect(evaluate("Function('return 1')()", {})).toBeUndefined();
expect(evaluate("eval('1')", {})).toBeUndefined();
});

it('lets an own context property shadow a dangerous global name', () => {
expect(evaluate<string>('constructor', { constructor: 'mine' })).toBe('mine');
});

it('still resolves own context properties', () => {
expect(evaluate<number>('a + b', { a: 2, b: 3 })).toBe(5);
});

it('still allows method calls on context values (inherited on the value, not the context)', () => {
expect(evaluate<string>('name.toUpperCase()', { name: 'ada' })).toBe('ADA');
});

it('resolves own properties that shadow prototype names', () => {
expect(evaluate<number>('hasOwnProperty', { hasOwnProperty: 42 })).toBe(42);
});
});