({
+ 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