Skip to content

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

Closed

Description

@os-zhuang

包:@objectstack/spec(packages/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 有三条旁路,都只做存在性检查:displayField、fields.name、fields.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 那道闸门在此之上给出的是一个它自己也不该给的通过。

Activity

  1. os-zhuang commented on Aug 1, 2026

    @os-zhuang
    ContributorAuthor

    交接说明(进行中)

    分支:claude/v17-verification-defects-gnf9e6-data-envelope
    提交:f566a1d fix(spec): the $search auto field set's lead ORDERS the set, it must not admit one (#4483)

    已完成

    • 按 issue 的建议修法改了 packages/spec/src/data/search-fields.ts 的 autoDefaultFields:lead 现在从已过滤的 names 里挑,而不是只做存在性检查。三条旁路(displayField / fields.name / fields.title)统一过同一条谓词,排序意图完整保留,不再兼作准入后门。
    • 新增回归测试 packages/spec/src/data/search-fields.test.ts(10 个用例)。已实测修复前 6 个失败、修复后全绿,覆盖:
    • pnpm --filter @objectstack/spec test 全绿(286 files / 7232 tests)。

    当前卡在哪

    算力额度可能耗尽,先把改动推到远端。这一条本身的代码与测试已经完成,尚未做的收尾:

    下一步具体该做什么

    1. 因为动了 packages/spec,按 AGENTS.md 跑:pnpm --filter @objectstack/spec build → check:generated → 只对证明过期的产物 --fix。(预期无产物变化:没有改 .describe(),也没有新增/删除导出。)
    2. pnpm typecheck。
    3. 真实启动验证:pnpm dev -- --fresh -p 38103,对一个「唯一文本列是 id」的对象发 $search,确认下发给 driver 的 AST 里不再出现 { id: { $contains: … } };并确认 $searchFields=id 返回 400 而不是被接受。用完 kill $(lsof -ti tcp:38103)。
    4. 与本卡其余五个缺陷(data: created_at is client-writable on an ordinary PATCH — the audit anchor can be forged silently #4447 / data: PATCH/DELETE of a nonexistent record answer 200 success instead of RECORD_NOT_FOUND #4435 / data: unsupported-filter-operator refusal ships without error.code and leaks the [sql-driver] prefix #4436 / data: a lookup accepts an id that does not exist in the referenced object — including the RBAC permission-set link tables #4441 / actions: sandbox capability denial answers 400 as a deliberate rejection, not the 500 the crash contract promises #4431)一起开草稿 PR 打到 main。

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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