Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fls-mask-write-response.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
Expand Down
9 changes: 7 additions & 2 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down