From c39a0ccc13ee97e9e784aa987576905d44971f7e Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:28:44 -0700 Subject: [PATCH 1/2] =?UTF-8?q?test(security):=20red=20=E2=80=94=20remaini?= =?UTF-8?q?ng=20repos=20accept=20unallowlisted=20fields=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude Code (Sonnet) Co-Authored-By: Claude Fable 5 --- .../repositories/auditLogRepository.test.js | 19 +++++++++++++++++++ .../collaboratorRepository.test.js | 17 +++++++++++++++++ .../repositories/shareLinkRepository.test.js | 19 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/server/src/repositories/auditLogRepository.test.js b/server/src/repositories/auditLogRepository.test.js index b5be21c..b817b46 100644 --- a/server/src/repositories/auditLogRepository.test.js +++ b/server/src/repositories/auditLogRepository.test.js @@ -88,4 +88,23 @@ describe('auditLogRepository', () => { const logs = await listAuditLogs(prisma, { limit: 1 }); expect(logs).toHaveLength(1); }); + + it('does not allow hash/previousHash to be set via create (integrity field mass assignment)', async () => { + const entry = await createAuditLog(prisma, { + userId: 'user-1', + eventType: 'AUTH_LOGIN', + action: 'login', + resourceId: 'draft-1', + metadata: { foo: 'bar' }, + hash: 'forged-hash', + previousHash: 'forged-previous-hash', + }); + + expect(entry.eventType).toBe('AUTH_LOGIN'); + expect(entry.action).toBe('login'); + expect(entry.resourceId).toBe('draft-1'); + expect(entry.metadata).toEqual({ foo: 'bar' }); + expect(entry.hash).toBeUndefined(); + expect(entry.previousHash).toBeUndefined(); + }); }); diff --git a/server/src/repositories/collaboratorRepository.test.js b/server/src/repositories/collaboratorRepository.test.js index ae0fe95..7f4eb13 100644 --- a/server/src/repositories/collaboratorRepository.test.js +++ b/server/src/repositories/collaboratorRepository.test.js @@ -152,4 +152,21 @@ describe('collaboratorRepository', () => { const updated = await updateCollaboratorRole(prisma, invite.id, 'admin'); expect(updated.role).toBe('admin'); }); + + it('does not allow collaboratorUserId to be set via invite (mass assignment)', async () => { + const invite = await inviteCollaborator(prisma, { + documentId: 'doc-1', + documentOwnerId: 'owner-1', + collaboratorEmail: 'friend@example.com', + role: 'editor', + invitedBy: 'owner-1', + inviteToken: 'token-abc', + collaboratorUserId: 'attacker-id', + }); + + expect(invite.collaboratorEmail).toBe('friend@example.com'); + expect(invite.role).toBe('editor'); + expect(invite.inviteToken).toBe('token-abc'); + expect(invite.collaboratorUserId).toBeFalsy(); + }); }); diff --git a/server/src/repositories/shareLinkRepository.test.js b/server/src/repositories/shareLinkRepository.test.js index e1d8604..2e4a038 100644 --- a/server/src/repositories/shareLinkRepository.test.js +++ b/server/src/repositories/shareLinkRepository.test.js @@ -96,4 +96,23 @@ describe('shareLinkRepository', () => { expect(revoked.isActive).toBe(false); expect(revoked.revokedBy).toBe('owner-1'); }); + + it('does not allow revokedBy/revokedAt to be set via create (mass assignment)', async () => { + const link = await createShareLink(prisma, { + documentId: 'doc-1', + documentOwnerId: 'owner-1', + token: 'tok-1', + passcode: 'ABC123', + accessLevel: 'view', + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + revokedBy: 'attacker', + revokedAt: new Date('2020-01-01'), + }); + + expect(link.documentId).toBe('doc-1'); + expect(link.token).toBe('tok-1'); + expect(link.accessLevel).toBe('view'); + expect(link.revokedBy).toBeFalsy(); + expect(link.revokedAt).toBeFalsy(); + }); }); From f83d21b2e705575e0b28f65258a66c92f7efcac0 Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:29:24 -0700 Subject: [PATCH 2/2] fix(security): allowlist auditLog/collaborator/shareLink repo writes (#53) Assisted-by: Claude Code (Sonnet) Co-Authored-By: Claude Fable 5 --- server/src/repositories/auditLogRepository.js | 21 ++++++++++++++++++- .../repositories/collaboratorRepository.js | 19 ++++++++++++++++- .../src/repositories/shareLinkRepository.js | 19 ++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/server/src/repositories/auditLogRepository.js b/server/src/repositories/auditLogRepository.js index 9daf7a6..34f2144 100644 --- a/server/src/repositories/auditLogRepository.js +++ b/server/src/repositories/auditLogRepository.js @@ -1,10 +1,29 @@ // Repository for the AuditLog aggregate. +import { pick } from './pick.js'; + +// Client-writable fields (see prisma/schema.prisma AuditLog model). Excludes +// id and the previousHash/hash integrity fields, which must never be +// settable by the caller. +const WRITABLE_FIELDS = [ + 'userId', + 'userEmail', + 'eventType', + 'action', + 'resourceType', + 'resourceId', + 'metadata', + 'ipAddress', + 'userAgent', + 'sessionId', + 'timestamp', +]; + export async function createAuditLog(prisma, data) { return prisma.auditLog.create({ data: { + ...pick(data, WRITABLE_FIELDS), timestamp: data.timestamp ?? new Date(), - ...data, }, }); } diff --git a/server/src/repositories/collaboratorRepository.js b/server/src/repositories/collaboratorRepository.js index 9392c27..5f3751c 100644 --- a/server/src/repositories/collaboratorRepository.js +++ b/server/src/repositories/collaboratorRepository.js @@ -1,10 +1,27 @@ // Repository for the DocumentCollaborator aggregate (document sharing with // role-based access, mirroring src/utils/collaborationService.ts). +import { pick } from './pick.js'; + +// Client-writable fields (see prisma/schema.prisma DocumentCollaborator +// model). Excludes id, collaboratorUserId/status/acceptedAt (set only by +// acceptCollaboratorInvite), and revokeCollaborator's status transition. +const WRITABLE_FIELDS = [ + 'documentId', + 'documentOwnerId', + 'collaboratorEmail', + 'role', + 'invitedBy', + 'invitedByName', + 'invitedAt', + 'inviteToken', + 'inviteExpiresAt', +]; + export async function inviteCollaborator(prisma, data) { return prisma.documentCollaborator.create({ data: { - ...data, + ...pick(data, WRITABLE_FIELDS), collaboratorEmail: data.collaboratorEmail.toLowerCase(), status: 'pending', invitedAt: data.invitedAt ?? new Date(), diff --git a/server/src/repositories/shareLinkRepository.js b/server/src/repositories/shareLinkRepository.js index ca619b8..aa775f3 100644 --- a/server/src/repositories/shareLinkRepository.js +++ b/server/src/repositories/shareLinkRepository.js @@ -1,10 +1,27 @@ // Repository for the ShareLink aggregate (passcode-protected document // share links, mirroring src/utils/collaborationService.ts). +import { pick } from './pick.js'; + +// Client-writable fields (see prisma/schema.prisma ShareLink model). +// Excludes id, lastAccessedAt/lastAccessedBy (set only by +// incrementShareLinkAccess), and revokedAt/revokedBy (set only by +// revokeShareLink). +const WRITABLE_FIELDS = [ + 'documentId', + 'documentOwnerId', + 'token', + 'passcode', + 'accessLevel', + 'expiresAt', + 'accessCount', + 'isActive', +]; + export async function createShareLink(prisma, data) { return prisma.shareLink.create({ data: { - ...data, + ...pick(data, WRITABLE_FIELDS), accessCount: data.accessCount ?? 0, isActive: data.isActive ?? true, },