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 { createAdapter } from '@socket.io/redis-adapter';
|
||||||
import type { Server as HTTPServer } from 'node:http';
|
import type { Server as HTTPServer } from 'node:http';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { redis } from '@/lib/redis';
|
import { redis } from '@/lib/redis';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
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 { berths } from '@/lib/db/schema/berths';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { deepMerge } from '@/lib/api/helpers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { ServerToClientEvents, ClientToServerEvents } from './events';
|
import type { ServerToClientEvents, ClientToServerEvents } from './events';
|
||||||
|
|
||||||
let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;
|
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 = [
|
const DEV_ORIGIN_PATTERNS = [
|
||||||
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/,
|
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/,
|
||||||
/^https?:\/\/192\.168\.\d+\.\d+(:\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({
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
where: eq(userProfiles.userId, userId),
|
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({
|
const role = await db.query.userPortRoles.findFirst({
|
||||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
|
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
|
* Verify the user can join an entity-scoped room. Two gates, both required
|
||||||
* tenant column is checked - if the user can access the entity's port,
|
* (super-admins bypass both):
|
||||||
* they may subscribe to that entity's room.
|
*
|
||||||
|
* - 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(
|
async function userCanJoinEntity(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -67,7 +115,9 @@ async function userCanJoinEntity(
|
|||||||
const profile = await db.query.userProfiles.findFirst({
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
where: eq(userProfiles.userId, userId),
|
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;
|
let entityPortId: string | null = null;
|
||||||
if (type === 'berth') {
|
if (type === 'berth') {
|
||||||
@@ -82,10 +132,80 @@ async function userCanJoinEntity(
|
|||||||
}
|
}
|
||||||
if (!entityPortId) return false;
|
if (!entityPortId) return false;
|
||||||
|
|
||||||
const role = await db.query.userPortRoles.findFirst({
|
// M11: resolve effective permissions for this user in the entity's port and
|
||||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, entityPortId)),
|
// 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(
|
export function initSocketServer(
|
||||||
@@ -113,6 +233,14 @@ export function initSocketServer(
|
|||||||
// holds a role in the requested port. The handshake's auth.portId is
|
// holds a role in the requested port. The handshake's auth.portId is
|
||||||
// user-supplied; we MUST cross-check it against userPortRoles or any
|
// user-supplied; we MUST cross-check it against userPortRoles or any
|
||||||
// authenticated user could subscribe to a foreign tenant's broadcasts.
|
// 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) => {
|
io.use(async (socket, next) => {
|
||||||
try {
|
try {
|
||||||
const cookie = socket.handshake.headers.cookie;
|
const cookie = socket.handshake.headers.cookie;
|
||||||
@@ -124,6 +252,19 @@ export function initSocketServer(
|
|||||||
});
|
});
|
||||||
if (!session?.user) return next(new Error('Invalid session'));
|
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
|
// Enforce max 10 connections per user
|
||||||
const userSockets = await io!.in(`user:${session.user.id}`).fetchSockets();
|
const userSockets = await io!.in(`user:${session.user.id}`).fetchSockets();
|
||||||
if (userSockets.length >= 10) {
|
if (userSockets.length >= 10) {
|
||||||
@@ -137,6 +278,13 @@ export function initSocketServer(
|
|||||||
return next(new Error('No access to this port'));
|
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 = {
|
socket.data = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -158,10 +306,20 @@ export function initSocketServer(
|
|||||||
if (portId) socket.join(`port:${portId}`);
|
if (portId) socket.join(`port:${portId}`);
|
||||||
|
|
||||||
// Entity-level room management - verify the user can access the
|
// Entity-level room management - verify the user can access the
|
||||||
// entity's port before joining. Without this, any authenticated user
|
// entity's port AND holds the matching `<resource>.view` permission
|
||||||
// could subscribe to a foreign-tenant entity's broadcast (note
|
// (M11) before joining. Without this, any authenticated user could
|
||||||
// previews, signer emails, etc.) by guessing or harvesting an id.
|
// subscribe to a foreign-tenant entity's broadcast (note previews,
|
||||||
socket.on('join:entity', async ({ type, id }) => {
|
// 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 {
|
try {
|
||||||
const ok = await userCanJoinEntity(userId, type, id);
|
const ok = await userCanJoinEntity(userId, type, id);
|
||||||
if (ok) socket.join(`${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');
|
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}`);
|
socket.leave(`${type}:${id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user