1
0

fix(server): prevent cross-tenant access via organization-id header

Resolve a CLS middleware in App.module.ts to copy the request
`organization-id` header straight into `cls.organizationId`, which the
TenancyDB factory used to pick the per-tenant database. The JWT path
never set `organizationId` from the authenticated user, and
TenancyGlobalGuard only checked that the header was present — so any
authenticated user could read or write another tenant's database by
sending their own JWT plus the victim's `organization-id`.

Make the JWT-resolved tenant the source of truth and validate the
header at the edge:

- AuthSigninService.verifyPayload now loads the user's tenant and sets
  `cls.organizationId` from `tenant.organizationId`, mirroring the
  API-key path in AuthApiKeyAuthorizeService.
- TenancyGlobalGuard rejects with `Organization mismatch.` when the
  request header disagrees with the CLS value set by the auth guard.
- App.module.ts no longer seeds `cls.organizationId` from the
  attacker-controlled request header.

GHSA-2g96-86rw-qmvg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-05-15 20:43:14 +02:00
parent a9d2316fc2
commit 7efac090a9
3 changed files with 26 additions and 5 deletions
@@ -18,7 +18,7 @@ import { createBullBoardAuthMiddleware } from '@/middleware/bull-board-auth.midd
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule';
import { PassportModule } from '@nestjs/passport';
import { ClsModule, ClsService } from 'nestjs-cls';
import { ClsModule } from 'nestjs-cls';
import { AppController } from './App.controller';
import { AppService } from './App.service';
import { ItemsModule } from '../Items/Items.module';
@@ -169,9 +169,6 @@ import { AppThrottleModule } from './AppThrottle.module';
global: true,
middleware: {
mount: true,
setup: (cls: ClsService, req: Request, res: Response) => {
cls.set('organizationId', req.headers['organization-id']);
},
generateId: true,
saveReq: true,
},
@@ -2,6 +2,7 @@ import { ClsService } from 'nestjs-cls';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { ModelObject } from 'objection';
import { JwtPayload } from '../Auth.interfaces';
import { InvalidEmailPasswordException } from '../exceptions/InvalidEmailPassword.exception';
@@ -12,6 +13,10 @@ export class AuthSigninService {
constructor(
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
private readonly jwtService: JwtService,
private readonly clsService: ClsService,
) { }
@@ -49,6 +54,7 @@ export class AuthSigninService {
*/
async verifyPayload(payload: JwtPayload): Promise<any> {
let user: SystemUser;
let tenant: TenantModel | undefined;
try {
user = await this.systemUserModel
@@ -56,8 +62,14 @@ export class AuthSigninService {
.findOne({ email: payload.sub })
.throwIfNotFound();
tenant = await this.tenantModel
.query()
.findById(user.tenantId)
.throwIfNotFound();
this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id);
this.clsService.set('organizationId', tenant.organizationId);
} catch (error) {
throw new UserNotFoundException(String(payload.sub));
}
@@ -7,6 +7,7 @@ import {
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ClsService } from 'nestjs-cls';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
import { getAuthApiKey } from '../Auth/Auth.utils';
@@ -16,7 +17,10 @@ export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true);
@Injectable()
export class TenancyGlobalGuard implements CanActivate {
constructor(private reflector: Reflector) {}
constructor(
private readonly reflector: Reflector,
private readonly clsService: ClsService,
) {}
/**
* Validates the organization ID in the request headers.
@@ -43,6 +47,14 @@ export class TenancyGlobalGuard implements CanActivate {
if (!organizationId) {
throw new UnauthorizedException('Organization ID is required.');
}
const authenticatedOrganizationId =
this.clsService.get('organizationId');
if (
authenticatedOrganizationId &&
authenticatedOrganizationId !== organizationId
) {
throw new UnauthorizedException('Organization mismatch.');
}
return true;
}
}