fix(audit): socket cluster — M10 (isActive gate), M11 (permission-scoped entity rooms), L20 (join:entity validation)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:52:20 +02:00
parent 25988dbfad
commit 0ed4323826

View File

@@ -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<ClientToServerEvents, ServerToClientEvents> | 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 `<resource>.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<boolea
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, userId),
});
if (profile?.isSuperAdmin) return true;
// M10: a deactivated profile has no access regardless of role rows. Mirrors
// the HTTP `withAuth` gate (helpers.ts:152) which 403s `!profile.isActive`.
// Short-circuit BEFORE the super-admin branch so a deactivated super-admin
// is also denied.
if (!profile || !profile.isActive) return false;
if (profile.isSuperAdmin) return true;
const role = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
});
@@ -55,9 +93,19 @@ async function userCanAccessPort(userId: string, portId: string): Promise<boolea
}
/**
* Verify the user can join an entity-scoped room. Each entity type's own
* tenant column is checked - if the user can access the entity's port,
* they may subscribe to that entity's room.
* Verify the user can join an entity-scoped room. Two gates, both required
* (super-admins bypass both):
*
* - tenant: the user must hold a role in the entity's port (cross-tenant
* subscription prevention).
* - M11 permission: the user must hold the matching `<resource>.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 `<resource>.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<string, boolean> | 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<RolePermissions | null> {
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<string, unknown>,
override.permissionOverrides as Record<string, unknown>,
) 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<string, unknown>,
userOverride.permissionOverrides as Record<string, unknown>,
) 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:<id>` room (no `port:` broadcast room) and can
// still emit `join:entity`, but every entity room is independently gated
// by `userCanJoinEntity` (tenant + `<resource>.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 `<resource>.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}`);
});