sec: lock down socket.io room subscription + crm-invite cross-tenant ops
1. HIGH — Socket.IO accepted client-supplied `auth.portId` in the
handshake without verifying the user actually held a role in that
port, then unconditionally joined the socket to `port:${portId}`.
The `join:entity` handler also skipped authorization. This let any
authenticated CRM user receive realtime events from any other
tenant: invoice numbers + totals + client names, document signer
emails, registration events with full client name + berth, file
uploads, etc. Auth middleware now resolves the user's
userPortRoles (or isSuperAdmin) before honouring portId, and
join:entity verifies the entity's port matches a port the user
has access to. Pre-existing pre-branch issue but fixed here given
the explicit "all data is extremely sensitive" directive.
2. MEDIUM — listCrmInvites issued a global SELECT with no port
scope. The crm_user_invites table has no portId column (invites
mint global better-auth users, then port roles are assigned
later). The previous gating on per-port admin.manage_users let
any director enumerate every other tenant's pending invitee
emails + isSuperAdmin flags — a phishing target list and a
super-admin onboarding timing oracle. Restrict GET (list),
DELETE (revoke), and POST resend to ctx.isSuperAdmin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,75 @@
|
||||
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 { redis } from '@/lib/redis';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles, userPortRoles } 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 { logger } from '@/lib/logger';
|
||||
import type { ServerToClientEvents, ClientToServerEvents } from './events';
|
||||
|
||||
let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;
|
||||
|
||||
export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerEvents, ServerToClientEvents> {
|
||||
/**
|
||||
* Returns true if the user is a super-admin OR holds a userPortRoles row
|
||||
* for the given portId. The Socket.IO auth middleware uses this to decide
|
||||
* whether to honour a client-supplied `auth.portId` — the prior code
|
||||
* trusted whatever the client passed and thereby joined the socket to a
|
||||
* foreign tenant's broadcast room.
|
||||
*/
|
||||
async function userCanAccessPort(userId: string, portId: string): Promise<boolean> {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
if (profile?.isSuperAdmin) return true;
|
||||
const role = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
|
||||
});
|
||||
return Boolean(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function userCanJoinEntity(
|
||||
userId: string,
|
||||
type: 'berth' | 'client' | 'interest',
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, userId),
|
||||
});
|
||||
if (profile?.isSuperAdmin) return true;
|
||||
|
||||
let entityPortId: string | null = null;
|
||||
if (type === 'berth') {
|
||||
const row = await db.query.berths.findFirst({ where: eq(berths.id, id) });
|
||||
entityPortId = row?.portId ?? null;
|
||||
} else if (type === 'client') {
|
||||
const row = await db.query.clients.findFirst({ where: eq(clients.id, id) });
|
||||
entityPortId = row?.portId ?? null;
|
||||
} else if (type === 'interest') {
|
||||
const row = await db.query.interests.findFirst({ where: eq(interests.id, id) });
|
||||
entityPortId = row?.portId ?? null;
|
||||
}
|
||||
if (!entityPortId) return false;
|
||||
|
||||
const role = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, entityPortId)),
|
||||
});
|
||||
return Boolean(role);
|
||||
}
|
||||
|
||||
export function initSocketServer(
|
||||
httpServer: HTTPServer,
|
||||
): Server<ClientToServerEvents, ServerToClientEvents> {
|
||||
const pubClient = redis.duplicate();
|
||||
const subClient = redis.duplicate();
|
||||
|
||||
@@ -24,7 +84,10 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
||||
maxHttpBufferSize: 1e6, // 1MB message limit
|
||||
});
|
||||
|
||||
// Auth middleware — validate session cookie via Better Auth
|
||||
// Auth middleware — validate session cookie + verify the user actually
|
||||
// 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.
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const cookie = socket.handshake.headers.cookie;
|
||||
@@ -42,9 +105,17 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
||||
return next(new Error('Maximum connections reached'));
|
||||
}
|
||||
|
||||
const requestedPortId = socket.handshake.auth.portId as string | undefined;
|
||||
if (requestedPortId) {
|
||||
const allowed = await userCanAccessPort(session.user.id, requestedPortId);
|
||||
if (!allowed) {
|
||||
return next(new Error('No access to this port'));
|
||||
}
|
||||
}
|
||||
|
||||
socket.data = {
|
||||
userId: session.user.id,
|
||||
portId: socket.handshake.auth.portId as string | undefined,
|
||||
portId: requestedPortId,
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
@@ -61,9 +132,18 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
||||
socket.join(`user:${userId}`);
|
||||
if (portId) socket.join(`port:${portId}`);
|
||||
|
||||
// Entity-level room management
|
||||
socket.on('join:entity', ({ type, id }) => {
|
||||
socket.join(`${type}:${id}`);
|
||||
// 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 }) => {
|
||||
try {
|
||||
const ok = await userCanJoinEntity(userId, type, id);
|
||||
if (ok) socket.join(`${type}:${id}`);
|
||||
else logger.warn({ userId, type, id }, 'Socket denied join:entity');
|
||||
} catch (err) {
|
||||
logger.warn({ err, userId, type, id }, 'join:entity check failed');
|
||||
}
|
||||
});
|
||||
socket.on('leave:entity', ({ type, id }) => {
|
||||
socket.leave(`${type}:${id}`);
|
||||
|
||||
Reference in New Issue
Block a user