From 7efac090a949c82ff10375608dd01857c84c15a8 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 15 May 2026 20:43:14 +0200 Subject: [PATCH] fix(server): prevent cross-tenant access via organization-id header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/server/src/modules/App/App.module.ts | 5 +---- .../modules/Auth/commands/AuthSignin.service.ts | 12 ++++++++++++ .../src/modules/Tenancy/TenancyGlobal.guard.ts | 14 +++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..ff4604180 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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, }, diff --git a/packages/server/src/modules/Auth/commands/AuthSignin.service.ts b/packages/server/src/modules/Auth/commands/AuthSignin.service.ts index 48694d3ea..73439c79a 100644 --- a/packages/server/src/modules/Auth/commands/AuthSignin.service.ts +++ b/packages/server/src/modules/Auth/commands/AuthSignin.service.ts @@ -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 { 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)); } diff --git a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts index 04310a100..edb146410 100644 --- a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts +++ b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts @@ -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; } }