包:@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 那道闸门在此之上给出的是一个它自己也不该给的通过。
包:
@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:lead有三条旁路,都只做存在性检查:displayField、fields.name、fields.title。任何一条命中的字段都会进入结果集,无论它是否在排除表里、类型是否可搜、是否
hidden。复现
对
packages/spec的构建产物直接调用(实测,非推演):这不是构造出来的场景
displayField的实参是对象的nameField,而nameField由 ADR-0079 的provisionPrimary(schema, { synthesize: false })在注册时自动指定(
packages/objectql/src/registry.ts:842):当对象上有可作标题的字段时就指认一个。对于「唯一文本列就是
id」的表——系统表、连接表、追加型日志表都是这个形状——它指认的就是id。我在真实注册对象上通过引擎观察到过展开结果,不是只调了这个 helper:
对象定义是
{ 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:never目前不成立。建议修法
把
lead过同一条谓词——它是否可搜,和它是不是主标题无关;lead的作用应该只是排序(把主标题排在最前),不该是准入。不通过就退回原
names:这样 lead 的排序意图完整保留,只是不再兼作后门。
影响面判断
不是数据泄露,也不属于 #4419 那类「谓词被丢弃」——它产生的是一个更窄且语义错误的谓词
(搜到的是 id 里恰好含该子串的行)。真正的代价是:宣称的不变量没有被执行,
而 #4254 那道闸门在此之上给出的是一个它自己也不该给的通过。