From 0ed43238266118f4beb9691d0d5e40a1c7e0271e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 12:52:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20socket=20cluster=20=E2=80=94=20M1?= =?UTF-8?q?0=20(isActive=20gate),=20M11=20(permission-scoped=20entity=20ro?= =?UTF-8?q?oms),=20L20=20(join:entity=20validation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/socket/server.ts | 192 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 14 deletions(-) diff --git a/src/lib/socket/server.ts b/src/lib/socket/server.ts index ee8bf725..f6037100 100644 --- a/src/lib/socket/server.ts +++ b/src/lib/socket/server.ts @@ -2,19 +2,52 @@ import { Server } from 'socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import type { Server as HTTPServer } from 'node:http'; import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; import { redis } from '@/lib/redis'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; -import { userProfiles, userPortRoles } from '@/lib/db/schema/users'; +import { + userProfiles, + userPortRoles, + portRoleOverrides, + userPermissionOverrides, +} from '@/lib/db/schema/users'; +import type { RolePermissions } from '@/lib/db/schema/users'; import { berths } from '@/lib/db/schema/berths'; import { clients } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; +import { deepMerge } from '@/lib/api/helpers'; import { logger } from '@/lib/logger'; import type { ServerToClientEvents, ClientToServerEvents } from './events'; let io: Server | null = null; +/** + * L20(b): allow-list + uuid validation for the `join:entity` / `leave:entity` + * payloads. The TS union on `ClientToServerEvents` is a compile-time contract + * only - the wire payload is attacker-controllable, so we re-validate at the + * trust boundary before any DB lookup. Fails closed: a malformed `{type,id}` + * is dropped rather than reaching `userCanJoinEntity`. + */ +const ENTITY_JOIN_SCHEMA = z.object({ + type: z.enum(['berth', 'client', 'interest']), + id: z.string().uuid(), +}); + +/** + * M11: each joinable entity room is gated by the matching `.view` + * leaf so a socket can only subscribe to entity broadcasts the REST API would + * also grant (`GET /clients/[id]/notes` etc. require `clients.view`). Without + * this, room membership was port-membership-only, leaking note-content + * previews to users whose role grants zero permissions on that resource. + */ +const ENTITY_VIEW_RESOURCE: Record<'berth' | 'client' | 'interest', keyof RolePermissions> = { + berth: 'berths', + client: 'clients', + interest: 'interests', +}; + const DEV_ORIGIN_PATTERNS = [ /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/, /^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/, @@ -47,7 +80,12 @@ async function userCanAccessPort(userId: string, portId: string): Promise.view` + * leaf (`berths.view` / `clients.view` / `interests.view`). Previously this + * was membership-only, so a user whose role granted zero permissions on the + * resource could still join the room and receive note-content previews that + * the REST API (`GET /clients/[id]/notes`, gated `withPermission`) would 403. + * We resolve the *effective* permission map (role → port-role override → + * per-user residential toggle → per-user override), mirroring the HTTP + * `withAuth` chain in src/lib/api/helpers.ts so the two surfaces agree. */ async function userCanJoinEntity( userId: string, @@ -67,7 +115,9 @@ async function userCanJoinEntity( const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, userId), }); - if (profile?.isSuperAdmin) return true; + // M10: deactivated users may not join any room (deny before super-admin). + if (!profile || !profile.isActive) return false; + if (profile.isSuperAdmin) return true; let entityPortId: string | null = null; if (type === 'berth') { @@ -82,10 +132,80 @@ async function userCanJoinEntity( } if (!entityPortId) return false; - const role = await db.query.userPortRoles.findFirst({ - where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, entityPortId)), + // M11: resolve effective permissions for this user in the entity's port and + // require the matching `.view` leaf. A null/missing role row means + // no port access; a missing leaf means no view permission. + const permissions = await resolveEffectivePermissions(userId, entityPortId); + if (!permissions) return false; + + const resource = ENTITY_VIEW_RESOURCE[type]; + const resourcePerms = permissions[resource] as Record | undefined; + return Boolean(resourcePerms?.view); +} + +/** + * Resolve the effective `RolePermissions` map for a non-super-admin user in a + * given port, applying the same layering as the HTTP `withAuth` path + * (src/lib/api/helpers.ts:174-238): base role → port-level role override + * (deep-merge) → per-user residential toggle → per-user override (deep-merge). + * + * Returns null when the user holds no role in the port. Super-admins are + * handled by the caller (they bypass permission checks entirely), so this is + * only invoked for non-super-admins. + */ +async function resolveEffectivePermissions( + userId: string, + portId: string, +): Promise { + const portRole = await db.query.userPortRoles.findFirst({ + where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)), + with: { role: true }, }); - return Boolean(role); + if (!portRole) return null; + + let permissions: RolePermissions = { ...(portRole.role.permissions as RolePermissions) }; + + // Port-specific role overrides (deep-merge on top of the base role). + const override = await db.query.portRoleOverrides.findFirst({ + where: and(eq(portRoleOverrides.portId, portId), eq(portRoleOverrides.roleId, portRole.roleId)), + }); + if (override?.permissionOverrides) { + permissions = deepMerge( + permissions as unknown as Record, + override.permissionOverrides as Record, + ) as unknown as RolePermissions; + } + + // Per-user residential toggle. + if (portRole.residentialAccess) { + permissions = { + ...permissions, + residential_clients: { view: true, create: true, edit: true, delete: true }, + residential_interests: { + view: true, + create: true, + edit: true, + delete: true, + change_stage: true, + }, + }; + } + + // Per-user permission overrides (final layer - wins over role + port override). + const userOverride = await db.query.userPermissionOverrides.findFirst({ + where: and( + eq(userPermissionOverrides.userId, userId), + eq(userPermissionOverrides.portId, portId), + ), + }); + if (userOverride?.permissionOverrides) { + permissions = deepMerge( + permissions as unknown as Record, + userOverride.permissionOverrides as Record, + ) as unknown as RolePermissions; + } + + return permissions; } export function initSocketServer( @@ -113,6 +233,14 @@ export function initSocketServer( // holds a role in the requested port. The handshake's auth.portId is // user-supplied; we MUST cross-check it against userPortRoles or any // authenticated user could subscribe to a foreign tenant's broadcasts. + // + // L20(c): `connectionStateRecovery` restores a socket's prior rooms on a + // brief reconnect, but Socket.IO re-runs this middleware on every + // (re)connection, so the cookie/session and `isActive` are re-validated each + // time. A session revoked or a profile deactivated mid-disconnect is rejected + // on the recovery attempt; the only residual is the <=2-min disconnection + // window during which an already-open transport retains its old rooms, which + // is acceptable for this push-only broadcast surface. io.use(async (socket, next) => { try { const cookie = socket.handshake.headers.cookie; @@ -124,6 +252,19 @@ export function initSocketServer( }); if (!session?.user) return next(new Error('Invalid session')); + // M10: reject deactivated users. A valid session cookie outlives a + // profile deactivation, so without this gate a deactivated rep's live + // tab keeps its socket and receives every port-scoped broadcast (new + // clients, invoice totals + names, document-signed, payment amounts, + // note previews) until the cookie expires. Mirrors the HTTP `withAuth` + // gate (helpers.ts:152) which 403s `!profile.isActive`. + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, session.user.id), + }); + if (!profile || !profile.isActive) { + return next(new Error('Account disabled')); + } + // Enforce max 10 connections per user const userSockets = await io!.in(`user:${session.user.id}`).fetchSockets(); if (userSockets.length >= 10) { @@ -137,6 +278,13 @@ export function initSocketServer( return next(new Error('No access to this port')); } } + // L20(a): a port-less connection is intentionally permitted - it joins + // only the personal `user:` room (no `port:` broadcast room) and can + // still emit `join:entity`, but every entity room is independently gated + // by `userCanJoinEntity` (tenant + `.view`), so no port/entity + // data leaks to a connection that never proved port access. Used by + // super-admins and the pre-port-selection window. Not rejected so those + // flows keep working. socket.data = { userId: session.user.id, @@ -158,10 +306,20 @@ export function initSocketServer( if (portId) socket.join(`port:${portId}`); // Entity-level room management - verify the user can access the - // entity's port before joining. Without this, any authenticated user - // could subscribe to a foreign-tenant entity's broadcast (note - // previews, signer emails, etc.) by guessing or harvesting an id. - socket.on('join:entity', async ({ type, id }) => { + // entity's port AND holds the matching `.view` permission + // (M11) before joining. Without this, any authenticated user could + // subscribe to a foreign-tenant entity's broadcast (note previews, + // signer emails, etc.) by guessing or harvesting an id. + socket.on('join:entity', async (payload) => { + // L20(b): validate the wire payload (allow-listed type + uuid id) before + // any DB lookup - the TS union is compile-time only and the payload is + // attacker-controllable. Fails closed on malformed input. + const parsed = ENTITY_JOIN_SCHEMA.safeParse(payload); + if (!parsed.success) { + logger.warn({ userId, payload }, 'Socket rejected malformed join:entity'); + return; + } + const { type, id } = parsed.data; try { const ok = await userCanJoinEntity(userId, type, id); if (ok) socket.join(`${type}:${id}`); @@ -170,7 +328,13 @@ export function initSocketServer( logger.warn({ err, userId, type, id }, 'join:entity check failed'); } }); - socket.on('leave:entity', ({ type, id }) => { + socket.on('leave:entity', (payload) => { + // Leave is harmless (removing yourself from a room you may not even be + // in), but validate for symmetry so a malformed payload can't reach + // `socket.leave` with a non-string room key. + const parsed = ENTITY_JOIN_SCHEMA.safeParse(payload); + if (!parsed.success) return; + const { type, id } = parsed.data; socket.leave(`${type}:${id}`); });