diff --git a/.changeset/fls-mask-write-response.md b/.changeset/fls-mask-write-response.md new file mode 100644 index 000000000..45077be17 --- /dev/null +++ b/.changeset/fls-mask-write-response.md @@ -0,0 +1,10 @@ +--- +"@objectstack/plugin-security": patch +--- + +Fix field-level-security read leak on mutation responses. The security +middleware only masked read-protected fields on `find`/`findOne` results, so a +caller with edit-but-not-field-read could `insert`/`update` a record and read a +read-protected field back out of the echoed post-image (field WRITES were +already blocked, but the response image was not masked). The mask now also +covers `insert`/`update` results, matching read behavior. diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d65c814c3..f08e5d6ea 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -716,6 +716,52 @@ describe('SecurityPlugin', () => { }); }); + it('FLS write — the record echoed back by an update is masked (no read-leak of hidden fields)', async () => { + // Regression: a caller with edit-but-not-field-read must not be able to + // read a read-protected field back out of the mutation response. The write + // itself only touches an editable field; the engine echoes the full row. + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [flsPolicySet], + objectFields: ['id', 'owner_id', 'name', 'salary', 'ssn'], + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', + operation: 'update', + data: { name: 'edited' }, // only editable field written → passes write gate + // Engine's echoed post-image (pre-set; harness `next` is a no-op). + result: { id: 't1', name: 'edited', salary: 9999, ssn: 'secret-leak' }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + }; + await harness.run(opCtx); + // ssn (readable:false) stripped; salary (readable:true) retained. + expect(opCtx.result.ssn).toBeUndefined(); + expect(opCtx.result.salary).toBe(9999); + expect(opCtx.result.name).toBe('edited'); + }); + + it('FLS write — the record echoed back by an insert is masked', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [flsPolicySet], + objectFields: ['id', 'owner_id', 'name', 'salary', 'ssn'], + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A' }, + result: { id: 't2', name: 'A', salary: 100, ssn: 'server-generated' }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + }; + await harness.run(opCtx); + expect(opCtx.result.ssn).toBeUndefined(); + expect(opCtx.result.salary).toBe(100); + }); + it('fails CLOSED when permission resolution throws — denies, never bypasses (P0-2)', async () => { const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7605f0787..bd1f0c8da 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -732,8 +732,13 @@ export class SecurityPlugin implements Plugin { await next(); - // 4. Field-level security: mask restricted fields in read results - if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) { + // 4. Field-level security: mask restricted fields in returned records. + // Covers reads AND the record echoed back by a write — otherwise a caller + // with edit-but-not-field-read could PATCH a record and read a + // read-protected field back out of the mutation response (FLS bypass). + // Field WRITES are already blocked upstream (detectForbiddenWrites); this + // closes the read leak on the response image. + if (opCtx.result && ['find', 'findOne', 'insert', 'update'].includes(opCtx.operation)) { let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); // [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask. fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);