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:
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user