From eb5835cd5da25b7fe1370b72bdc405d07af0a794 Mon Sep 17 00:00:00 2001 From: hongboji <116709317+hongboji@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:53:51 +0800 Subject: [PATCH 1/3] fix(map): preserve raw values in detail display (#9653) --- .../fieldModels/DisplayMapFieldModel.tsx | 38 +++++++++++ .../__tests__/DisplayMapFieldModel.test.tsx | 66 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/plugins/@nocobase/plugin-map/src/client/models/fieldModels/__tests__/DisplayMapFieldModel.test.tsx diff --git a/packages/plugins/@nocobase/plugin-map/src/client/models/fieldModels/DisplayMapFieldModel.tsx b/packages/plugins/@nocobase/plugin-map/src/client/models/fieldModels/DisplayMapFieldModel.tsx index 435d4bc346b..28fd5a502e2 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/models/fieldModels/DisplayMapFieldModel.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/models/fieldModels/DisplayMapFieldModel.tsx @@ -10,6 +10,8 @@ import React from 'react'; import { DisplayTitleFieldModel, TableColumnModel } from '@nocobase/client'; import { tExpr } from '@nocobase/flow-engine'; +import { Typography } from 'antd'; +import { css } from '@emotion/css'; import { MapComponent } from '../MapComponent'; import { NAMESPACE } from '../../locale'; @@ -26,6 +28,42 @@ export class DisplayMapFieldModel extends DisplayTitleFieldModel { return null; } + render(): any { + const { value, displayStyle, overflowMode, width } = this.props; + + if (displayStyle === 'map') { + return this.renderComponent(value); + } + + return ( + + {this.renderComponent(value)} + + ); + } + public renderComponent(value) { return ( ({ + MapComponent: (props) =>
{JSON.stringify(props.value)}
, +})); + +describe('DisplayMapFieldModel', () => { + function createModel(props: Record) { + const engine = new FlowEngine(); + engine.registerModels({ DisplayPointFieldModel }); + + const model = engine.createModel({ + use: DisplayPointFieldModel, + uid: `display-point-${props.displayStyle || 'text'}`, + props, + }); + + model.context.defineProperty('collectionField', { + value: { + uiSchema: { + 'x-component-props': { + mapType: 'amap', + }, + }, + }, + }); + + return model; + } + + it('renders point values as text without converting the coordinate array to an empty string', () => { + const model = createModel({ + displayStyle: 'text', + value: [116.397, 39.907], + }); + + render(<>{model.render()}); + + expect(screen.getByText('116.397,39.907')).toBeInTheDocument(); + }); + + it('passes the raw point value to the map component in map mode', () => { + const value = [116.397, 39.907]; + const model = createModel({ + displayStyle: 'map', + value, + }); + + render(<>{model.render()}); + + expect(screen.getByTestId('map-component')).toHaveTextContent(JSON.stringify(value)); + }); +}); From e2f2078cbb68279969f0a54afa81761b19866984 Mon Sep 17 00:00:00 2001 From: YANG QIA <2013xile@gmail.com> Date: Sun, 7 Jun 2026 12:33:54 +0800 Subject: [PATCH 2/3] fix(plugin-field-sort): generate unique sort values in bulk create (#9684) --- .../server/__tests__/xlsx-importer.test.ts | 57 +++++++++++++++++++ .../src/server/sort-field.ts | 55 ++++++++++-------- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts b/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts index 3f9b66379b8..e89658e7bf7 100644 --- a/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts +++ b/packages/plugins/@nocobase/plugin-action-import/src/server/__tests__/xlsx-importer.test.ts @@ -2299,6 +2299,63 @@ describe('basic importer', () => { expect(users[1].get('email')).toBe('test2@test.com'); }); + it('should generate different sort values for imported rows', async () => { + await app.destroy(); + app = await createMockServer({ + plugins: ['field-sort', 'data-source-main', 'error-handler'], + }); + + const Task = app.db.collection({ + name: 'tasks', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'sort', + name: 'sort', + }, + ], + }); + + await app.db.sync(); + + const columns = [ + { + dataIndex: ['name'], + defaultTitle: 'Name', + }, + ]; + + const templateCreator = new TemplateCreator({ + collection: Task, + columns, + }); + + const template = (await templateCreator.run({ returnXLSXWorkbook: true })) as XLSX.WorkBook; + const worksheet = template.Sheets[template.SheetNames[0]]; + + XLSX.utils.sheet_add_aoa(worksheet, [['task1'], ['task2'], ['task3']], { + origin: 'A2', + }); + + const importer = new XlsxImporter({ + collectionManager: app.mainDataSource.collectionManager, + collection: Task, + columns, + workbook: template, + }); + + await importer.run(); + + const tasks = await Task.repository.find({ + sort: ['id'], + }); + + expect(tasks.map((task) => task.get('sort'))).toEqual([1, 2, 3]); + }); + describe('template creator', () => { it('should create template with explain and field descriptions', async () => { const User = app.db.collection({ diff --git a/packages/plugins/@nocobase/plugin-field-sort/src/server/sort-field.ts b/packages/plugins/@nocobase/plugin-field-sort/src/server/sort-field.ts index d931670e166..f97b2db7745 100644 --- a/packages/plugins/@nocobase/plugin-field-sort/src/server/sort-field.ts +++ b/packages/plugins/@nocobase/plugin-field-sort/src/server/sort-field.ts @@ -24,33 +24,42 @@ export class SortField extends Field { const { model } = this.context.collection; instances = Array.isArray(instances) ? instances : [instances]; - for (const instance of instances) { - if (from == 'create' && isNumber(instance.get(name))) { - continue; - } - if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) { - continue; - } + await (this.constructor).lockManager.runExclusive( + this.context.collection.name, + async () => { + const maxCache = new Map(); - const where = {}; + for (const instance of instances) { + if (from == 'create' && isNumber(instance.get(name))) { + continue; + } + if (isNumber(instance.get(name)) && instance._previousDataValues[scopeKey] == instance[scopeKey]) { + continue; + } - if (scopeKey) { - const value = instance.get(scopeKey); - if (value !== undefined && value !== null) { - where[scopeKey] = value; - } - } + const where = {}; + let cacheKey = '__default__'; - await (this.constructor).lockManager.runExclusive( - this.context.collection.name, - async () => { - const max = await model.max(name, { ...options, where }); - const newValue = (max || 0) + 1; + if (scopeKey) { + const value = instance.get(scopeKey); + if (value !== undefined && value !== null) { + where[scopeKey] = value; + cacheKey = `${typeof value}:${String(value)}`; + } + } + + if (!maxCache.has(cacheKey)) { + const max = await model.max(name, { ...options, where }); + maxCache.set(cacheKey, max || 0); + } + + const newValue = (maxCache.get(cacheKey) ?? 0) + 1; + maxCache.set(cacheKey, newValue); instance.set(name, newValue); - }, - 2000, - ); - } + } + }, + 2000, + ); }; onScopeChange = async (instance, options) => { From 2b73dae8deffa93c7cdbc628ef2517ae314fe764 Mon Sep 17 00:00:00 2001 From: gchust Date: Sun, 7 Jun 2026 22:32:12 +0800 Subject: [PATCH 3/3] fix: server context resolve (#9656) * fix(plugin-flow-engine): harden variable resolver sandbox * fix(plugin-flow-engine): allow plain then sandbox fields * fix: bug --- .../__tests__/variables.resolver.unit.test.ts | 248 ++++++++++++++ .../src/server/template/contexts.ts | 38 +++ .../src/server/template/resolver.ts | 315 ++++++++++++++++-- 3 files changed, 565 insertions(+), 36 deletions(-) diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolver.unit.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolver.unit.test.ts index 97eee43fe49..162ccbd4d5c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolver.unit.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/variables.resolver.unit.test.ts @@ -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(); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/contexts.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/contexts.ts index 6345d2b9df5..bc3a597ac6b 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/contexts.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/contexts.ts @@ -12,6 +12,12 @@ import { ResourcerContext } from '@nocobase/resourcer'; type Getter = (ctx: ServerBaseContext) => T | Promise; +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(); + 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; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/resolver.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/resolver.ts index e23c678bd07..670896e21dd 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/resolver.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/template/resolver.ts @@ -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(); +const sandboxProxyMeta = new WeakMap(); +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 { + 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); + + 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) { + 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()): Promise { + 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 = {}; + 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 = {}; + seen.set(source, out); + for (const key of Object.keys(source as Record)) { + 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);