mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
fix: server context resolve (#9656)
* fix(plugin-flow-engine): harden variable resolver sandbox * fix(plugin-flow-engine): allow plain then sandbox fields * fix: bug
This commit is contained in:
+248
@@ -7,6 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { createMockServer, MockServer } from '@nocobase/test';
|
||||
import { GlobalContext, HttpRequestContext, ServerBaseContext } from '../template/contexts';
|
||||
import { resolveJsonTemplate } from '../template/resolver';
|
||||
@@ -78,6 +79,17 @@ describe('variables resolver (no HTTP)', () => {
|
||||
expect(out.x).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks intrinsic constructor traversal to host process', async () => {
|
||||
const { req } = makeCtx(1);
|
||||
const tpl = {
|
||||
v: "{{ (() => { try { return ({}).constructor.constructor('return process')() ? 'escaped' : 'safe'; } catch (_) { return 'blocked'; } })() }}",
|
||||
} as any;
|
||||
|
||||
const out = await resolveJsonTemplate(tpl, req);
|
||||
|
||||
expect(out.v).toBe('blocked');
|
||||
});
|
||||
|
||||
it('preserves unknown placeholders', async () => {
|
||||
const { req } = makeCtx(1);
|
||||
const tpl = { x: '{{ ctx.unknown }}', y: 'Hello {{ foo.bar }}' } as any;
|
||||
@@ -100,6 +112,54 @@ describe('variables resolver (no HTTP)', () => {
|
||||
expect(out.t).toBe('undefined');
|
||||
});
|
||||
|
||||
it('does not expose koa context internals to SES expressions', async () => {
|
||||
const { koa, req } = makeCtx(1);
|
||||
const query = vi.fn();
|
||||
koa.db = { sequelize: { query } };
|
||||
const exploit =
|
||||
"{{ (async () => { const seq = ctx.koaCtx.db.sequelize; await seq.query('SELECT 1'); return 'ran'; })() }}";
|
||||
|
||||
const out = await resolveJsonTemplate({ v: exploit }, req);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(out.v).toBe(exploit);
|
||||
});
|
||||
|
||||
it('only exposes explicitly registered context keys in the sandbox', async () => {
|
||||
const { req } = makeCtx(1);
|
||||
const tpl = {
|
||||
app: '{{ ctx.app }}',
|
||||
db: '{{ ctx.db }}',
|
||||
koaCtx: '{{ ctx.koaCtx }}',
|
||||
request: '{{ ctx.request }}',
|
||||
user: '{{ ctx.user.id }}',
|
||||
} as any;
|
||||
|
||||
const out = await resolveJsonTemplate(tpl, req);
|
||||
|
||||
expect(out.app).toBe('{{ ctx.app }}');
|
||||
expect(out.db).toBe('{{ ctx.db }}');
|
||||
expect(out.koaCtx).toBe('{{ ctx.koaCtx }}');
|
||||
expect(out.request).toBe('{{ ctx.request }}');
|
||||
expect(out.user).toBe(1);
|
||||
});
|
||||
|
||||
it('does not expose plain root object properties as sandbox variables', async () => {
|
||||
const query = vi.fn();
|
||||
const rawCtx = {
|
||||
db: { sequelize: { query } },
|
||||
user: { id: 1 },
|
||||
};
|
||||
const exploit =
|
||||
"{{ (async () => { const seq = ctx.db.sequelize; await seq.query('SELECT 1'); return 'ran'; })() }}";
|
||||
|
||||
const out = await resolveJsonTemplate({ exploit, user: '{{ ctx.user.id }}' }, rawCtx as any);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(out.exploit).toBe(exploit);
|
||||
expect(out.user).toBe('{{ ctx.user.id }}');
|
||||
});
|
||||
|
||||
it('supports custom ctx methods attached via registry', async () => {
|
||||
if (!variables.get('twice')) {
|
||||
variables.register({
|
||||
@@ -115,6 +175,194 @@ describe('variables resolver (no HTTP)', () => {
|
||||
expect(out.v).toBe(42);
|
||||
});
|
||||
|
||||
it('blocks constructor traversal on context values and methods', async () => {
|
||||
if (!variables.get('twice')) {
|
||||
variables.register({
|
||||
name: 'twice',
|
||||
scope: 'request',
|
||||
attach: (flowCtx) => flowCtx.defineMethod('twice', (n: any) => Number(n) * 2),
|
||||
});
|
||||
}
|
||||
const { koa, req } = makeCtx(1);
|
||||
const tpl = {
|
||||
userCtor: '{{ ctx.user.constructor }}',
|
||||
getCtor: "{{ (await __get('user')).constructor }}",
|
||||
helperCtor: '{{ __get.constructor }}',
|
||||
methodCtor: '{{ ctx.twice.constructor }}',
|
||||
methodStillWorks: '{{ ctx.twice(21) }}',
|
||||
} as any;
|
||||
|
||||
await variables.attachUsedVariables(req, koa, tpl, {});
|
||||
const out = await resolveJsonTemplate(tpl, req);
|
||||
|
||||
expect(out.userCtor).toBe('{{ ctx.user.constructor }}');
|
||||
expect(out.getCtor).toBe("{{ (await __get('user')).constructor }}");
|
||||
expect(out.helperCtor).toBe('{{ __get.constructor }}');
|
||||
expect(out.methodCtor).toBe('{{ ctx.twice.constructor }}');
|
||||
expect(out.methodStillWorks).toBe(42);
|
||||
});
|
||||
|
||||
it('does not read then accessors on exposed data values', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
const thenGetter = vi.fn(() => () => undefined);
|
||||
const payload = { name: 'safe' };
|
||||
Object.defineProperty(payload, 'then', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: thenGetter,
|
||||
});
|
||||
ctx.defineProperty('payload', { value: payload });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
name: '{{ ctx.payload.name }}',
|
||||
thenValue: '{{ ctx.payload.then }}',
|
||||
whole: '{{ ctx.payload }}',
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(thenGetter).not.toHaveBeenCalled();
|
||||
expect(out.name).toBe('safe');
|
||||
expect(out.thenValue).toBe('{{ ctx.payload.then }}');
|
||||
expect(out.whole).toEqual({ name: 'safe' });
|
||||
});
|
||||
|
||||
it('resolves plain then data fields on exposed data values', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
ctx.defineProperty('payload', { value: { name: 'safe', then: 'visible' } });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
name: '{{ ctx.payload.name }}',
|
||||
thenValue: '{{ ctx.payload.then }}',
|
||||
whole: '{{ ctx.payload }}',
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(out.name).toBe('safe');
|
||||
expect(out.thenValue).toBe('visible');
|
||||
expect(out.whole).toEqual({ name: 'safe', then: 'visible' });
|
||||
});
|
||||
|
||||
it('does not expose function-valued then fields on exposed data values', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
const then = vi.fn(() => undefined);
|
||||
ctx.defineProperty('payload', { value: { name: 'safe', then } });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
name: '{{ ctx.payload.name }}',
|
||||
thenValue: '{{ ctx.payload.then }}',
|
||||
whole: '{{ ctx.payload }}',
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(then).not.toHaveBeenCalled();
|
||||
expect(out.name).toBe('safe');
|
||||
expect(out.thenValue).toBe('{{ ctx.payload.then }}');
|
||||
expect(out.whole).toEqual({ name: 'safe' });
|
||||
});
|
||||
|
||||
it('does not expose or invoke accessor properties from data values', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
const secretGetter = vi.fn(() => 'secret');
|
||||
const payload = { name: 'safe' };
|
||||
Object.defineProperty(payload, 'secret', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: secretGetter,
|
||||
});
|
||||
ctx.defineProperty('payload', { value: payload });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
direct: '{{ ctx.payload.secret }}',
|
||||
descriptor: "{{ Object.getOwnPropertyDescriptor(ctx.payload, 'secret') ? 'present' : 'missing' }}",
|
||||
keys: "{{ Object.keys(ctx.payload).join(',') }}",
|
||||
whole: '{{ ctx.payload }}',
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(secretGetter).not.toHaveBeenCalled();
|
||||
expect(out.direct).toBe('{{ ctx.payload.secret }}');
|
||||
expect(out.descriptor).toBe('missing');
|
||||
expect(out.keys).toBe('name');
|
||||
expect(out.whole).toEqual({ name: 'safe' });
|
||||
});
|
||||
|
||||
it('does not invoke array index accessors when unwrapping whole arrays', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
const itemGetter = vi.fn(() => 'secret');
|
||||
const items = [];
|
||||
Object.defineProperty(items, '0', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: itemGetter,
|
||||
});
|
||||
ctx.defineProperty('items', { value: items });
|
||||
|
||||
const out = await resolveJsonTemplate('{{ ctx.items }}', ctx);
|
||||
|
||||
expect(itemGetter).not.toHaveBeenCalled();
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps array enumeration and length descriptors usable in the sandbox', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
ctx.defineProperty('items', { value: ['a', 'b'] });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
keys: "{{ Object.keys(ctx.items).join(',') }}",
|
||||
length: "{{ Object.getOwnPropertyDescriptor(ctx.items, 'length').value }}",
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(out.keys).toBe('0,1');
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it('supports own data properties on primitive tail values without exposing prototype members', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
ctx.defineProperty('user', { value: { name: 'Alice' } });
|
||||
|
||||
const out = await resolveJsonTemplate(
|
||||
{
|
||||
length: '{{ ctx.user.name.length }}',
|
||||
computedLength: '{{ ctx.user.name.length + 1 }}',
|
||||
index: '{{ ctx.user.name[0] }}',
|
||||
prototypeMethod: '{{ ctx.user.name.toString() }}',
|
||||
constructorValue: '{{ ctx.user.name.constructor }}',
|
||||
} as any,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(out.length).toBe(5);
|
||||
expect(out.computedLength).toBe(6);
|
||||
expect(out.index).toBe('A');
|
||||
expect(out.prototypeMethod).toBe('{{ ctx.user.name.toString() }}');
|
||||
expect(out.constructorValue).toBe('{{ ctx.user.name.constructor }}');
|
||||
});
|
||||
|
||||
it('passes the top-level sandbox proxy to delegated getters', async () => {
|
||||
const parent = new ServerBaseContext();
|
||||
parent.defineProperty('x', {
|
||||
get: (flowCtx) => flowCtx.hello(),
|
||||
});
|
||||
const child = new ServerBaseContext();
|
||||
child.defineMethod('hello', () => 'ok');
|
||||
child.delegate(parent);
|
||||
|
||||
const out = await resolveJsonTemplate('{{ ctx.x }}', child);
|
||||
|
||||
expect(out).toBe('ok');
|
||||
});
|
||||
|
||||
describe('server resolver: dot-only path aggregation', () => {
|
||||
it('aggregates across arrays with dot-only path', async () => {
|
||||
const ctx = new ServerBaseContext();
|
||||
|
||||
@@ -12,6 +12,12 @@ import { ResourcerContext } from '@nocobase/resourcer';
|
||||
|
||||
type Getter<T = any> = (ctx: ServerBaseContext) => T | Promise<T>;
|
||||
|
||||
const BLOCKED_SANDBOX_KEYS = new Set(['__proto__', 'prototype', 'constructor', 'then']);
|
||||
|
||||
function isBlockedSandboxKey(key: string) {
|
||||
return BLOCKED_SANDBOX_KEYS.has(key);
|
||||
}
|
||||
|
||||
export interface PropertyOptions {
|
||||
/** 固定值,优先级高于 get */
|
||||
value?: any;
|
||||
@@ -100,6 +106,38 @@ export class ServerBaseContext {
|
||||
this._delegates = [];
|
||||
}
|
||||
|
||||
getSandboxKeys(): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const key of Object.keys(this._props)) {
|
||||
if (!isBlockedSandboxKey(key)) keys.add(key);
|
||||
}
|
||||
for (const key of Object.keys(this._methods)) {
|
||||
if (!isBlockedSandboxKey(key)) keys.add(key);
|
||||
}
|
||||
for (const d of this._delegates) {
|
||||
for (const key of d.getSandboxKeys()) {
|
||||
if (!isBlockedSandboxKey(key)) keys.add(key);
|
||||
}
|
||||
}
|
||||
return Array.from(keys);
|
||||
}
|
||||
|
||||
getSandboxValue(key: string, current: ServerBaseContext = this.createProxy()): any {
|
||||
if (isBlockedSandboxKey(key)) return undefined;
|
||||
if (Object.prototype.hasOwnProperty.call(this._props, key)) {
|
||||
return this._getOwn(key, current);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this._methods, key)) {
|
||||
const fn = this._methods[key];
|
||||
return typeof fn === 'function' ? fn.bind(this) : fn;
|
||||
}
|
||||
for (const d of this._delegates) {
|
||||
if (!d.getSandboxKeys().includes(key)) continue;
|
||||
return d.getSandboxValue(key, current);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 创建并返回代理对象(同一实例下保持稳定引用) */
|
||||
createProxy() {
|
||||
if (this._proxy) return this._proxy as any;
|
||||
|
||||
@@ -9,23 +9,27 @@
|
||||
|
||||
import 'ses';
|
||||
import _ from 'lodash';
|
||||
import { getValuesByPath } from '@nocobase/utils/client';
|
||||
|
||||
// TODO: 是否有必要lockdown?
|
||||
// // 使用 SES 进行隔离
|
||||
// declare const lockdown: any;
|
||||
// try {
|
||||
// // 测试环境下避免执行全局 lockdown,以免冻结测试依赖(如 Vitest/Chai)
|
||||
// const env = (typeof process !== 'undefined' && (process as any)?.env) ? (process as any).env : {} as any;
|
||||
// if (typeof lockdown === 'function' && env.NODE_ENV !== 'test') {
|
||||
// lockdown({ errorTaming: 'unsafe', consoleTaming: 'unsafe' });
|
||||
// }
|
||||
// } catch (_) {
|
||||
// // ignore
|
||||
// }
|
||||
import { lockdownSes } from '@nocobase/utils';
|
||||
import { ServerBaseContext } from './contexts';
|
||||
|
||||
export type JSONValue = string | { [key: string]: JSONValue } | JSONValue[];
|
||||
|
||||
type SandboxContextSource = {
|
||||
getSandboxKeys: () => string[];
|
||||
getSandboxValue: (key: string) => unknown;
|
||||
};
|
||||
|
||||
type SandboxProxyKind = 'context' | 'data' | 'function';
|
||||
|
||||
const BLOCKED_SANDBOX_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const sandboxProxyCache = new WeakMap<object, unknown>();
|
||||
const sandboxProxyMeta = new WeakMap<object, { kind: SandboxProxyKind; source: unknown }>();
|
||||
const EMPTY_SANDBOX_CONTEXT: SandboxContextSource = {
|
||||
getSandboxKeys: () => [],
|
||||
getSandboxValue: () => undefined,
|
||||
};
|
||||
let resolverLockdownReady = false;
|
||||
|
||||
/**
|
||||
* 解析 JSON 模板中形如 {{ ... }} 的占位符(服务端解析)。
|
||||
* 仅支持以 ctx 开头的路径与表达式(如:{{ ctx.user.id }}、{{ ctx.record.roles[0].name }})。
|
||||
@@ -70,6 +74,248 @@ async function replacePlaceholders(input: string, ctx: any) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function isObjectLike(value: unknown): value is object {
|
||||
return value !== null && (typeof value === 'object' || typeof value === 'function');
|
||||
}
|
||||
|
||||
function isTrustedPromise(value: unknown): value is Promise<unknown> {
|
||||
return value instanceof Promise;
|
||||
}
|
||||
|
||||
function isBlockedSandboxKey(key: PropertyKey) {
|
||||
return typeof key === 'string' && BLOCKED_SANDBOX_KEYS.has(key);
|
||||
}
|
||||
|
||||
function getSandboxDataDescriptor(source: object, key: PropertyKey) {
|
||||
if (isBlockedSandboxKey(key) || typeof key === 'symbol') return undefined;
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !('value' in descriptor)) return undefined;
|
||||
if (key === 'then' && typeof descriptor.value === 'function') return undefined;
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function getPrimitiveDataDescriptor(source: unknown, key: string) {
|
||||
if (source == null || isObjectLike(source)) return undefined;
|
||||
return getSandboxDataDescriptor(Object(source), key);
|
||||
}
|
||||
|
||||
function isSandboxContextSource(value: unknown): value is SandboxContextSource {
|
||||
return value === EMPTY_SANDBOX_CONTEXT || value instanceof ServerBaseContext;
|
||||
}
|
||||
|
||||
function wrapSandboxValue(value: unknown): unknown {
|
||||
if (isTrustedPromise(value)) {
|
||||
return Promise.prototype.then.call(value, (resolved) => wrapSandboxValue(resolved));
|
||||
}
|
||||
if (!isObjectLike(value)) return value;
|
||||
if (sandboxProxyMeta.has(value)) return value;
|
||||
|
||||
const cached = sandboxProxyCache.get(value);
|
||||
if (cached) return cached;
|
||||
|
||||
const kind: SandboxProxyKind = isSandboxContextSource(value)
|
||||
? 'context'
|
||||
: typeof value === 'function'
|
||||
? 'function'
|
||||
: 'data';
|
||||
const proxy =
|
||||
kind === 'context'
|
||||
? createSandboxContextProxy(value as SandboxContextSource)
|
||||
: kind === 'function'
|
||||
? createSandboxFunctionProxy(value as (...args: unknown[]) => unknown)
|
||||
: createSandboxDataProxy(value as Record<PropertyKey, unknown>);
|
||||
|
||||
sandboxProxyCache.set(value, proxy);
|
||||
sandboxProxyMeta.set(proxy as object, { kind, source: value });
|
||||
return proxy;
|
||||
}
|
||||
|
||||
function wrapRootSandboxContext(ctx: unknown) {
|
||||
return wrapSandboxValue(isSandboxContextSource(ctx) ? ctx : EMPTY_SANDBOX_CONTEXT);
|
||||
}
|
||||
|
||||
function ensureResolverLockdown() {
|
||||
if (resolverLockdownReady) return;
|
||||
lockdownSes({
|
||||
consoleTaming: 'unsafe',
|
||||
errorTaming: 'unsafe',
|
||||
overrideTaming: 'moderate',
|
||||
stackFiltering: 'verbose',
|
||||
});
|
||||
resolverLockdownReady = true;
|
||||
}
|
||||
|
||||
function createSandboxContextProxy(source: SandboxContextSource) {
|
||||
return new Proxy(Object.create(null), {
|
||||
get: (_target, key) => {
|
||||
if (isBlockedSandboxKey(key) || typeof key !== 'string') return undefined;
|
||||
if (!source.getSandboxKeys().includes(key)) return undefined;
|
||||
return wrapSandboxValue(source.getSandboxValue(key));
|
||||
},
|
||||
has: (_target, key) => typeof key === 'string' && source.getSandboxKeys().includes(key),
|
||||
ownKeys: () => source.getSandboxKeys(),
|
||||
getOwnPropertyDescriptor: (_target, key) => {
|
||||
if (isBlockedSandboxKey(key) || typeof key !== 'string' || !source.getSandboxKeys().includes(key)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: wrapSandboxValue(source.getSandboxValue(key)),
|
||||
};
|
||||
},
|
||||
getPrototypeOf: () => null,
|
||||
set: () => false,
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
function createSandboxFunctionProxy(source: (...args: unknown[]) => unknown) {
|
||||
const callable = (...args: unknown[]) => Reflect.apply(source, undefined, args);
|
||||
return new Proxy(callable, {
|
||||
apply: (_target, _thisArg, argArray) => wrapSandboxValue(Reflect.apply(source, undefined, argArray)),
|
||||
get: (_target, key) => {
|
||||
if (key === 'length' || key === 'name') return Reflect.get(source, key);
|
||||
return undefined;
|
||||
},
|
||||
has: (_target, key) => key === 'length' || key === 'name',
|
||||
ownKeys: () => [],
|
||||
getOwnPropertyDescriptor: () => undefined,
|
||||
getPrototypeOf: () => null,
|
||||
set: () => false,
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
function createSandboxDataProxy(source: Record<PropertyKey, unknown>) {
|
||||
const target = Array.isArray(source) ? new Array(source.length) : Object.create(null);
|
||||
return new Proxy(target, {
|
||||
get: (_target, key) => {
|
||||
const descriptor = getSandboxDataDescriptor(source, key);
|
||||
if (!descriptor) return undefined;
|
||||
const value = descriptor.value;
|
||||
return typeof value === 'function' ? wrapSandboxValue(value.bind(source)) : wrapSandboxValue(value);
|
||||
},
|
||||
has: (_target, key) => {
|
||||
if (isBlockedSandboxKey(key) || typeof key !== 'string') return false;
|
||||
return !!getSandboxDataDescriptor(source, key);
|
||||
},
|
||||
ownKeys: () => {
|
||||
const keys = Reflect.ownKeys(source).filter((key) => {
|
||||
if (typeof key !== 'string' || isBlockedSandboxKey(key)) return false;
|
||||
return !!getSandboxDataDescriptor(source, key);
|
||||
});
|
||||
if (Array.isArray(source) && !keys.includes('length')) keys.push('length');
|
||||
return keys;
|
||||
},
|
||||
getOwnPropertyDescriptor: (proxyTarget, key) => {
|
||||
if (isBlockedSandboxKey(key) || typeof key === 'symbol') return undefined;
|
||||
if (Array.isArray(source) && key === 'length') {
|
||||
return Reflect.getOwnPropertyDescriptor(proxyTarget, key);
|
||||
}
|
||||
const descriptor = getSandboxDataDescriptor(source, key);
|
||||
if (!descriptor) return undefined;
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: descriptor.enumerable,
|
||||
writable: false,
|
||||
value: wrapSandboxValue(descriptor.value),
|
||||
};
|
||||
},
|
||||
getPrototypeOf: () => null,
|
||||
set: () => false,
|
||||
defineProperty: () => false,
|
||||
deleteProperty: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
async function unwrapSandboxValue(value: unknown, seen = new WeakMap<object, unknown>()): Promise<unknown> {
|
||||
const resolved = isTrustedPromise(value) ? await value : value;
|
||||
if (!isObjectLike(resolved)) return resolved;
|
||||
|
||||
const meta = sandboxProxyMeta.get(resolved);
|
||||
if (meta?.kind === 'function' || typeof resolved === 'function') return undefined;
|
||||
const source = meta?.source ?? resolved;
|
||||
if (!isObjectLike(source)) return source;
|
||||
|
||||
if (seen.has(source)) return seen.get(source);
|
||||
|
||||
if (isSandboxContextSource(source)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
seen.set(source, out);
|
||||
for (const key of source.getSandboxKeys()) {
|
||||
if (BLOCKED_SANDBOX_KEYS.has(key)) continue;
|
||||
out[key] = await unwrapSandboxValue(wrapSandboxValue(source.getSandboxValue(key)), seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
const out: unknown[] = [];
|
||||
seen.set(source, out);
|
||||
const lengthDescriptor = Reflect.getOwnPropertyDescriptor(source, 'length');
|
||||
const length = typeof lengthDescriptor?.value === 'number' ? lengthDescriptor.value : 0;
|
||||
for (let index = 0; index < length; index++) {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(source, String(index));
|
||||
if (!descriptor || !('value' in descriptor)) continue;
|
||||
out.push(await unwrapSandboxValue(descriptor.value, seen));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (source instanceof Date) return source;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
seen.set(source, out);
|
||||
for (const key of Object.keys(source as Record<string, unknown>)) {
|
||||
if (BLOCKED_SANDBOX_KEYS.has(key)) continue;
|
||||
const descriptor = getSandboxDataDescriptor(source, key);
|
||||
if (!descriptor) continue;
|
||||
out[key] = await unwrapSandboxValue(descriptor.value, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getRootSandboxValue(ctx: unknown, key: string) {
|
||||
if (BLOCKED_SANDBOX_KEYS.has(key)) return undefined;
|
||||
if (isSandboxContextSource(ctx)) {
|
||||
if (!ctx.getSandboxKeys().includes(key)) return undefined;
|
||||
return wrapSandboxValue(ctx.getSandboxValue(key));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function getSandboxProperty(value: unknown, key: string) {
|
||||
if (BLOCKED_SANDBOX_KEYS.has(key)) return undefined;
|
||||
|
||||
const resolved = isTrustedPromise(value) ? await value : value;
|
||||
if (resolved == null) return undefined;
|
||||
|
||||
const meta = isObjectLike(resolved) ? sandboxProxyMeta.get(resolved) : undefined;
|
||||
const source = meta?.source ?? resolved;
|
||||
|
||||
if (isSandboxContextSource(source)) {
|
||||
if (!source.getSandboxKeys().includes(key)) return undefined;
|
||||
return wrapSandboxValue(source.getSandboxValue(key));
|
||||
}
|
||||
|
||||
if (typeof source === 'function') {
|
||||
return key === 'length' || key === 'name' ? Reflect.get(source, key) : undefined;
|
||||
}
|
||||
|
||||
if (!isObjectLike(source)) {
|
||||
const descriptor = getPrimitiveDataDescriptor(source, key);
|
||||
if (!descriptor || typeof descriptor.value === 'function') return undefined;
|
||||
return wrapSandboxValue(descriptor.value);
|
||||
}
|
||||
const descriptor = getSandboxDataDescriptor(source, key);
|
||||
if (!descriptor) return undefined;
|
||||
const current = descriptor.value;
|
||||
return typeof current === 'function' ? wrapSandboxValue(current.bind(source)) : wrapSandboxValue(current);
|
||||
}
|
||||
|
||||
// 在 SES 沙箱中执行完整的 JS 表达式;在此之前会将 ctx.* 访问改写为 await __get(var, path)
|
||||
async function evaluate(expr: string, ctx: any) {
|
||||
try {
|
||||
@@ -83,25 +329,25 @@ async function evaluate(expr: string, ctx: any) {
|
||||
if (dotOnly) {
|
||||
const first = dotOnly[1];
|
||||
const rest = dotOnly[2];
|
||||
const base = await ctx[first];
|
||||
if (!rest) return base;
|
||||
const base = getRootSandboxValue(ctx, first);
|
||||
if (!rest) return await unwrapSandboxValue(wrapSandboxValue(base));
|
||||
// 使用异步版本取值,逐段 await,并保留数组场景下的隐式聚合语义
|
||||
const resolved = await asyncGetValuesByPath(base, rest);
|
||||
// 当 dot path 含 '-' 时可能与减号运算符存在歧义(例如:ctx.aa.bb-ctx.cc)。
|
||||
// 若按 path 解析未取到值,则回退到 JS 表达式解析,尽量保持兼容。
|
||||
if (typeof resolved !== 'undefined' || !rest.includes('-')) {
|
||||
return resolved;
|
||||
return await unwrapSandboxValue(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const transformed = preprocessExpression(raw);
|
||||
ensureResolverLockdown();
|
||||
const compartment = new Compartment({
|
||||
ctx,
|
||||
__get: (varName: string, path?: string) => getAtPath(ctx, varName, path),
|
||||
console,
|
||||
ctx: wrapRootSandboxContext(ctx),
|
||||
__get: wrapSandboxValue((varName: string, path?: string) => getAtPath(ctx, varName, path)),
|
||||
});
|
||||
const wrapped = `(async () => { try { return ${transformed}; } catch (e) { return undefined; } })()`;
|
||||
return await compartment.evaluate(wrapped);
|
||||
return await unwrapSandboxValue(await compartment.evaluate(wrapped));
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -112,19 +358,15 @@ async function evaluate(expr: string, ctx: any) {
|
||||
async function getAtPath(ctx: any, varName: string, path?: string) {
|
||||
try {
|
||||
// base may be Promise; wait once
|
||||
let current = await ctx[varName];
|
||||
let current = getRootSandboxValue(ctx, varName);
|
||||
if (!path) return current;
|
||||
const norm = String(path || '').replace(/^\./, '');
|
||||
const segments = _.toPath(norm);
|
||||
for (const seg of segments) {
|
||||
if (current == null) return undefined;
|
||||
let val = current[seg];
|
||||
if (val && typeof val['then'] === 'function') {
|
||||
val = await val;
|
||||
}
|
||||
current = val;
|
||||
current = await getSandboxProperty(current, seg);
|
||||
}
|
||||
return current;
|
||||
return wrapSandboxValue(current);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -156,21 +398,22 @@ async function asyncGetValuesByPath(obj: any, path: string, defaultValue?: any):
|
||||
if (Array.isArray(currentValue)) {
|
||||
shouldReturnArray = true;
|
||||
const rest = keys.slice(i).join('.');
|
||||
const parts = await Promise.all(currentValue.map((el) => asyncGetValuesByPath(el, rest, defaultValue)));
|
||||
const parts = await Promise.all(
|
||||
Array.prototype.map.call(currentValue, (el) => asyncGetValuesByPath(el, rest, defaultValue)),
|
||||
);
|
||||
// 将数组或标量统一拍平一层
|
||||
for (const p of parts) {
|
||||
if (Array.isArray(p)) result.push(...p);
|
||||
else if (typeof p !== 'undefined') result.push(p);
|
||||
if (Array.isArray(p)) {
|
||||
for (let index = 0; index < p.length; index++) {
|
||||
if (typeof p[index] !== 'undefined') result.push(p[index]);
|
||||
}
|
||||
} else if (typeof p !== 'undefined') result.push(p);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 普通对象属性访问,若为 Promise 则等待
|
||||
let val = currentValue?.[key];
|
||||
if (val && typeof (val as any).then === 'function') {
|
||||
val = await val;
|
||||
}
|
||||
currentValue = val;
|
||||
currentValue = await getSandboxProperty(currentValue, key);
|
||||
|
||||
if (i === keys.length - 1) {
|
||||
result.push(currentValue);
|
||||
|
||||
Reference in New Issue
Block a user