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,12 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||||
|
|
||||||
|
// Resend mints a fresh token + new email on a global invite row;
|
||||||
|
// restrict to super-admins to match revoke/list and avoid cross-tenant
|
||||||
|
// re-issuance of foreign-port invitations.
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
throw new ForbiddenError('Resending CRM invites requires super-admin');
|
||||||
|
}
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
const result = await resendCrmInvite(id, {
|
const result = await resendCrmInvite(id, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||||
|
|
||||||
|
// Invites are a global resource (no portId column). Revoking a foreign
|
||||||
|
// tenant's pending invite by id would be cross-tenant tampering;
|
||||||
|
// restrict to super-admins to match the listing endpoint.
|
||||||
export const DELETE = withAuth(
|
export const DELETE = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
throw new ForbiddenError('Revoking CRM invites requires super-admin');
|
||||||
|
}
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
await revokeCrmInvite(id, {
|
await revokeCrmInvite(id, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ import { errorResponse, ForbiddenError } from '@/lib/errors';
|
|||||||
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, _ctx) => {
|
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
// crm_user_invites is a global table (no per-port column) — invites
|
||||||
|
// mint better-auth users that may later be assigned roles in any
|
||||||
|
// port. Listing it cross-tenant would let a port-A director
|
||||||
|
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||||
|
// for every other tenant. Restrict the listing to super-admins.
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
throw new ForbiddenError('Listing CRM invites requires super-admin');
|
||||||
|
}
|
||||||
const data = await listCrmInvites();
|
const data = await listCrmInvites();
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,15 +1,75 @@
|
|||||||
import { Server } from 'socket.io';
|
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 { 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 { 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 { 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;
|
||||||
|
|
||||||
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 pubClient = redis.duplicate();
|
||||||
const subClient = redis.duplicate();
|
const subClient = redis.duplicate();
|
||||||
|
|
||||||
@@ -24,7 +84,10 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
|||||||
maxHttpBufferSize: 1e6, // 1MB message limit
|
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) => {
|
io.use(async (socket, next) => {
|
||||||
try {
|
try {
|
||||||
const cookie = socket.handshake.headers.cookie;
|
const cookie = socket.handshake.headers.cookie;
|
||||||
@@ -42,9 +105,17 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
|||||||
return next(new Error('Maximum connections reached'));
|
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 = {
|
socket.data = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
portId: socket.handshake.auth.portId as string | undefined,
|
portId: requestedPortId,
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -61,9 +132,18 @@ export function initSocketServer(httpServer: HTTPServer): Server<ClientToServerE
|
|||||||
socket.join(`user:${userId}`);
|
socket.join(`user:${userId}`);
|
||||||
if (portId) socket.join(`port:${portId}`);
|
if (portId) socket.join(`port:${portId}`);
|
||||||
|
|
||||||
// Entity-level room management
|
// Entity-level room management — verify the user can access the
|
||||||
socket.on('join:entity', ({ type, id }) => {
|
// entity's port before joining. Without this, any authenticated user
|
||||||
socket.join(`${type}:${id}`);
|
// 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.on('leave:entity', ({ type, id }) => {
|
||||||
socket.leave(`${type}:${id}`);
|
socket.leave(`${type}:${id}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user