Skip to content

$search 自动字段集:nameField/name/title 被无条件前置,绕过 SEARCH_AUTO_EXCLUDED_FIELDS——搜索会打到主键 #4483

Description

@os-zhuang

@objectstack/specpackages/spec/src/data/search-fields.ts
发现于:核对 #4419 时(PR #4459),与该 issue 无关,故当时留在 PR 之外
核实基线main @ ec975f1

摘要

autoDefaultFields 逐字段过了三道排除条件来决定哪些字段可以被 $search 自动扫描,
然后把「主标题字段」无条件前置——不再复查那三道条件。于是排除表对这一个字段完全失效。

后果是 $search 会对主键做子串扫描:一个只有 id(text) 和数值列的对象,
ADR-0079 会把它的 nameField 定成 id$search 随即展开成 { id: { $contains: <term> } }

位置

packages/spec/src/data/search-fields.ts:65-82

function autoDefaultFields(fields, displayField?): string[] {
  const names = Object.keys(fields).filter((f) => {
    if (SEARCH_AUTO_EXCLUDED_FIELDS.has(f)) return false;   // ← 66-74 行:三道排除
    const meta = fields[f];
    if (!meta || meta.hidden) return false;
    const t = meta.type;
    if (!t) return false;
    if (SEARCH_AUTO_EXCLUDED_TYPES.has(t)) return false;
    return SEARCHABLE_TEXTUAL_TYPES.has(t) || SEARCHABLE_ENUM_TYPES.has(t);
  });
  // Lead with the display/name field when present.
  const lead = displayField && fields[displayField] ? displayField   // ← 75-81 行:只查存在
    : fields.name ? 'name'
    : fields.title ? 'title'
    : undefined;
  if (!lead) return names;
  return [lead, ...names.filter((f) => f !== lead)];                 // ← 无条件前置
}

lead三条旁路,都只做存在性检查:displayFieldfields.namefields.title
任何一条命中的字段都会进入结果集,无论它是否在排除表里、类型是否可搜、是否 hidden

复现

packages/spec 的构建产物直接调用(实测,非推演):

import { resolveSearchFieldResolution } from '@objectstack/spec/data';

const fields = { id: { type: 'text' }, amount: { type: 'number' } };

resolveSearchFieldResolution({ fields });
// → { allowed: [], source: 'auto' }          ✅ 正确:id 在排除表,amount 类型不符

resolveSearchFieldResolution({ fields, displayField: 'id' });
// → { allowed: ['id'], source: 'auto' }      ❌ 排除表被绕过

这不是构造出来的场景

displayField 的实参是对象的 nameField,而 nameField 由 ADR-0079 的
provisionPrimary(schema, { synthesize: false }) 在注册时自动指定
packages/objectql/src/registry.ts:842):当对象上有可作标题的字段时就指认一个。
对于「唯一文本列就是 id」的表——系统表、连接表、追加型日志表都是这个形状——它指认的就是 id

我在真实注册对象上通过引擎观察到过展开结果,不是只调了这个 helper:

findOne('ledger', { search: 'anything' })
  → 交给 driver 的 AST:{ object: 'ledger', limit: 1,
                         where: { $or: [{ id: { $contains: 'anything' } }] } }

对象定义是 { id: { type: 'text', primaryKey: true }, amount: { type: 'number' } }

第二层后果:#4254 的 REST 入口闸门也跟着放行

resolveSearchFieldResolution 不只服务引擎侧的自动默认集,它同时是 #4254 那道
REST 入口闸门的判据(packages/metadata-protocol/src/protocol.ts:3751)。
闸门的职责是「拒绝引擎不会扫描的 $searchFields 覆盖」——但因为 id 现在确实在 allowed 里,
一个 $searchFields=id 的请求会被接受,而不是按设计拒绝。

所以这一处偏差同时松动了两层:自动默认集,以及本该收口它的那道闸门。

被打破的是该模块自己写下的不变量

search-fields.ts:42

/** System / audit / heavy fields never auto-included. */
export const SEARCH_AUTO_EXCLUDED_FIELDS: ReadonlySet<string> = new Set([
  'id', '_id', 'created', 'modified', 'created_at', 'updated_at',
  'created_by', 'updated_by', 'owner_id', 'organization_id', 'space', 'company_id',
]);

never 目前不成立。

建议修法

lead 过同一条谓词——它是否可搜,和它是不是主标题无关;lead 的作用应该只是排序
(把主标题排在最前),不该是准入。不通过就退回原 names

const eligible = (f?: string) => !!f && names.includes(f);
const lead = eligible(displayField) ? displayField
  : eligible('name') ? 'name'
  : eligible('title') ? 'title'
  : undefined;

这样 lead 的排序意图完整保留,只是不再兼作后门。

影响面判断

不是数据泄露,也不属于 #4419 那类「谓词被丢弃」——它产生的是一个更窄且语义错误的谓词
(搜到的是 id 里恰好含该子串的行)。真正的代价是:宣称的不变量没有被执行,
#4254 那道闸门在此之上给出的是一个它自己也不该给的通过。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions