From 589e0d5c3deecddce4947588b4770c9f4dacd033 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:40:44 +0000 Subject: [PATCH] =?UTF-8?q?test(objectql):=20pin=20`listDrafts`=20?= =?UTF-8?q?=E7=9A=84=20header-only=20=E6=8A=95=E5=BD=B1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20#6599=20=E6=89=80=E8=BF=B0=E6=B3=84=E9=9C=B2?= =?UTF-8?q?=E5=90=91=E9=87=8F=E7=9A=84=E5=94=AF=E4=B8=80=E5=B1=8F=E9=9A=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6599 认为 `GET /meta/_drafts` 会吐出 draft 对象的完整 `fields`。实测不成立: `SysMetadataRepository.listDrafts` 是一个显式六键投影,从不读行上的 body。 两条路由(rest-server.ts / domains/meta.ts)确实是 `protocol.listDrafts()` 的 裸透传 —— 它们今天安全,完全依赖这一个投影。本用例把该依赖钉死:任何人把 item 体加宽进投影,两条路由会立刻开始无掩码地服务对象 schema,而 ADR-0106 的 mask 覆盖不到这条路由。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- ...ys-metadata-repository-list-drafts.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts index f5f7891d4a..af924f31b9 100644 --- a/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts +++ b/packages/objectql/src/sys-metadata-repository-list-drafts.test.ts @@ -98,4 +98,86 @@ describe('SysMetadataRepository.listDrafts (ADR-0033)', () => { where: { organization_id: null, state: 'draft', type: 'object', package_id: 'app.edu' }, }); }); + + /** + * [#6599] The header projection is a DISCLOSURE BOUNDARY, not a payload-size + * optimization — and it is the ONLY thing standing between a draft row and an + * unmasked object schema on the wire. + * + * `GET /api/v1/meta/_drafts` (`packages/rest/src/rest-server.ts`) and + * `GET /metadata/_drafts` (`packages/runtime/src/domains/meta.ts`) both call + * `protocol.listDrafts()` and serve the result verbatim — no ADR-0106 + * `applyObjectSchemaMask`, no capability gate, nothing beyond `requireAuth`. + * That is safe today for exactly one reason: this function reads six columns + * and never touches the row's stored body. #6599 was filed believing the body + * DID come through (`.item.fields.salary_grade`); it does not, and this case + * is what keeps that true. + * + * So the moment anyone "helpfully" widens the projection to carry the item — + * a Studio diff view wanting field-level detail is the obvious pull — both + * routes start serving full object schemas, `requiredPermissions`, picklist + * option values and `formula` business IP to any authenticated caller, and + * ADR-0106's mask is bypassed wholesale via a route it never covered. This + * test goes red at that instant. If you are here because it went red: the + * widening needs the ADR-0106 projection (or an authoring gate) on BOTH + * routes FIRST — see #6599 for the (a)/(b) fork. + */ + it('projects headers ONLY — a draft row\'s stored body never reaches the caller (#6599)', async () => { + // A draft row carrying everything ADR-0106 names as leaking with a field: + // a sensitive picklist, the capability guarding it, and a formula. + const sensitive = { + name: 'account', + fields: { + salary_grade: { + type: 'select', + label: 'Salary Grade', + options: [{ value: 'band_a', label: 'Band A' }], + requiredPermissions: ['view_compensation'], + }, + bonus_formula: { type: 'formula', formula: 'salary_grade == "band_a" ? 0.2 : 0.1' }, + }, + }; + const rows = [ + { + type: 'object', + name: 'account', + state: 'draft', + package_id: 'app.hr', + organization_id: null, + updated_at: 't1', + updated_by: 'ai', + // Both spellings the repository layer has used for the stored document. + body: JSON.stringify(sensitive), + metadata_json: JSON.stringify(sensitive), + }, + ]; + const { repo } = makeRepo(rows as any); + const out = await repo.listDrafts({ type: 'object' }); + + // Exactly the six header keys — no `item`, no `body`, no `fields`. + expect(Object.keys(out[0]).sort()).toEqual([ + 'name', + 'organizationId', + 'packageId', + 'type', + 'updatedAt', + 'updatedBy', + ]); + + // Whole-payload sweep: no residue of the schema anywhere in what is served. + // Asserting on the serialized form (rather than key-by-key) is deliberate — + // it catches a body smuggled in under ANY key name, which a key allowlist + // check alone would miss. + const wire = JSON.stringify(out); + for (const secret of [ + 'salary_grade', + 'bonus_formula', + 'view_compensation', + 'band_a', + 'formula', + 'fields', + ]) { + expect(wire).not.toContain(secret); + } + }); });