diff --git a/apps/backend/docker-entrypoint.sh b/apps/backend/docker-entrypoint.sh index a2b68e9..7650776 100644 --- a/apps/backend/docker-entrypoint.sh +++ b/apps/backend/docker-entrypoint.sh @@ -1,26 +1,26 @@ -#!/bin/sh -echo "Starting Xpeditis Backend..." -echo "Waiting for PostgreSQL..." -max_attempts=30 -attempt=0 -while [ $attempt -lt $max_attempts ]; do - if node -e "const { Client } = require('pg'); const client = new Client({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT, user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME }); client.connect().then(() => { client.end(); process.exit(0); }).catch(() => process.exit(1));" 2>/dev/null; then - echo "PostgreSQL is ready" - break - fi - attempt=$((attempt + 1)) - echo "Attempt $attempt/$max_attempts - Retrying..." - sleep 2 -done -if [ $attempt -eq $max_attempts ]; then - echo "Failed to connect to PostgreSQL" - exit 1 -fi -echo "Running database migrations..." -node /app/run-migrations.js -if [ $? -ne 0 ]; then - echo "Migrations failed" - exit 1 -fi -echo "Starting NestJS application..." -exec "$@" +#!/bin/sh +echo "Starting Xpeditis Backend..." +echo "Waiting for PostgreSQL..." +max_attempts=30 +attempt=0 +while [ $attempt -lt $max_attempts ]; do + if node -e "const { Client } = require('pg'); const { databaseTlsOptions } = require('/app/dist/infrastructure/persistence/typeorm/database-tls'); const client = new Client({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT, user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, ssl: databaseTlsOptions(process.env.DATABASE_SSL, process.env.DATABASE_SSL_CA, process.env.DATABASE_HOST) }); client.connect().then(() => { client.end(); process.exit(0); }).catch(() => process.exit(1));" 2>/dev/null; then + echo "PostgreSQL is ready" + break + fi + attempt=$((attempt + 1)) + echo "Attempt $attempt/$max_attempts - Retrying..." + sleep 2 +done +if [ $attempt -eq $max_attempts ]; then + echo "Failed to connect to PostgreSQL" + exit 1 +fi +echo "Running database migrations..." +node /app/run-migrations.js +if [ $? -ne 0 ]; then + echo "Migrations failed" + exit 1 +fi +echo "Starting NestJS application..." +exec "$@" diff --git a/apps/backend/scripts/setup/run-migrations.js b/apps/backend/scripts/setup/run-migrations.js index 3ffae11..3f2e2f9 100644 --- a/apps/backend/scripts/setup/run-migrations.js +++ b/apps/backend/scripts/setup/run-migrations.js @@ -1,5 +1,12 @@ const { DataSource } = require('typeorm'); const path = require('path'); +const { existsSync } = require('fs'); +const applicationRoot = existsSync(path.join(__dirname, 'dist')) + ? __dirname + : path.resolve(__dirname, '../..'); +const { databaseTlsOptions } = require( + path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls') +); const AppDataSource = new DataSource({ type: 'postgres', @@ -8,8 +15,15 @@ const AppDataSource = new DataSource({ username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + ssl: databaseTlsOptions( + process.env.DATABASE_SSL, + process.env.DATABASE_SSL_CA, + process.env.DATABASE_HOST + ), + entities: [path.join(applicationRoot, 'dist/**/*.orm-entity.js')], + migrations: [ + path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/migrations/*.js'), + ], synchronize: false, logging: true, }); diff --git a/apps/backend/scripts/setup/startup.js b/apps/backend/scripts/setup/startup.js index 02a7143..37e5215 100644 --- a/apps/backend/scripts/setup/startup.js +++ b/apps/backend/scripts/setup/startup.js @@ -4,6 +4,14 @@ const { Client } = require('pg'); const { DataSource } = require('typeorm'); const path = require('path'); const { spawn } = require('child_process'); +const { existsSync } = require('fs'); +// Docker copies this script to /app/startup.js; local copies stay in scripts/setup. +const applicationRoot = existsSync(path.join(__dirname, 'dist')) + ? __dirname + : path.resolve(__dirname, '../..'); +const { databaseTlsOptions } = require( + path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls') +); async function waitForPostgres(maxAttempts = 30) { console.log('⏳ Waiting for PostgreSQL to be ready...'); @@ -16,6 +24,11 @@ async function waitForPostgres(maxAttempts = 30) { user: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, + ssl: databaseTlsOptions( + process.env.DATABASE_SSL, + process.env.DATABASE_SSL_CA, + process.env.DATABASE_HOST + ), }); await client.connect(); @@ -42,8 +55,15 @@ async function runMigrations() { username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, database: process.env.DATABASE_NAME, - entities: [path.join(__dirname, 'dist/**/*.orm-entity.js')], - migrations: [path.join(__dirname, 'dist/infrastructure/persistence/typeorm/migrations/*.js')], + ssl: databaseTlsOptions( + process.env.DATABASE_SSL, + process.env.DATABASE_SSL_CA, + process.env.DATABASE_HOST + ), + entities: [path.join(applicationRoot, 'dist/**/*.orm-entity.js')], + migrations: [ + path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/migrations/*.js'), + ], synchronize: false, logging: true, }); @@ -78,6 +98,7 @@ function startApplication() { const app = spawn('node', ['dist/main'], { stdio: 'inherit', env: process.env, + cwd: applicationRoot, }); app.on('exit', code => { @@ -96,7 +117,11 @@ async function main() { startApplication(); } -main().catch(error => { - console.error('❌ Startup failed:', error); - process.exit(1); -}); +if (require.main === module) { + main().catch(error => { + console.error('❌ Startup failed:', error); + process.exit(1); + }); +} + +module.exports = { waitForPostgres, runMigrations }; diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 8b3fbba..d028c4b 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,3 +1,5 @@ +import { SafeDatabaseLogger } from './infrastructure/persistence/typeorm/safe-database-logger'; +import { safeHttpSerializers } from './application/logging/safe-http-log'; import { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module'; import { McpModule } from './application/mcp/mcp.module'; import { Module } from '@nestjs/common'; @@ -16,6 +18,7 @@ import { import * as path from 'path'; import * as Joi from 'joi'; import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver'; +import { databaseTlsOptions } from './infrastructure/persistence/typeorm/database-tls'; // Import feature modules import { AuthModule } from './application/auth/auth.module'; @@ -62,6 +65,8 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; DATABASE_USER: Joi.string().required(), DATABASE_PASSWORD: Joi.string().required(), DATABASE_NAME: Joi.string().required(), + DATABASE_SSL: Joi.boolean().default(false), + DATABASE_SSL_CA: Joi.string().optional(), REDIS_HOST: Joi.string().required(), REDIS_PORT: Joi.number().default(6379), REDIS_PASSWORD: Joi.string().required(), @@ -118,6 +123,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; return { pinoHttp: { + serializers: safeHttpSerializers, transport: usePretty ? { target: 'pino-pretty', @@ -178,9 +184,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; username: configService.get('DATABASE_USER'), password: configService.get('DATABASE_PASSWORD'), database: configService.get('DATABASE_NAME'), + ssl: databaseTlsOptions( + configService.get('DATABASE_SSL'), + configService.get('DATABASE_SSL_CA'), + configService.get('DATABASE_HOST') + ), entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], synchronize: false, // ✅ Force false - use migrations instead logging: configService.get('DATABASE_LOGGING', false), + logger: new SafeDatabaseLogger(configService.get('DATABASE_LOGGING', false)), autoLoadEntities: true, // Auto-load entities from forFeature() }), inject: [ConfigService], diff --git a/apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts b/apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts new file mode 100644 index 0000000..3ae620a --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts @@ -0,0 +1,88 @@ +import { ForbiddenException } from '@nestjs/common'; +import { ApiKey } from '@domain/entities/api-key.entity'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo'; +import { + SubscriptionStatus, + SubscriptionStatusType, +} from '@domain/value-objects/subscription-status.vo'; +import { ApiKeyRepository } from '@domain/ports/out/api-key.repository'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { ApiKeysService } from './api-keys.service'; + +describe('API key current entitlement', () => { + const setup = () => { + let subscription = Subscription.create({ + id: 'sub', + organizationId: 'org', + plan: SubscriptionPlan.gold(), + }); + const key = ApiKey.create({ + id: 'key', + userId: 'user', + organizationId: 'org', + name: 'test', + keyHash: 'hash', + keyPrefix: 'xped_live_test', + }); + const keys = { + findByKeyHash: jest.fn().mockResolvedValue(key), + save: jest.fn().mockImplementation(async value => value), + }; + const users = { + findById: jest + .fn() + .mockResolvedValue({ id: 'user', organizationId: 'org', isActive: true, role: 'MANAGER' }), + }; + const subscriptions = { + findByOrganizationId: jest.fn().mockImplementation(async () => subscription), + }; + return { + service: new ApiKeysService( + keys as unknown as ApiKeyRepository, + users as unknown as UserRepository, + subscriptions as unknown as SubscriptionRepository + ), + keys, + setStatus: (status: SubscriptionStatusType) => { + subscription = subscription.updateStatus(SubscriptionStatus.create(status)); + }, + }; + }; + + it.each([ + 'UNPAID', + 'PAUSED', + 'INCOMPLETE', + 'INCOMPLETE_EXPIRED', + 'CANCELED', + ])('%s invalidates an existing key and forbids creation', async status => { + const { service, keys, setStatus } = setup(); + await expect(service.validateAndGetUser('xped_live_test')).resolves.toMatchObject({ + plan: 'GOLD', + }); + keys.save.mockClear(); + setStatus(status); + await expect(service.validateAndGetUser('xped_live_test')).resolves.toBeNull(); + await expect(service.generateApiKey('user', 'org', { name: 'new' })).rejects.toBeInstanceOf( + ForbiddenException + ); + expect(keys.save).not.toHaveBeenCalled(); + }); + + it.each(['ACTIVE', 'TRIALING', 'PAST_DUE'])( + '%s permits keys', + async status => { + const { service, setStatus } = setup(); + setStatus(status); + await expect(service.validateAndGetUser('xped_live_test')).resolves.toMatchObject({ + plan: 'GOLD', + }); + await expect(service.generateApiKey('user', 'org', { name: 'new' })).resolves.toMatchObject({ + name: 'new', + isActive: true, + }); + } + ); +}); diff --git a/apps/backend/src/application/api-keys/api-keys.service.ts b/apps/backend/src/application/api-keys/api-keys.service.ts index 3ae100c..88b02b3 100644 --- a/apps/backend/src/application/api-keys/api-keys.service.ts +++ b/apps/backend/src/application/api-keys/api-keys.service.ts @@ -154,8 +154,8 @@ export class ApiKeysService { organizationId: user.organizationId, firstName: user.firstName, lastName: user.lastName, - plan: subscription.plan.value, - planFeatures: [...subscription.plan.planFeatures], + plan: subscription.accessPlan.value, + planFeatures: [...subscription.accessPlan.planFeatures], }; } diff --git a/apps/backend/src/application/auth/auth-session.spec.ts b/apps/backend/src/application/auth/auth-session.spec.ts new file mode 100644 index 0000000..b56948f --- /dev/null +++ b/apps/backend/src/application/auth/auth-session.spec.ts @@ -0,0 +1,69 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Repository } from 'typeorm'; +import { AuthService, JwtPayload } from './auth.service'; +import { User, UserRole } from '@domain/entities/user.entity'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { OrganizationRepository } from '@domain/ports/out/organization.repository'; +import { EmailPort } from '@domain/ports/out/email.port'; +import { CachePort } from '@domain/ports/out/cache.port'; +import { PasswordResetTokenOrmEntity } from '@infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity'; +import { SubscriptionService } from '../services/subscription.service'; + +jest.mock('argon2', () => ({ verify: jest.fn().mockResolvedValue(true) })); + +describe('password-bound sessions', () => { + let user: User; + let auth: AuthService; + let jwt: JwtService; + beforeEach(() => { + user = User.create({ + id: 'user-1', + organizationId: 'org-1', + email: 'test@example.org', + firstName: 'Test', + lastName: 'User', + role: UserRole.ADMIN, + passwordHash: 'old-salted-hash', + }); + jwt = new JwtService({ secret: 'test-only-session-secret' }); + auth = new AuthService( + { + findById: jest.fn(async () => user), + findByEmail: jest.fn(async () => user), + } as unknown as UserRepository, + {} as OrganizationRepository, + {} as EmailPort, + { get: jest.fn(async () => null) } as unknown as CachePort, + {} as Repository, + jwt, + new ConfigService({ JWT_SECRET: 'test-only-session-secret' }), + {} as SubscriptionService + ); + }); + + it('rejects old access and refresh tokens after a password change, but accepts a new login', async () => { + const tokens = await auth.login(user.email, 'password'); + const payload = jwt.verify(tokens.accessToken); + expect(await auth.validateUser(payload)).toBe(user); + expect(payload.credentialVersion).not.toContain(user.passwordHash); + user.updatePassword('new-salted-hash'); + expect(await auth.validateUser(payload)).toBeNull(); + await expect(auth.refreshAccessToken(tokens.refreshToken)).rejects.toThrow(); + const fresh = await auth.login(user.email, 'new-password'); + expect(await auth.validateUser(jwt.verify(fresh.accessToken))).toBe(user); + await expect(auth.refreshAccessToken(fresh.refreshToken)).resolves.toHaveProperty( + 'accessToken' + ); + }); + + it('preserves sessions after a profile change and rejects legacy or disabled sessions', async () => { + const tokens = await auth.login(user.email, 'password'); + const payload = jwt.verify(tokens.accessToken); + user.updateFirstName('New name'); + expect(await auth.validateUser(payload)).toBe(user); + expect(await auth.validateUser({ ...payload, credentialVersion: undefined })).toBeNull(); + user.deactivate(); + expect(await auth.validateUser(payload)).toBeNull(); + }); +}); diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 3eb1718..6cc6a51 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -35,6 +35,7 @@ export interface JwtPayload { plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM) planFeatures?: string[]; // plan feature flags type: 'access' | 'refresh'; + credentialVersion?: string; rememberMe?: boolean; // drives auth cookie persistence across refreshes } @@ -253,7 +254,7 @@ export class AuthService { throw new UnauthorizedException('Refresh token has been revoked'); } - const user = await this.userRepository.findById(payload.sub); + const user = await this.validateUser(payload); if (!user || !user.isActive) { throw new UnauthorizedException('User not found or inactive'); @@ -413,13 +414,22 @@ export class AuthService { async validateUser(payload: JwtPayload): Promise { const user = await this.userRepository.findById(payload.sub); - if (!user || !user.isActive) { + if (!user || !user.isActive || payload.credentialVersion !== this.credentialVersion(user)) { return null; } return user; } + // Bind sessions to the current password hash without exposing the hash in JWTs. + // Tokens minted before this binding was introduced require a fresh login. + private credentialVersion(user: User): string { + return crypto + .createHmac('sha256', this.configService.getOrThrow('JWT_SECRET')) + .update(JSON.stringify(['credential-version-v1', user.id, user.passwordHash])) + .digest('hex'); + } + /** * Generate access and refresh tokens */ @@ -447,8 +457,8 @@ export class AuthService { const subscription = await this.subscriptionService.getOrCreateSubscription( user.organizationId ); - plan = subscription.plan.value; - planFeatures = [...subscription.plan.planFeatures]; + plan = subscription.accessPlan.value; + planFeatures = [...subscription.accessPlan.planFeatures]; } catch (error) { this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); } @@ -462,6 +472,7 @@ export class AuthService { plan, planFeatures, type: 'access', + credentialVersion: this.credentialVersion(user), }; const refreshPayload: JwtPayload = { @@ -472,6 +483,7 @@ export class AuthService { plan, planFeatures, type: 'refresh', + credentialVersion: this.credentialVersion(user), rememberMe, }; diff --git a/apps/backend/src/application/auth/jwt.strategy.ts b/apps/backend/src/application/auth/jwt.strategy.ts index 83dd766..5ca757a 100644 --- a/apps/backend/src/application/auth/jwt.strategy.ts +++ b/apps/backend/src/application/auth/jwt.strategy.ts @@ -13,6 +13,7 @@ export interface JwtPayload { role: string; organizationId: string; type: 'access' | 'refresh'; + credentialVersion?: string; iat?: number; // issued at exp?: number; // expiration } diff --git a/apps/backend/src/application/controllers/bookings.controller.ts b/apps/backend/src/application/controllers/bookings.controller.ts index 921aa73..76588ab 100644 --- a/apps/backend/src/application/controllers/bookings.controller.ts +++ b/apps/backend/src/application/controllers/bookings.controller.ts @@ -117,7 +117,7 @@ export class BookingsController { const subscription = await this.subscriptionService.getOrCreateSubscription( user.organizationId ); - const maxShipments = subscription.plan.maxShipmentsPerYear; + const maxShipments = subscription.maxShipmentsPerYear; if (maxShipments !== -1) { const currentYear = new Date().getFullYear(); const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 15359de..f4ff7cd 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -31,6 +31,8 @@ import { ApiParam, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; import { SubscriptionService } from '../services/subscription.service'; @@ -84,8 +86,20 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings */ @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @ApiBearerAuth() - @UseInterceptors(FilesInterceptor('documents', 10)) + @UseInterceptors( + FilesInterceptor('documents', 10, { + limits: { + fileSize: 10 * 1024 * 1024, + files: 10, + fields: 40, + parts: 50, + fieldSize: 64 * 1024, + }, + }) + ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new CSV booking request', @@ -144,13 +158,6 @@ export class CsvBookingsController { @Request() req: any ): Promise { // Debug: Log request details - console.log('=== CSV Booking Request Debug ==='); - console.log('req.user:', req.user); - console.log('req.body:', req.body); - console.log('dto:', dto); - console.log('files:', files?.length); - console.log('================================'); - if (!files || files.length === 0) { throw new BadRequestException('At least one document is required'); } @@ -171,7 +178,7 @@ export class CsvBookingsController { if (req.user.role !== 'ADMIN') { // Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year) const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); - const maxShipments = subscription.plan.maxShipmentsPerYear; + const maxShipments = subscription.maxShipmentsPerYear; if (maxShipments !== -1) { const currentYear = new Date().getFullYear(); const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( @@ -248,7 +255,7 @@ export class CsvBookingsController { ): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> { const organizationId = req.user.organizationId; const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); - const max = subscription.plan.maxShipmentsPerYear; + const max = subscription.maxShipmentsPerYear; const unlimited = max === -1; const currentYear = new Date().getFullYear(); const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( @@ -288,6 +295,8 @@ export class CsvBookingsController { * GET /api/v1/csv-bookings/stats/organization */ @Get('stats/organization') + @UseGuards(RolesGuard) + @Roles('ADMIN', 'MANAGER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -311,6 +320,8 @@ export class CsvBookingsController { * GET /api/v1/csv-bookings/organization/all */ @Get('organization/all') + @UseGuards(RolesGuard) + @Roles('ADMIN', 'MANAGER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -417,6 +428,8 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings/:id/pay */ @Post(':id/pay') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -465,6 +478,8 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings/:id/confirm-payment */ @Post(':id/confirm-payment') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -508,6 +523,8 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings/:id/declare-transfer */ @Post(':id/declare-transfer') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -577,6 +594,8 @@ export class CsvBookingsController { * PATCH /api/v1/csv-bookings/:id/cancel */ @Patch(':id/cancel') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -606,6 +625,8 @@ export class CsvBookingsController { * DELETE /api/v1/csv-bookings/:id */ @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -631,6 +652,8 @@ export class CsvBookingsController { * PATCH /api/v1/csv-bookings/:id/details */ @Patch(':id/details') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -662,6 +685,8 @@ export class CsvBookingsController { * PATCH /api/v1/csv-bookings/:id/rate */ @Patch(':id/rate') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ @@ -693,9 +718,21 @@ export class CsvBookingsController { * POST /api/v1/csv-bookings/:id/documents */ @Post(':id/documents') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @UseInterceptors(FilesInterceptor('documents', 10)) + @UseInterceptors( + FilesInterceptor('documents', 10, { + limits: { + fileSize: 10 * 1024 * 1024, + files: 10, + fields: 40, + parts: 50, + fieldSize: 64 * 1024, + }, + }) + ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Add documents to an existing booking', @@ -749,9 +786,15 @@ export class CsvBookingsController { * PUT /api/v1/csv-bookings/:bookingId/documents/:documentId */ @Patch(':bookingId/documents/:documentId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @UseInterceptors(FilesInterceptor('document', 1)) + @UseInterceptors( + FilesInterceptor('document', 1, { + limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 10, parts: 11, fieldSize: 64 * 1024 }, + }) + ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Replace a document in a booking', @@ -818,6 +861,8 @@ export class CsvBookingsController { * DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId */ @Delete(':bookingId/documents/:documentId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN', 'MANAGER', 'USER') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ diff --git a/apps/backend/src/application/controllers/csv-bookings.security.spec.ts b/apps/backend/src/application/controllers/csv-bookings.security.spec.ts new file mode 100644 index 0000000..5fe3b17 --- /dev/null +++ b/apps/backend/src/application/controllers/csv-bookings.security.spec.ts @@ -0,0 +1,119 @@ +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import request from 'supertest'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo'; +import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception'; +import { CreateCsvBookingDto } from '../dto/csv-booking.dto'; +import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo'; +import { CsvBookingsController } from './csv-bookings.controller'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { CsvBookingService } from '../services/csv-booking.service'; +import { SubscriptionService } from '../services/subscription.service'; +import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port'; +import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; + +describe('CSV booking HTTP security', () => { + let app: INestApplication; + let subscription: Subscription; + const countPaidShipmentsForOrganizationInYear = jest.fn().mockResolvedValue(0); + const createBooking = jest.fn(async () => ({ id: 'booking' })); + const getUserBookings = jest.fn(async () => ({ bookings: [] })); + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [CsvBookingsController], + providers: [ + { provide: CsvBookingService, useValue: { createBooking, getUserBookings } }, + { + provide: SubscriptionService, + useValue: { + getOrCreateSubscription: async () => subscription, + }, + }, + { provide: ConfigService, useValue: {} }, + { provide: SHIPMENT_COUNTER_PORT, useValue: { countPaidShipmentsForOrganizationInYear } }, + { provide: ORGANIZATION_REPOSITORY, useValue: {} }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + req.user = { + id: 'user', + organizationId: 'org', + role: req.headers['x-test-role'] || 'USER', + }; + return true; + }, + }) + .compile(); + app = module.createNestApplication({ logger: false }); + await app.init(); + await app.listen(0, '127.0.0.1'); + }); + afterAll(async () => { + await app?.close(); + }); + beforeEach(() => { + jest.clearAllMocks(); + subscription = Subscription.create({ + id: 'sub', + organizationId: 'org', + plan: SubscriptionPlan.gold(), + }); + countPaidShipmentsForOrganizationInYear.mockResolvedValue(0); + }); + + it('rejects VIEWER mutations before invoking the booking service', async () => { + await request(app.getHttpServer()) + .post('/csv-bookings') + .set('x-test-role', 'VIEWER') + .attach('documents', Buffer.from('document'), 'test.pdf') + .expect(403); + expect(createBooking).not.toHaveBeenCalled(); + }); + it('preserves VIEWER reads', async () => { + await request(app.getHttpServer()) + .get('/csv-bookings') + .set('x-test-role', 'VIEWER') + .expect(200); + expect(getUserBookings).toHaveBeenCalled(); + }); + it('rejects organization-wide reads for an ordinary member', async () => { + await request(app.getHttpServer()).get('/csv-bookings/organization/all').expect(403); + }); + it('rejects oversized documents before invoking the service', async () => { + await request(app.getHttpServer()) + .post('/csv-bookings') + .attach('documents', Buffer.alloc(10 * 1024 * 1024 + 1), 'large.pdf') + .expect(413); + expect(createBooking).not.toHaveBeenCalled(); + }); + it('applies the Bronze quota after a paid subscription is suspended', async () => { + subscription = subscription.updateStatus(SubscriptionStatus.create('UNPAID')); + countPaidShipmentsForOrganizationInYear.mockResolvedValue( + SubscriptionPlan.bronze().maxShipmentsPerYear + ); + await expect( + app + .get(CsvBookingsController) + .createBooking({} as CreateCsvBookingDto, [{} as Express.Multer.File], { + user: { id: 'user', organizationId: 'org', role: 'USER' }, + }) + ).rejects.toBeInstanceOf(ShipmentLimitExceededException); + expect(createBooking).not.toHaveBeenCalled(); + expect(countPaidShipmentsForOrganizationInYear).toHaveBeenCalledWith( + 'org', + new Date().getFullYear() + ); + }); + it('preserves permitted uploads', async () => { + await request(app.getHttpServer()) + .post('/csv-bookings') + .attach('documents', Buffer.from('document'), 'test.pdf') + .expect(201); + expect(createBooking).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index ddfc08a..bbab1a3 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -119,7 +119,7 @@ export class InvitationsController { description: 'Invitation expired or already used', }) async verifyInvitation(@Param('token') token: string): Promise { - this.logger.log(`Verifying invitation token: ${token}`); + this.logger.log('Verifying invitation token'); const invitation = await this.invitationService.verifyInvitation(token); diff --git a/apps/backend/src/application/controllers/notifications.controller.ts b/apps/backend/src/application/controllers/notifications.controller.ts index 1a3b06f..c7e420f 100644 --- a/apps/backend/src/application/controllers/notifications.controller.ts +++ b/apps/backend/src/application/controllers/notifications.controller.ts @@ -152,7 +152,7 @@ export class NotificationsController { throw new NotFoundException('Notification not found'); } - await this.notificationService.markAsRead(id); + await this.notificationService.markAsRead(id, user.id); return { success: true }; } diff --git a/apps/backend/src/application/controllers/organizations.controller.spec.ts b/apps/backend/src/application/controllers/organizations.controller.spec.ts new file mode 100644 index 0000000..d9821f1 --- /dev/null +++ b/apps/backend/src/application/controllers/organizations.controller.spec.ts @@ -0,0 +1,67 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Organization, OrganizationType } from '@domain/entities/organization.entity'; +import { OrganizationRepository } from '@domain/ports/out/organization.repository'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { OrganizationsController } from './organizations.controller'; +import { NotificationService } from '../services/notification.service'; +import { UserPayload } from '../decorators/current-user.decorator'; + +describe('OrganizationsController tenant authorization', () => { + const actor = (role: string): UserPayload => ({ + id: 'user-id', + email: 'manager@example.org', + role, + organizationId: 'own-org', + firstName: 'Test', + lastName: 'User', + }); + const makeOrganization = (id: string) => + Organization.create({ + id, + name: 'Original', + type: OrganizationType.FREIGHT_FORWARDER, + address: { street: '1 rue Test', city: 'Paris', postalCode: '75001', country: 'FR' }, + documents: [], + isActive: true, + }); + const findById = jest.fn(); + const save = jest.fn(async (organization: Organization) => organization); + const controller = new OrganizationsController( + { findById, save } as unknown as OrganizationRepository, + {} as UserRepository, + {} as NotificationService + ); + + beforeEach(() => jest.clearAllMocks()); + + it.each(['MANAGER', 'manager', 'USER', 'VIEWER'])( + 'rejects foreign organization for %s', + async role => { + const target = makeOrganization('other-org'); + findById.mockResolvedValue(target); + await expect( + controller.updateOrganization(target.id, { name: 'Changed' }, actor(role)) + ).rejects.toBeInstanceOf(ForbiddenException); + expect(target.name).toBe('Original'); + expect(save).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['MANAGER', 'own-org'], + ['ADMIN', 'other-org'], + ])('allows %s to update %s', async (role, id) => { + findById.mockResolvedValue(makeOrganization(id)); + const result = await controller.updateOrganization(id, { name: 'Changed' }, actor(role)); + expect(result.name).toBe('Changed'); + expect(save).toHaveBeenCalledTimes(1); + }); + + it('preserves missing organization response', async () => { + findById.mockResolvedValue(null); + await expect( + controller.updateOrganization('missing', {}, actor('ADMIN')) + ).rejects.toBeInstanceOf(NotFoundException); + expect(save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/application/controllers/organizations.controller.ts b/apps/backend/src/application/controllers/organizations.controller.ts index d19b8ae..7d02078 100644 --- a/apps/backend/src/application/controllers/organizations.controller.ts +++ b/apps/backend/src/application/controllers/organizations.controller.ts @@ -42,6 +42,7 @@ import { ORGANIZATION_REPOSITORY, } from '@domain/ports/out/organization.repository'; import { Organization, OrganizationType } from '@domain/entities/organization.entity'; +import { UserRole } from '@domain/entities/user.entity'; import { NotificationType, NotificationPriority } from '@domain/entities/notification.entity'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -251,7 +252,7 @@ export class OrganizationsController { } // Authorization: Managers can only update their own organization - if (user.role === 'manager' && organization.id !== user.organizationId) { + if (user.role !== UserRole.ADMIN && organization.id !== user.organizationId) { throw new ForbiddenException('You can only update your own organization'); } diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts index bc806d1..a517f54 100644 --- a/apps/backend/src/application/controllers/subscriptions.controller.ts +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -24,6 +24,8 @@ import { Req, Inject, ForbiddenException, + BadRequestException, + InternalServerErrorException, } from '@nestjs/common'; import { ApiTags, @@ -269,7 +271,7 @@ export class SubscriptionsController { const rawBody = req.rawBody; if (!rawBody) { this.logger.error('No raw body found in request'); - return { received: false }; + throw new BadRequestException('Missing webhook body'); } try { @@ -277,7 +279,7 @@ export class SubscriptionsController { return { received: true }; } catch (error) { this.logger.error('Webhook processing failed', error); - return { received: false }; + throw new InternalServerErrorException('Webhook processing failed'); } } } diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 447ffe7..83a5399 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -208,8 +208,7 @@ export class UsersController { this.logger.log(`Access email sent to new user ${newUser.email}`); return true; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error(`User ${newUser.email} created but the access email failed: ${message}`); + this.logger.error('User created but the access email failed'); return false; } } @@ -299,6 +298,10 @@ export class UsersController { throw new BadRequestException('You cannot change your own role'); } + if (user.role === DomainUserRole.ADMIN && currentUser.role !== DomainUserRole.ADMIN) { + throw new ForbiddenException('Only platform administrators can update ADMIN users'); + } + // Authorization: Only ADMIN can assign ADMIN role if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { throw new ForbiddenException('Only platform administrators can assign ADMIN role'); diff --git a/apps/backend/src/application/controllers/users.security.spec.ts b/apps/backend/src/application/controllers/users.security.spec.ts new file mode 100644 index 0000000..2848fd7 --- /dev/null +++ b/apps/backend/src/application/controllers/users.security.spec.ts @@ -0,0 +1,88 @@ +import { ForbiddenException, Logger } from '@nestjs/common'; +import { User, UserRole } from '@domain/entities/user.entity'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { UsersController } from './users.controller'; +import { SubscriptionService } from '../services/subscription.service'; +import { UserPayload } from '../decorators/current-user.decorator'; +import { UserRole as DtoUserRole } from '../dto/user.dto'; + +describe('administrator target protection', () => { + it('does not log a temporary password when creating an account', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + try { + const controller = new UsersController( + { + findByEmail: async () => null, + save: async (user: User) => user, + } as unknown as UserRepository, + {} as SubscriptionService, + {} as never, + { assertKeepsAnActiveAdmin: jest.fn() } as never, + { sendUserInvitation: jest.fn().mockResolvedValue(undefined) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never + ); + await controller.createUser( + { + email: 'new@example.org', + firstName: 'New', + lastName: 'User', + organizationId: 'org-1', + role: DtoUserRole.USER, + password: 'test-only-Temporary-password-42', + }, + { + id: 'admin', + email: 'admin@example.org', + role: 'ADMIN', + organizationId: 'org-1', + firstName: 'A', + lastName: 'B', + } + ); + expect(JSON.stringify([...log.mock.calls, ...warn.mock.calls])).not.toContain( + 'test-only-Temporary-password-42' + ); + } finally { + log.mockRestore(); + warn.mockRestore(); + } + }); + const actor: UserPayload = { + id: 'manager', + role: 'MANAGER', + organizationId: 'org-1', + email: 'manager@example.org', + firstName: 'Test', + lastName: 'Manager', + }; + it.each([UserRole.ADMIN, UserRole.USER])('enforces target hierarchy for %s', async role => { + const user = User.create({ + id: 'target', + role, + organizationId: actor.organizationId, + email: 'target@example.org', + firstName: 'Original', + lastName: 'User', + passwordHash: 'test-hash', + }); + const save = jest.fn(async () => user); + const controller = new UsersController( + { findById: jest.fn(async () => user), save } as unknown as UserRepository, + {} as SubscriptionService, + {} as never, + { assertKeepsAnActiveAdmin: jest.fn() } as never, + { sendUserInvitation: jest.fn().mockResolvedValue(undefined) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never + ); + const result = controller.updateUser(user.id, { firstName: 'Changed' }, actor); + if (role === UserRole.ADMIN) { + await expect(result).rejects.toBeInstanceOf(ForbiddenException); + expect(save).not.toHaveBeenCalled(); + expect(user.firstName).toBe('Original'); + } else { + await expect(result).resolves.toHaveProperty('firstName', 'Changed'); + expect(save).toHaveBeenCalled(); + } + }); +}); diff --git a/apps/backend/src/application/controllers/webhooks-validation.security.spec.ts b/apps/backend/src/application/controllers/webhooks-validation.security.spec.ts new file mode 100644 index 0000000..1f8e37e --- /dev/null +++ b/apps/backend/src/application/controllers/webhooks-validation.security.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { I18nValidationPipe } from 'nestjs-i18n'; +import { WebhooksController } from './webhooks.controller'; + +describe('Current webhook configuration boundary (OBS-02)', () => { + it.each([ + ['createWebhook', 0], + ['updateWebhook', 1], + ])('%s rejects an unvalidated destination at the global pipe', async (method, index) => { + const types = Reflect.getMetadata( + 'design:paramtypes', + WebhooksController.prototype, + method as string + ); + const pipe = new I18nValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }); + await expect( + pipe.transform( + { url: 'http://127.0.0.1/internal', events: ['booking.created'] }, + { type: 'body', metatype: types[index as number] } + ) + ).rejects.toMatchObject({ status: 400 }); + }); +}); diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index b7c057e..bd5b6fc 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -554,12 +554,6 @@ export class CsvBookingResponseDto { }) documents: CsvBookingDocumentDto[]; - @ApiProperty({ - description: 'Confirmation token for accept/reject actions', - example: 'abc123-def456-ghi789', - }) - confirmationToken: string; - @ApiProperty({ description: 'Booking request timestamp', example: '2025-10-23T14:30:00Z', diff --git a/apps/backend/src/application/filters/domain-exception.filter.ts b/apps/backend/src/application/filters/domain-exception.filter.ts index d45dad0..990d3f4 100644 --- a/apps/backend/src/application/filters/domain-exception.filter.ts +++ b/apps/backend/src/application/filters/domain-exception.filter.ts @@ -1,3 +1,4 @@ +import { safeRequestRoute } from '../logging/safe-http-log'; /** * DomainExceptionFilter * @@ -38,7 +39,7 @@ export class DomainExceptionFilter implements ExceptionFilter { error: exception.name, message: typeof translated === 'string' ? translated : exception.message, timestamp: new Date().toISOString(), - path: request.url, + path: safeRequestRoute(request), }); } } diff --git a/apps/backend/src/application/filters/unhandled-exception.filter.ts b/apps/backend/src/application/filters/unhandled-exception.filter.ts index e626fdd..93b5da2 100644 --- a/apps/backend/src/application/filters/unhandled-exception.filter.ts +++ b/apps/backend/src/application/filters/unhandled-exception.filter.ts @@ -1,3 +1,4 @@ +import { safeRequestRoute } from '../logging/safe-http-log'; import { ArgumentsHost, Catch, @@ -80,8 +81,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter { const reference = randomUUID().slice(0, 8); this.logger.error( - `[${reference}] ${request.method} ${request.url} — ${describe(exception)}`, - exception instanceof Error ? exception.stack : undefined + `[${reference}] ${request.method} ${safeRequestRoute(request)} — ${unavailable ? 'dependency unavailable' : 'unexpected error'}` ); response.status(status).json({ @@ -91,7 +91,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter { message: this.translate(key, lang), reference, timestamp: new Date().toISOString(), - path: request.url, + path: safeRequestRoute(request), }); } @@ -112,9 +112,6 @@ export function isLastActiveAdminViolation(exception: unknown): boolean { return code === LAST_ACTIVE_ADMIN_SQLSTATE || driverError?.code === LAST_ACTIVE_ADMIN_SQLSTATE; } -const describe = (exception: unknown): string => - exception instanceof Error ? `${exception.name}: ${exception.message}` : String(exception); - /** * L'erreur vient-elle d'une dependance injoignable, plutot que d'une requete * fautive ou d'un defaut du code ? diff --git a/apps/backend/src/application/gateways/notifications.gateway.spec.ts b/apps/backend/src/application/gateways/notifications.gateway.spec.ts new file mode 100644 index 0000000..b011745 --- /dev/null +++ b/apps/backend/src/application/gateways/notifications.gateway.spec.ts @@ -0,0 +1,70 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { Socket } from 'socket.io'; +import { NotificationsGateway } from './notifications.gateway'; +import { JwtStrategy } from '../auth/jwt.strategy'; +import { AuthService } from '../auth/auth.service'; +import { NotificationService } from '../services/notification.service'; + +describe('notification socket sessions', () => { + const jwt = new JwtService({ secret: 'test-only-socket-secret' }); + const validateUser = jest.fn(); + const notifications = { + getUnreadCount: jest.fn(async () => 0), + getRecentNotifications: jest.fn(async () => []), + markAllAsRead: jest.fn(), + }; + let gateway: NotificationsGateway; + const socket = (token: string) => + ({ + id: 'socket-1', + data: {}, + handshake: { headers: {}, query: {}, auth: { token } }, + join: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn(), + }) as unknown as Socket; + const token = (type = 'access', expiresIn = 300) => + jwt.sign({ sub: 'user-1', type }, { expiresIn }); + + beforeEach(() => { + jest.clearAllMocks(); + validateUser.mockResolvedValue({ id: 'user-1', organizationId: 'org-1' }); + const strategy = new JwtStrategy(new ConfigService({ JWT_SECRET: 'test-only-socket-secret' }), { + validateUser, + } as unknown as AuthService); + gateway = new NotificationsGateway( + jwt, + notifications as unknown as NotificationService, + strategy + ); + }); + + it.each(['refresh', 'unknown'])('rejects %s tokens before any data is sent', async type => { + const client = socket(token(type)); + await gateway.handleConnection(client); + expect(client.disconnect).toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalled(); + }); + + it('rejects expired and disabled sessions', async () => { + const expired = socket(token('access', -1)); + await gateway.handleConnection(expired); + expect(expired.emit).not.toHaveBeenCalled(); + validateUser.mockResolvedValue(null); + const disabled = socket(token()); + await gateway.handleConnection(disabled); + expect(disabled.emit).not.toHaveBeenCalled(); + }); + + it('rechecks the account on messages after a valid connection', async () => { + const client = socket(token()); + await gateway.handleConnection(client); + expect(client.emit).toHaveBeenCalledWith('unread_count', { count: 0 }); + validateUser.mockResolvedValue(null); + const result = await gateway.handleMarkAllAsRead(client); + expect(result.success).toBe(false); + expect(notifications.markAllAsRead).not.toHaveBeenCalled(); + expect(client.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/application/gateways/notifications.gateway.ts b/apps/backend/src/application/gateways/notifications.gateway.ts index 739aace..44f7662 100644 --- a/apps/backend/src/application/gateways/notifications.gateway.ts +++ b/apps/backend/src/application/gateways/notifications.gateway.ts @@ -14,8 +14,9 @@ import { MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { Logger, UseGuards } from '@nestjs/common'; +import { Logger, UseGuards, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { JwtStrategy, JwtPayload } from '../auth/jwt.strategy'; import { NotificationService } from '../services/notification.service'; import { Notification } from '@domain/entities/notification.entity'; import { notificationTarget } from '@domain/services/notification-target'; @@ -36,11 +37,13 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco server: Server; private readonly logger = new Logger(NotificationsGateway.name); + private readonly connections = new Map(); private userSockets: Map> = new Map(); // userId -> Set of socket IDs constructor( private readonly jwtService: JwtService, - private readonly notificationService: NotificationService + private readonly notificationService: NotificationService, + private readonly jwtStrategy: JwtStrategy ) {} /** @@ -57,8 +60,9 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco } // Verify JWT token - const payload = await this.jwtService.verifyAsync(token); - const userId = payload.sub; + const user = await this.authenticate(client); + const userId = user.id; + this.connections.set(client.id, client); // Store socket connection for user if (!this.userSockets.has(userId)) { @@ -68,7 +72,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco // Store user ID in socket data for later use client.data.userId = userId; - client.data.organizationId = payload.organizationId; + client.data.organizationId = user.organizationId; // Join user-specific room client.join(`user:${userId}`); @@ -97,6 +101,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco * Handle client disconnection */ handleDisconnect(client: Socket) { + this.connections.delete(client.id); const userId = client.data.userId; if (userId && this.userSockets.has(userId)) { this.userSockets.get(userId)!.delete(client.id); @@ -116,12 +121,12 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco @MessageBody() data: { notificationId: string } ) { try { - const userId = client.data.userId; - await this.notificationService.markAsRead(data.notificationId); + const userId = (await this.authenticate(client)).id; + await this.notificationService.markAsRead(data.notificationId, userId); // Send updated unread count const unreadCount = await this.notificationService.getUnreadCount(userId); - this.emitToUser(userId, 'unread_count', { count: unreadCount }); + await this.emitToUser(userId, 'unread_count', { count: unreadCount }); return { success: true }; } catch (error: any) { @@ -136,11 +141,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco @SubscribeMessage('mark_all_as_read') async handleMarkAllAsRead(@ConnectedSocket() client: Socket) { try { - const userId = client.data.userId; + const userId = (await this.authenticate(client)).id; await this.notificationService.markAllAsRead(userId); // Send updated unread count (should be 0) - this.emitToUser(userId, 'unread_count', { count: 0 }); + await this.emitToUser(userId, 'unread_count', { count: 0 }); return { success: true }; } catch (error: any) { @@ -155,7 +160,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco @SubscribeMessage('get_unread_count') async handleGetUnreadCount(@ConnectedSocket() client: Socket) { try { - const userId = client.data.userId; + const userId = (await this.authenticate(client)).id; const unreadCount = await this.notificationService.getUnreadCount(userId); return { count: unreadCount }; } catch (error: any) { @@ -171,11 +176,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco const notificationDto = this.mapNotificationToDto(notification); // Emit to all connected sockets for this user - this.emitToUser(userId, 'new_notification', { notification: notificationDto }); + await this.emitToUser(userId, 'new_notification', { notification: notificationDto }); // Update unread count const unreadCount = await this.notificationService.getUnreadCount(userId); - this.emitToUser(userId, 'unread_count', { count: unreadCount }); + await this.emitToUser(userId, 'unread_count', { count: unreadCount }); this.logger.log(`Notification sent to user ${userId}: ${notification.title}`); } @@ -185,9 +190,16 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco */ async broadcastToOrganization(organizationId: string, notification: Notification) { const notificationDto = this.mapNotificationToDto(notification); - this.server.to(`org:${organizationId}`).emit('new_notification', { - notification: notificationDto, - }); + for (const client of this.connections.values()) { + try { + const user = await this.authenticate(client); + if (user.organizationId === organizationId) { + client.emit('new_notification', { notification: notificationDto }); + } + } catch { + client.disconnect(); + } + } this.logger.log(`Notification broadcasted to organization ${organizationId}`); } @@ -195,8 +207,36 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco /** * Helper: Emit event to all sockets of a user */ - private emitToUser(userId: string, event: string, data: any) { - this.server.to(`user:${userId}`).emit(event, data); + private async emitToUser(userId: string, event: string, data: unknown) { + for (const socketId of this.userSockets.get(userId) ?? []) { + const client = this.connections.get(socketId); + if (!client) continue; + try { + const user = await this.authenticate(client); + if (user.id === userId) client.emit(event, data); + } catch { + client.disconnect(); + } + } + } + + private async authenticate(client: Socket) { + try { + const token = this.extractToken(client); + if (!token) throw new UnauthorizedException(); + const payload = await this.jwtService.verifyAsync(token); + if ( + typeof payload.sub !== 'string' || + !Number.isFinite(payload.exp) || + payload.exp! * 1000 <= Date.now() + ) { + throw new UnauthorizedException(); + } + return await this.jwtStrategy.validate(payload); + } catch { + client.disconnect(); + throw new UnauthorizedException('Invalid or expired session'); + } } /** diff --git a/apps/backend/src/application/guards/feature-flag.guard.ts b/apps/backend/src/application/guards/feature-flag.guard.ts index d769ac4..c550337 100644 --- a/apps/backend/src/application/guards/feature-flag.guard.ts +++ b/apps/backend/src/application/guards/feature-flag.guard.ts @@ -18,7 +18,7 @@ import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator' * Feature Flag Guard * * Checks if the user's subscription plan includes the required features. - * First tries to read plan from JWT payload (fast path), falls back to DB lookup. + * Uses current subscription data so stale token claims cannot preserve revoked rights. * * Usage: * @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) @@ -58,19 +58,7 @@ export class FeatureFlagGuard implements CanActivate { return true; } - // Fast path: check plan features from JWT payload - if (user.planFeatures && Array.isArray(user.planFeatures)) { - const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature)); - - if (hasAllFeatures) { - return true; - } - - // JWT says no — but JWT might be stale after an upgrade. - // Fall through to DB check. - } - - // Slow path: DB lookup for fresh subscription data + // Always resolve current rights, including after suspension or downgrade. try { const subscription = await this.subscriptionRepository.findByOrganizationId( user.organizationId @@ -81,8 +69,9 @@ export class FeatureFlagGuard implements CanActivate { this.throwFeatureRequired(requiredFeatures); } - const plan = subscription!.plan; - const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature)); + const missingFeatures = requiredFeatures.filter( + feature => !subscription!.hasFeature(feature) + ); if (missingFeatures.length > 0) { this.throwFeatureRequired(requiredFeatures); diff --git a/apps/backend/src/application/guards/subscription-access.security.spec.ts b/apps/backend/src/application/guards/subscription-access.security.spec.ts new file mode 100644 index 0000000..b79415a --- /dev/null +++ b/apps/backend/src/application/guards/subscription-access.security.spec.ts @@ -0,0 +1,90 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo'; +import { + SubscriptionStatus, + SubscriptionStatusType, +} from '@domain/value-objects/subscription-status.vo'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { FeatureFlagGuard } from './feature-flag.guard'; + +const subscriptionFor = (status: SubscriptionStatusType) => + Subscription.create({ + id: 'sub', + organizationId: 'org', + plan: SubscriptionPlan.gold(), + }).updateStatus(SubscriptionStatus.create(status)); + +const denied: SubscriptionStatusType[] = [ + 'UNPAID', + 'PAUSED', + 'INCOMPLETE', + 'INCOMPLETE_EXPIRED', + 'CANCELED', +]; +const allowed: SubscriptionStatusType[] = ['ACTIVE', 'TRIALING', 'PAST_DUE']; + +describe('Current subscription entitlement', () => { + it.each(denied)('%s removes paid benefits without erasing billing plan', status => { + const subscription = subscriptionFor(status); + expect(subscription.hasFeature('api_access')).toBe(false); + expect(subscription.maxShipmentsPerYear).toBe(SubscriptionPlan.bronze().maxShipmentsPerYear); + expect(subscription.bookingFeeEur).toBe(SubscriptionPlan.bronze().bookingFeeEur); + expect(subscription.plan.value).toBe('GOLD'); + }); + + it.each(allowed)('%s retains paid benefits', status => { + const subscription = subscriptionFor(status); + expect(subscription.hasFeature('api_access')).toBe(true); + expect(subscription.maxShipmentsPerYear).toBe(SubscriptionPlan.gold().maxShipmentsPerYear); + }); + + const setup = ( + subscription: Subscription | null, + role = 'MANAGER', + planFeatures = ['user_management'] + ) => { + const findByOrganizationId = jest.fn().mockResolvedValue(subscription); + const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['user_management']) }; + const guard = new FeatureFlagGuard( + reflector as unknown as Reflector, + { findByOrganizationId } as unknown as SubscriptionRepository + ); + const context = { + getHandler: () => undefined, + getClass: () => undefined, + switchToHttp: () => ({ + getRequest: () => ({ user: { organizationId: 'org', role, planFeatures } }), + }), + } as unknown as ExecutionContext; + return { guard, context, findByOrganizationId }; + }; + + it.each(denied)('%s cannot be bypassed by stale token features', async status => { + const { guard, context } = setup(subscriptionFor(status)); + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('denies a deleted subscription even with paid token features', async () => { + const { guard, context } = setup(null); + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it.each(allowed)('%s allows current rights despite an old Bronze token', async status => { + const { guard, context } = setup(subscriptionFor(status), 'MANAGER', []); + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('preserves the platform ADMIN override', async () => { + const { guard, context, findByOrganizationId } = setup(null, 'ADMIN'); + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(findByOrganizationId).not.toHaveBeenCalled(); + }); + + it('fails closed when current rights cannot be loaded', async () => { + const { guard, context, findByOrganizationId } = setup(subscriptionFor('ACTIVE')); + findByOrganizationId.mockRejectedValue(new Error('unavailable')); + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts b/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts index 16befff..d3bb2bf 100644 --- a/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts +++ b/apps/backend/src/application/interceptors/performance-monitoring.interceptor.ts @@ -1,3 +1,4 @@ +import { safeRequestRoute } from '../logging/safe-http-log'; /** * Performance Monitoring Interceptor * @@ -15,7 +16,8 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); - const { method, url, user } = request; + const { method, user } = request; + const url = safeRequestRoute(request); const startTime = Date.now(); return next.handle().pipe( @@ -39,10 +41,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor { const duration = Date.now() - startTime; // Log error - this.logger.error( - `Request error: ${method} ${url} (${duration}ms) - ${error.message}`, - error.stack - ); + this.logger.error(`Request error: ${method} ${url} (${duration}ms)`); // Capture exception in Sentry Sentry.withScope(scope => { @@ -52,7 +51,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor { userId: user?.sub, duration, }); - Sentry.captureException(error); + Sentry.captureException(new Error('Request failed; sensitive error details omitted')); }); throw error; diff --git a/apps/backend/src/application/logging/safe-http-log.spec.ts b/apps/backend/src/application/logging/safe-http-log.spec.ts new file mode 100644 index 0000000..3341ebf --- /dev/null +++ b/apps/backend/src/application/logging/safe-http-log.spec.ts @@ -0,0 +1,103 @@ +import pino from 'pino'; +import { Logger, ArgumentsHost, NotFoundException } from '@nestjs/common'; +import { safeHttpSerializers, safeRequestRoute } from './safe-http-log'; +import { UnhandledExceptionFilter } from '../filters/unhandled-exception.filter'; +import { CsvBookingService } from '../services/csv-booking.service'; +import { InvitationsController } from '../controllers/invitations.controller'; +import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository'; + +const secret = 'test-secret-not-for-logs'; + +describe('Capability-safe logs', () => { + afterEach(() => jest.restoreAllMocks()); + + it('serializes real Pino events without request, response or driver secrets', () => { + let output = ''; + const logger = pino( + { serializers: safeHttpSerializers }, + { + write: (line: string) => { + output += line; + }, + } + ); + logger.error( + { + req: { + method: 'GET', + url: `/api/v1/invitations/verify/${secret}?password=${secret}`, + headers: { cookie: secret, referer: secret }, + params: { token: secret }, + body: { password: secret }, + raw: { route: { path: '/api/v1/invitations/verify/:token' } }, + }, + res: { statusCode: 500, headers: { 'set-cookie': secret } }, + err: Object.assign(new Error(secret), { + query: secret, + parameters: [secret], + cause: new Error(secret), + }), + }, + 'request failed' + ); + expect(output).not.toContain(secret); + expect(JSON.parse(output)).toMatchObject({ + req: { method: 'GET', route: '/api/v1/invitations/verify/:token' }, + res: { statusCode: 500 }, + }); + }); + + it('does not fall back to a raw or encoded path on an unmatched request', () => { + expect(safeRequestRoute({ url: `/api/v1/%69nvitations/verify/${secret}` })).toBe( + '[unmatched route]' + ); + }); + + it('keeps correlation without raw exception details or URL in the global filter', () => { + const errorLog = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const host = { + switchToHttp: () => ({ + getRequest: () => ({ method: 'GET', url: `/${secret}`, headers: {} }), + getResponse: () => ({ status }), + }), + } as unknown as ArgumentsHost; + new UnhandledExceptionFilter({ translate: () => 'Please retry' } as never).catch( + new Error(secret), + host + ); + expect(JSON.stringify(errorLog.mock.calls)).not.toContain(secret); + expect(JSON.stringify(json.mock.calls)).not.toContain(secret); + expect(json.mock.calls[0][0].reference).toBeTruthy(); + }); + + it('does not log tokens across controller, service and repository lookup paths', async () => { + const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const orm = { findOne: jest.fn().mockResolvedValue(null) }; + const repository = new TypeOrmCsvBookingRepository(orm as never); + const service = new CsvBookingService( + repository, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + for (const call of [ + () => service.getBookingByToken(secret), + () => service.acceptBooking(secret), + () => service.rejectBooking(secret), + ]) { + await expect(call()).rejects.toBeInstanceOf(NotFoundException); + } + await expect(service.getBookingByToken(secret)).rejects.not.toThrow(secret); + const controller = new InvitationsController({ + verifyInvitation: jest.fn().mockRejectedValue(new NotFoundException('not found')), + } as never); + await expect(controller.verifyInvitation(secret)).rejects.toBeInstanceOf(NotFoundException); + expect(JSON.stringify(logs.mock.calls)).not.toContain(secret); + expect(orm.findOne).toHaveBeenCalledWith({ where: { confirmationToken: secret } }); + }); +}); diff --git a/apps/backend/src/application/logging/safe-http-log.ts b/apps/backend/src/application/logging/safe-http-log.ts new file mode 100644 index 0000000..155d592 --- /dev/null +++ b/apps/backend/src/application/logging/safe-http-log.ts @@ -0,0 +1,24 @@ +/** Log server-owned routing metadata, never credentials carried by URLs or headers. */ +export function safeRequestRoute(request: unknown): string { + if (!request || typeof request !== 'object') return '[unmatched route]'; + const req = request as { route?: { path?: unknown }; raw?: { route?: { path?: unknown } } }; + const path = req.route?.path ?? req.raw?.route?.path; + return typeof path === 'string' ? path : '[unmatched route]'; +} + +export const safeHttpSerializers = { + req(request: { + method?: string; + route?: { path?: unknown }; + raw?: { route?: { path?: unknown } }; + }) { + return { method: request.method, route: safeRequestRoute(request) }; + }, + res(response: { statusCode?: number }) { + return { statusCode: response.statusCode }; + }, + err(_error: unknown) { + // Driver/SMTP errors can contain SQL parameters, tokens, headers or message bodies. + return { type: 'Error', message: 'Request failed; sensitive error details omitted' }; + }, +}; diff --git a/apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts b/apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts new file mode 100644 index 0000000..f792d16 --- /dev/null +++ b/apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts @@ -0,0 +1,69 @@ +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo'; +import { + SubscriptionStatus, + SubscriptionStatusType, +} from '@domain/value-objects/subscription-status.vo'; +import { McpController } from './mcp.controller'; +import { CapabilityRegistry } from './capability.registry'; +import { SubscriptionService } from '../services/subscription.service'; + +// Real registry and account capability: stale client claims must not be echoed as rights. +describe('MCP current entitlement', () => { + it.each<[SubscriptionStatusType, string, string]>([ + ['UNPAID', 'MANAGER', 'BRONZE'], + ['PAUSED', 'MANAGER', 'BRONZE'], + ['ACTIVE', 'MANAGER', 'GOLD'], + ['TRIALING', 'MANAGER', 'GOLD'], + ['PAST_DUE', 'MANAGER', 'GOLD'], + ['UNPAID', 'ADMIN', 'PLATINIUM'], + ])('%s resolves live rights for %s', async (status, role, expected) => { + const subscription = Subscription.create({ + id: 'sub', + organizationId: 'org', + plan: SubscriptionPlan.gold(), + }).updateStatus(SubscriptionStatus.create(status)); + const subscriptions = { getOrCreateSubscription: jest.fn().mockResolvedValue(subscription) }; + const registry = new CapabilityRegistry( + {} as never, + {} as never, + {} as never, + subscriptions as unknown as SubscriptionService, + {} as never, + {} as never, + { log: jest.fn().mockResolvedValue(undefined) } as never + ); + const controller = new McpController(registry, subscriptions as unknown as SubscriptionService); + const user = { + id: 'user', + organizationId: 'org', + email: 'test@example.test', + firstName: 'Test', + lastName: 'User', + role, + plan: 'GOLD', + }; + const result = await controller.rpc(user, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'whoami' }, + }); + expect(result).toMatchObject({ + result: { + isError: false, + content: [ + { + type: 'text', + text: JSON.stringify( + { userId: 'user', organizationId: 'org', role, plan: expected }, + null, + 2 + ), + }, + ], + }, + }); + expect(subscriptions.getOrCreateSubscription).toHaveBeenCalledWith('org'); + }); +}); diff --git a/apps/backend/src/application/mcp/mcp.controller.ts b/apps/backend/src/application/mcp/mcp.controller.ts index 68c9962..46e9e24 100644 --- a/apps/backend/src/application/mcp/mcp.controller.ts +++ b/apps/backend/src/application/mcp/mcp.controller.ts @@ -155,28 +155,17 @@ export class McpController { /** * Identite de l'appelant, completee de son offre. * - * Une cle API porte deja l'offre ; un jeton JWT ne la porte pas, elle est - * alors lue sur l'abonnement. Sans cette resolution, un utilisateur connecte - * a l'application serait traite comme un compte Bronze. + * L'offre est relue a chaque appel pour appliquer les suspensions et les + * changements de droits, meme si un jeton porte encore une ancienne offre. */ private async actorOf(user: UserPayload & { plan?: string }): Promise { - if (user.plan) { - return { - id: user.id, - organizationId: user.organizationId, - role: user.role, - email: user.email, - plan: user.plan, - }; - } - const subscription = await this.subscriptions.getOrCreateSubscription(user.organizationId); return { id: user.id, organizationId: user.organizationId, role: user.role, email: user.email, - plan: subscription.plan.value, + plan: subscription.accessPlan.value, }; } } diff --git a/apps/backend/src/application/notifications/notifications.module.ts b/apps/backend/src/application/notifications/notifications.module.ts index 9b95537..c9be031 100644 --- a/apps/backend/src/application/notifications/notifications.module.ts +++ b/apps/backend/src/application/notifications/notifications.module.ts @@ -5,6 +5,7 @@ */ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -17,6 +18,7 @@ import { NOTIFICATION_REPOSITORY } from '@domain/ports/out/notification.reposito @Module({ imports: [ + AuthModule, TypeOrmModule.forFeature([NotificationOrmEntity]), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/apps/backend/src/application/services/csv-booking-fee.security.spec.ts b/apps/backend/src/application/services/csv-booking-fee.security.spec.ts new file mode 100644 index 0000000..f8dcd9e --- /dev/null +++ b/apps/backend/src/application/services/csv-booking-fee.security.spec.ts @@ -0,0 +1,133 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { CsvBookingService } from './csv-booking.service'; +import { CreateCsvBookingDto } from '../dto/csv-booking.dto'; + +describe('Booking fee failure boundary', () => { + const setup = () => { + const subscriptionService = { getOrCreateSubscription: jest.fn() }; + const booking = { + id: 'booking', + organizationId: 'org', + accept: jest.fn(), + applyBookingFee: jest.fn(), + }; + const repo = { + findByToken: jest.fn().mockResolvedValue(booking), + repository: { findOne: jest.fn().mockResolvedValue(null) }, + create: jest.fn(), + update: jest.fn(), + }; + const service = new CsvBookingService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + subscriptionService as never, + {} as never + ); + const upload = jest + .spyOn(service as unknown as { uploadDocuments: () => Promise }, 'uploadDocuments') + .mockResolvedValue([]); + return { service, subscriptionService, repo, booking, upload }; + }; + + it('refuses creation before uploading or saving when the fee is unknown', async () => { + const { service, subscriptionService, repo, upload } = setup(); + subscriptionService.getOrCreateSubscription.mockRejectedValue( + new Error('dependency unavailable') + ); + await expect( + service.createBooking({} as CreateCsvBookingDto, [{} as Express.Multer.File], 'user', 'org') + ).rejects.toBeInstanceOf(ServiceUnavailableException); + expect(upload).not.toHaveBeenCalled(); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('does not accept or save a booking when the fee lookup fails', async () => { + const { service, subscriptionService, repo, booking } = setup(); + subscriptionService.getOrCreateSubscription.mockRejectedValue( + new Error('dependency unavailable') + ); + await expect(service.acceptBooking('test-token')).rejects.toBeInstanceOf( + ServiceUnavailableException + ); + expect(booking.accept).not.toHaveBeenCalled(); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it.each([ + [15, 'QUOTE'], + [-1, 'PENDING'], + ])('preserves creation for a known fee %s', async (fee, expectedStatus) => { + const { service, subscriptionService, repo, upload } = setup(); + subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee }); + upload.mockResolvedValue([ + { + id: 'doc', + type: 'OTHER', + fileName: 'test.pdf', + filePath: 'test', + mimeType: 'application/pdf', + size: 1, + uploadedAt: new Date(), + }, + ]); + repo.create.mockImplementation(async value => value); + const mail = jest + .spyOn( + service as unknown as { sendCarrierBookingRequest: () => Promise }, + 'sendCarrierBookingRequest' + ) + .mockResolvedValue(undefined); + jest + .spyOn( + service as unknown as { notifyBookingRequestSent: () => Promise }, + 'notifyBookingRequestSent' + ) + .mockResolvedValue(undefined); + const result = await service.createBooking( + { + carrierName: 'Carrier', + carrierEmail: 'carrier@example.test', + origin: 'FRLEH', + destination: 'CNSHA', + volumeCBM: 1, + weightKG: 100, + palletCount: 1, + priceUSD: 100, + priceEUR: 90, + primaryCurrency: 'EUR', + transitDays: 10, + containerType: 'LCL', + } as CreateCsvBookingDto, + [{} as Express.Multer.File], + 'user', + 'org' + ); + expect(result.status).toBe(expectedStatus); + expect(result.commissionAmountEur).toBe(fee === -1 ? 0 : fee); + expect(repo.create).toHaveBeenCalledTimes(1); + expect(mail).toHaveBeenCalledTimes(fee === -1 ? 1 : 0); + }); + + it.each([ + [15, 15], + [10, 10], + [5, 5], + [-1, 0], + [0, 0], + ])('preserves fee %s as %s', async (fee, expected) => { + const { service, subscriptionService } = setup(); + subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee }); + await expect(service['resolveBookingFeeEur']('org')).resolves.toBe(expected); + }); + + it.each([undefined, NaN, Infinity, -2])('rejects an invalid fee %s', async fee => { + const { service, subscriptionService } = setup(); + subscriptionService.getOrCreateSubscription.mockResolvedValue({ bookingFeeEur: fee }); + await expect(service['resolveBookingFeeEur']('org')).rejects.toBeInstanceOf( + ServiceUnavailableException + ); + }); +}); diff --git a/apps/backend/src/application/services/csv-booking-response.security.spec.ts b/apps/backend/src/application/services/csv-booking-response.security.spec.ts new file mode 100644 index 0000000..30b8c0e --- /dev/null +++ b/apps/backend/src/application/services/csv-booking-response.security.spec.ts @@ -0,0 +1,24 @@ +import { CsvBookingService } from './csv-booking.service'; +import { CsvBooking } from '@domain/entities/csv-booking.entity'; + +describe('customer booking response', () => { + it('omits carrier capabilities while preserving booking information', () => { + const booking = { + id: 'booking-1', + primaryCurrency: 'EUR', + confirmationToken: 'carrier-secret', + origin: { getValue: () => 'FRLEH' }, + destination: { getValue: () => 'CNSHA' }, + documents: [], + getRouteDescription: () => 'FRLEH → CNSHA', + isExpired: () => false, + getPriceInCurrency: () => 100, + } as unknown as CsvBooking; + const service = Object.create(CsvBookingService.prototype) as CsvBookingService; + const response = service['toResponseDto'](booking); + expect(response.id).toBe('booking-1'); + expect(response.price).toBe(100); + expect(response).not.toHaveProperty('confirmationToken'); + expect(JSON.stringify(response)).not.toContain('carrier-secret'); + }); +}); diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 908ef60..ea0f7d8 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, Inject, UnauthorizedException, + ServiceUnavailableException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import * as argon2 from 'argon2'; @@ -151,6 +152,9 @@ export class CsvBookingService { throw new BadRequestException('At least one document is required'); } + // Resolve pricing before uploads or any persistent side effect. + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); + // Generate unique confirmation token and booking number const confirmationToken = uuidv4(); const bookingId = uuidv4(); @@ -166,7 +170,6 @@ export class CsvBookingService { // Flat per-booking service fee (forfait par booking) based on the org's plan. // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the // booking skips the payment gate and the carrier is notified immediately. - const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); const requiresPayment = bookingFeeEur > 0; const initialStatus = requiresPayment ? CsvBookingStatus.QUOTE : CsvBookingStatus.PENDING; @@ -246,17 +249,20 @@ export class CsvBookingService { /** * Resolve the flat per-booking fee (forfait par booking) for an organization * from its subscription plan. Returns the plan's bookingFeeEur, or 0 when the - * plan has a custom fee (-1, e.g. Platinium "sur mesure") or on error — such - * bookings are not auto-charged. + * plan has a custom fee (-1, e.g. Platinium "sur mesure"). + * An unknown fee must never be interpreted as a free booking. */ private async resolveBookingFeeEur(organizationId: string): Promise { try { const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); - const fee = subscription.plan.bookingFeeEur; + const fee = subscription.bookingFeeEur; + if (!Number.isFinite(fee) || (fee < 0 && fee !== -1)) { + throw new Error('Invalid booking fee'); + } return fee > 0 ? fee : 0; - } catch (error: any) { - this.logger.error(`Failed to resolve booking fee: ${error?.message}`); - return 0; + } catch { + this.logger.error('Failed to resolve booking fee'); + throw new ServiceUnavailableException('Booking fee unavailable. Please retry later.'); } } @@ -409,7 +415,7 @@ export class CsvBookingService { }); this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { - this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); + this.logger.error('Failed to send email to carrier'); } } @@ -491,7 +497,7 @@ export class CsvBookingService { this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); } } catch (error: any) { - this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack); + this.logger.error('Failed to send admin notification email'); } // In-app notification for the user @@ -617,11 +623,7 @@ export class CsvBookingService { `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` ); } catch (error: any) { - this.logger.error( - `Bank transfer validated for booking ${bookingId} but the carrier email to ` + - `${booking.carrierEmail} failed: ${error?.message}`, - error?.stack - ); + this.logger.error('Failed to send email to carrier'); } // In-app notification for the user @@ -702,7 +704,7 @@ export class CsvBookingService { const booking = await this.csvBookingRepository.findByToken(token); if (!booking) { - throw new NotFoundException(`Booking with token ${token} not found`); + throw new NotFoundException('Booking not found'); } return this.toResponseDto(booking); @@ -716,7 +718,7 @@ export class CsvBookingService { token: string, password?: string ): Promise { - this.logger.log(`Getting documents for carrier with token: ${token}`); + this.logger.log('Getting documents for carrier'); // Get ORM entity to access passwordHash const ormBooking = await this.csvBookingRepository['repository'].findOne({ @@ -886,7 +888,7 @@ export class CsvBookingService { * Accept a booking request */ async acceptBooking(token: string): Promise { - this.logger.log(`Accepting booking with token: ${token}`); + this.logger.log('Accepting booking'); const booking = await this.csvBookingRepository.findByToken(token); @@ -899,11 +901,9 @@ export class CsvBookingService { where: { confirmationToken: token }, }); - // Accept the booking (domain logic validates status) - booking.accept(); - - // Apply the flat per-booking service fee (forfait par booking) from the org's plan + // Resolve pricing before mutating the booking. const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); + booking.accept(); booking.applyBookingFee(bookingFeeEur); this.logger.log( `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` @@ -933,7 +933,7 @@ export class CsvBookingService { }); this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { - this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack); + this.logger.error('Failed to send document access email'); } // Create notification for user @@ -960,7 +960,7 @@ export class CsvBookingService { * Reject a booking request */ async rejectBooking(token: string, reason?: string): Promise { - this.logger.log(`Rejecting booking with token: ${token}`); + this.logger.log('Rejecting booking'); const booking = await this.csvBookingRepository.findByToken(token); @@ -1411,10 +1411,7 @@ export class CsvBookingService { }); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); } catch (error: any) { - this.logger.error( - `Failed to send new documents notification: ${error?.message}`, - error?.stack - ); + this.logger.error('Failed to send new documents notification'); } } @@ -1618,7 +1615,6 @@ export class CsvBookingService { containerType: booking.containerType, status: booking.status, documents: booking.documents.map(this.toDocumentDto), - confirmationToken: booking.confirmationToken, requestedAt: booking.requestedAt, respondedAt: booking.respondedAt || null, notes: booking.notes, diff --git a/apps/backend/src/application/services/invitation.security.spec.ts b/apps/backend/src/application/services/invitation.security.spec.ts new file mode 100644 index 0000000..9c0936a --- /dev/null +++ b/apps/backend/src/application/services/invitation.security.spec.ts @@ -0,0 +1,57 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InvitationService } from './invitation.service'; +import { SubscriptionService } from './subscription.service'; +import { InvitationToken } from '@domain/entities/invitation-token.entity'; +import { UserRole } from '@domain/entities/user.entity'; +import { InvitationTokenRepository } from '@domain/ports/out/invitation-token.repository'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { OrganizationRepository } from '@domain/ports/out/organization.repository'; +import { EmailPort } from '@domain/ports/out/email.port'; + +describe('invitation secret handling', () => { + it('keeps the token in the email but out of success and failure logs', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + try { + const invitation = InvitationToken.create({ + id: 'invite-id', + token: 'test-only-invitation-secret', + email: 'user@example.org', + firstName: 'Test', + lastName: 'User', + role: UserRole.USER, + organizationId: 'org', + invitedById: 'admin', + expiresAt: new Date(Date.now() + 60_000), + }); + const send = jest.fn().mockResolvedValue(undefined); + const service = new InvitationService( + { + findByToken: async () => invitation, + update: async () => invitation, + } as unknown as InvitationTokenRepository, + { + findById: async () => ({ firstName: 'Test', lastName: 'Admin' }), + } as unknown as UserRepository, + { findById: async () => ({ name: 'Company' }) } as unknown as OrganizationRepository, + { sendInvitationWithToken: send } as unknown as EmailPort, + new ConfigService({ FRONTEND_URL: 'https://example.org' }), + {} as SubscriptionService + ); + await service['sendInvitationEmail'](invitation); + expect(send.mock.calls[0][5]).toBe( + 'https://example.org/register?token=test-only-invitation-secret' + ); + send.mockRejectedValue(new Error('test-only-invitation-secret')); + await expect(service['sendInvitationEmail'](invitation)).rejects.toThrow(); + await service.markInvitationAsUsed(invitation.token); + expect(JSON.stringify([...log.mock.calls, ...error.mock.calls])).not.toContain( + invitation.token + ); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); +}); diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index 3543023..ffd2e0a 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -109,10 +109,8 @@ export class InvitationService { // Send invitation email (async - don't block on email sending) this.logger.log(`[INVITATION] About to send email to ${email}...`); - this.sendInvitationEmail(savedInvitation).catch(err => { - this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${email}`, err); - this.logger.error(`[INVITATION] Error message: ${err?.message}`); - this.logger.error(`[INVITATION] Error stack: ${err?.stack?.substring(0, 500)}`); + this.sendInvitationEmail(savedInvitation).catch(() => { + this.logger.error(`Invitation email delivery failed: ${savedInvitation.id}`); }); this.logger.log(`Invitation created successfully for ${email}`); @@ -151,7 +149,7 @@ export class InvitationService { await this.invitationRepository.update(invitation); - this.logger.log(`Invitation ${token} marked as used`); + this.logger.log(`Invitation ${invitation.id} marked as used`); } /** @@ -178,7 +176,6 @@ export class InvitationService { const invitationLink = `${frontendUrl}/register?token=${invitation.token}`; this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`); - this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`); // Get organization details this.logger.log(`[INVITATION] Fetching organization ${invitation.organizationId}...`); @@ -214,11 +211,7 @@ export class InvitationService { this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`); } catch (error) { - this.logger.error( - `[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`, - error - ); - this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`); + this.logger.error(`Invitation email delivery failed: ${invitation.id}`); throw error; } } diff --git a/apps/backend/src/application/services/notification.security.spec.ts b/apps/backend/src/application/services/notification.security.spec.ts new file mode 100644 index 0000000..f6d6ae3 --- /dev/null +++ b/apps/backend/src/application/services/notification.security.spec.ts @@ -0,0 +1,38 @@ +import { NotificationService } from './notification.service'; +import { NotificationRepository } from '@domain/ports/out/notification.repository'; +import { TypeOrmNotificationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-notification.repository'; +import { NotificationOrmEntity } from '@infrastructure/persistence/typeorm/entities/notification.orm-entity'; +import { Repository } from 'typeorm'; + +describe('notification mutation boundary', () => { + const owner = 'bd223f0d-89be-4f98-aaf4-0ab1353594e1'; + const other = 'bd223f0d-89be-4f98-aaf4-0ab1353594e2'; + const id = 'bd223f0d-89be-4f98-aaf4-0ab1353594e3'; + it.each([{ read: false }, [], null, '', 'invalid'])( + 'rejects malformed notification criteria %j', + async value => { + const markAsRead = jest.fn(); + const service = new NotificationService({ markAsRead } as unknown as NotificationRepository); + await expect(service.markAsRead(value as unknown as string, owner)).rejects.toThrow(); + expect(markAsRead).not.toHaveBeenCalled(); + } + ); + it('restricts an update to the authenticated recipient', async () => { + const row = { id, user_id: owner, read: false }; + const update = jest.fn(async (criteria: { id: string; user_id: string }) => { + if (row.id === criteria.id && row.user_id === criteria.user_id) row.read = true; + }); + const repository = new TypeOrmNotificationRepository({ + update, + } as unknown as Repository); + const service = new NotificationService(repository); + await service.markAsRead(id, other); + expect(row.read).toBe(false); + expect(update).toHaveBeenLastCalledWith( + { id, user_id: other }, + expect.objectContaining({ read: true }) + ); + await service.markAsRead(id, owner); + expect(row.read).toBe(true); + }); +}); diff --git a/apps/backend/src/application/services/notification.service.ts b/apps/backend/src/application/services/notification.service.ts index 2deac6a..2b7417f 100644 --- a/apps/backend/src/application/services/notification.service.ts +++ b/apps/backend/src/application/services/notification.service.ts @@ -4,8 +4,8 @@ * Handles creating and sending notifications to users */ -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { v4 as uuidv4 } from 'uuid'; +import { Injectable, Logger, Inject, BadRequestException } from '@nestjs/common'; +import { v4 as uuidv4, validate as isUuid } from 'uuid'; import { Notification, NotificationType, @@ -109,8 +109,11 @@ export class NotificationService { /** * Mark notification as read */ - async markAsRead(id: string): Promise { - await this.notificationRepository.markAsRead(id); + async markAsRead(id: string, userId: string): Promise { + if (typeof id !== 'string' || !isUuid(id) || typeof userId !== 'string' || !isUuid(userId)) { + throw new BadRequestException('Invalid notification or user ID'); + } + await this.notificationRepository.markAsRead(id, userId); this.logger.log(`Notification marked as read: ${id}`); } diff --git a/apps/backend/src/application/services/subscription-cancellation.spec.ts b/apps/backend/src/application/services/subscription-cancellation.spec.ts new file mode 100644 index 0000000..ba22b8f --- /dev/null +++ b/apps/backend/src/application/services/subscription-cancellation.spec.ts @@ -0,0 +1,56 @@ +import { ConfigService } from '@nestjs/config'; +import { RawBodyRequest } from '@nestjs/common'; +import { Request } from 'express'; +import { SubscriptionService } from './subscription.service'; +import { SubscriptionsController } from '../controllers/subscriptions.controller'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionPlan } from '@domain/value-objects/subscription-plan.vo'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { LicenseRepository } from '@domain/ports/out/license.repository'; +import { OrganizationRepository } from '@domain/ports/out/organization.repository'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { StripePort } from '@domain/ports/out/stripe.port'; + +describe('Stripe subscription deletion', () => { + it('persists cancellation with excess seats and accepts a duplicate event', async () => { + let saved = Subscription.create({ + id: 'sub', + organizationId: 'org', + plan: SubscriptionPlan.gold(), + }); + const save = jest.fn(async (value: Subscription) => { + saved = value; + }); + const count = jest.fn(async () => 10); + const service = new SubscriptionService( + { findByStripeSubscriptionId: async () => saved, save } as unknown as SubscriptionRepository, + { countActiveBySubscriptionIdExcludingAdmins: count } as unknown as LicenseRepository, + { findById: async () => null } as unknown as OrganizationRepository, + {} as UserRepository, + { + constructWebhookEvent: async () => ({ + type: 'customer.subscription.deleted', + data: { object: { id: 'stripe-sub' } }, + }), + } as unknown as StripePort, + new ConfigService() + ); + await service.handleStripeWebhook(Buffer.from('signed fixture'), 'signature'); + await service.handleStripeWebhook(Buffer.from('signed fixture'), 'signature'); + expect(saved.plan.value).toBe('BRONZE'); + expect(saved.status.value).toBe('CANCELED'); + expect(save).toHaveBeenCalledTimes(2); + }); + + it('does not acknowledge processing failures as successful delivery', async () => { + const handleStripeWebhook = jest.fn().mockRejectedValue(new Error('storage unavailable')); + const controller = new SubscriptionsController( + { handleStripeWebhook } as unknown as SubscriptionService, + {} as OrganizationRepository + ); + const req = { rawBody: Buffer.from('fixture') } as RawBodyRequest; + await expect(controller.handleWebhook('signature', req)).rejects.toMatchObject({ status: 500 }); + handleStripeWebhook.mockResolvedValue(undefined); + await expect(controller.handleWebhook('signature', req)).resolves.toEqual({ received: true }); + }); +}); diff --git a/apps/backend/src/application/services/subscription-sync.security.spec.ts b/apps/backend/src/application/services/subscription-sync.security.spec.ts new file mode 100644 index 0000000..7b170db --- /dev/null +++ b/apps/backend/src/application/services/subscription-sync.security.spec.ts @@ -0,0 +1,115 @@ +import { ConfigService } from '@nestjs/config'; +import { SubscriptionService } from './subscription.service'; +import { Subscription } from '@domain/entities/subscription.entity'; +import { SubscriptionRepository } from '@domain/ports/out/subscription.repository'; +import { LicenseRepository } from '@domain/ports/out/license.repository'; +import { OrganizationRepository } from '@domain/ports/out/organization.repository'; +import { UserRepository } from '@domain/ports/out/user.repository'; +import { + StripePort, + StripeCheckoutSessionData, + StripeSubscriptionData, +} from '@domain/ports/out/stripe.port'; +import { SubscriptionOverviewResponseDto } from '../dto/subscription.dto'; + +describe('Stripe checkout organization binding', () => { + let subscription: Subscription; + let session: StripeCheckoutSessionData; + let stripeData: StripeSubscriptionData; + let save: jest.Mock; + let getSubscription: jest.Mock; + let service: SubscriptionService; + beforeEach(() => { + subscription = Subscription.create({ id: 'local-sub', organizationId: 'org-caller' }); + session = { + sessionId: 'cs_fixture', + customerId: 'cus_fixture', + subscriptionId: 'sub_fixture', + status: 'complete', + metadata: { organizationId: 'org-caller' }, + }; + stripeData = { + subscriptionId: 'sub_fixture', + customerId: 'cus_fixture', + status: 'active', + planId: 'price_fixture', + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }; + // No local row owns the Stripe subscription yet: models checkout before webhook delivery. + save = jest.fn(async (value: Subscription) => { + subscription = value; + return value; + }); + getSubscription = jest.fn(async () => stripeData); + service = new SubscriptionService( + { findByOrganizationId: async () => subscription, save } as unknown as SubscriptionRepository, + { countActiveBySubscriptionIdExcludingAdmins: async () => 0 } as unknown as LicenseRepository, + {} as OrganizationRepository, + {} as UserRepository, + { + getCheckoutSession: async () => session, + getSubscription, + mapPriceIdToPlan: () => 'GOLD', + } as unknown as StripePort, + new ConfigService() + ); + jest + .spyOn(service, 'getSubscriptionOverview') + .mockResolvedValue({} as SubscriptionOverviewResponseDto); + }); + + it.each(['org-victim', undefined])( + 'rejects a checkout whose organization is %j before fetching or saving its subscription', + async owner => { + session.metadata = owner ? { organizationId: owner } : {}; + await expect(service.syncFromStripe('org-caller', session.sessionId)).rejects.toMatchObject({ + status: 403, + }); + expect(getSubscription).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + } + ); + + it('preserves checkout synchronization for its authenticated organization', async () => { + await service.syncFromStripe('org-caller', session.sessionId); + expect(save).toHaveBeenCalledTimes(1); + expect(subscription.stripeSubscriptionId).toBe('sub_fixture'); + expect(subscription.stripeCustomerId).toBe('cus_fixture'); + expect(subscription.plan.value).toBe('GOLD'); + }); + + it('does not accept a foreign checkout merely because the customer matches', async () => { + subscription = subscription.updateStripeCustomerId('cus_fixture'); + session.metadata = { organizationId: 'org-victim' }; + await expect(service.syncFromStripe('org-caller', session.sessionId)).rejects.toMatchObject({ + status: 403, + }); + expect(save).not.toHaveBeenCalled(); + }); + + it('permits an owned upgrade to replace the existing Stripe subscription ID', async () => { + subscription = subscription.updateStripeCustomerId('cus_fixture').updateStripeSubscription({ + stripeSubscriptionId: 'sub_old', + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }); + await service.syncFromStripe('org-caller', session.sessionId); + expect(subscription.stripeSubscriptionId).toBe('sub_fixture'); + expect(save).toHaveBeenCalledTimes(1); + }); + + it('preserves sessionless refresh of an already linked subscription', async () => { + subscription = subscription.updateStripeCustomerId('cus_fixture').updateStripeSubscription({ + stripeSubscriptionId: 'sub_fixture', + currentPeriodStart: new Date(), + currentPeriodEnd: new Date(), + cancelAtPeriodEnd: false, + }); + await service.syncFromStripe('org-caller'); + expect(getSubscription).toHaveBeenCalledWith('sub_fixture'); + expect(save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts index 0efd705..294f3a4 100644 --- a/apps/backend/src/application/services/subscription.service.ts +++ b/apps/backend/src/application/services/subscription.service.ts @@ -4,7 +4,14 @@ * Business logic for subscription and license management. */ -import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + Inject, + Logger, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; import { @@ -90,7 +97,7 @@ export class SubscriptionService { // ADMIN users always have PLATINIUM plan with no expiration. // La regle vit dans le domaine : l'assistant la lit au meme endroit. const isAdmin = userRole === PLATFORM_ADMIN_ROLE; - const effectivePlan = resolveEffectivePlan(userRole, subscription.plan); + const effectivePlan = resolveEffectivePlan(userRole, subscription.accessPlan); const maxLicenses = effectivePlan.maxLicenses; const availableLicenses = effectivePlan.isUnlimited() ? -1 @@ -280,6 +287,9 @@ export class SubscriptionService { const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); if (checkoutSession) { + if (checkoutSession.metadata?.organizationId !== organizationId) { + throw new ForbiddenException('Checkout session does not belong to this organization'); + } this.logger.log( `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` ); @@ -608,12 +618,7 @@ export class SubscriptionService { } // Downgrade to FREE plan - count only non-ADMIN licenses - const canceledSubscription = subscription - .updatePlan( - SubscriptionPlan.bronze(), - await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) - ) - .updateStatus(SubscriptionStatus.canceled()); + const canceledSubscription = subscription.cancel(); await this.subscriptionRepository.save(canceledSubscription); diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts index 190c521..33cc104 100644 --- a/apps/backend/src/domain/entities/subscription.entity.spec.ts +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -357,6 +357,18 @@ describe('Subscription Entity', () => { }); describe('cancel', () => { + it('removes paid entitlements even when inactive, and remains idempotent', () => { + const paid = Subscription.create({ + id: 'sub-paid', + organizationId: 'org-1', + plan: SubscriptionPlan.gold(), + }); + const inactive = paid.updateStatus(SubscriptionStatus.canceled()); + const result = inactive.cancel().cancel(); + expect(result.plan.value).toBe('BRONZE'); + expect(result.status.value).toBe('CANCELED'); + expect(paid.plan.value).toBe('GOLD'); + }); it('should cancel the subscription immediately', () => { const subscription = createValidSubscription(); const updated = subscription.cancel(); diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts index 34e49fd..7c21458 100644 --- a/apps/backend/src/domain/entities/subscription.entity.ts +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -62,35 +62,34 @@ export class Subscription { }); } - /** - * Reconstitute from persistence - */ - /** - * Check if a specific plan feature is available - */ + /** Current entitlements; keep the persisted plan intact for billing and recovery. */ + get accessPlan(): SubscriptionPlan { + return this.isActive() ? this.props.plan : SubscriptionPlan.bronze(); + } + hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean { - return this.props.plan.hasFeature(feature); + return this.accessPlan.hasFeature(feature); } /** * Get the maximum shipments per year allowed */ get maxShipmentsPerYear(): number { - return this.props.plan.maxShipmentsPerYear; + return this.accessPlan.maxShipmentsPerYear; } /** * Get the per-booking fee for this subscription's plan */ get bookingFeeEur(): number { - return this.props.plan.bookingFeeEur; + return this.accessPlan.bookingFeeEur; } /** * Get the status badge for this subscription's plan */ get statusBadge(): string { - return this.props.plan.statusBadge; + return this.accessPlan.statusBadge; } /** @@ -345,6 +344,7 @@ export class Subscription { return new Subscription({ ...this.props, status: SubscriptionStatus.canceled(), + plan: SubscriptionPlan.bronze(), cancelAtPeriodEnd: false, updatedAt: new Date(), }); diff --git a/apps/backend/src/domain/ports/out/notification.repository.ts b/apps/backend/src/domain/ports/out/notification.repository.ts index 24ad7fe..c562f37 100644 --- a/apps/backend/src/domain/ports/out/notification.repository.ts +++ b/apps/backend/src/domain/ports/out/notification.repository.ts @@ -60,7 +60,7 @@ export interface NotificationRepository { /** * Mark a notification as read */ - markAsRead(id: string): Promise; + markAsRead(id: string, userId: string): Promise; /** * Mark all notifications as read for a user diff --git a/apps/backend/src/infrastructure/email/email-sender.spec.ts b/apps/backend/src/infrastructure/email/email-sender.spec.ts new file mode 100644 index 0000000..617cf61 --- /dev/null +++ b/apps/backend/src/infrastructure/email/email-sender.spec.ts @@ -0,0 +1,97 @@ +import { Logger } from '@nestjs/common'; +import { EmailAdapter } from './email.adapter'; + +/** + * Tous les emails doivent partir de l'adresse SMTP_FROM, la seule validee chez + * le relais SMTP (Brevo). Les invitations et les demandes aux transporteurs + * partaient d'adresses codees en dur et etaient refusees. + */ + +function buildAdapter(smtpFrom = 'noreply@xpeditis.com') { + const settings: Record = { + SMTP_FROM: smtpFrom, + APP_URL: 'https://app.preprod.xpeditis.com', + }; + const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) }; + const templates = { + renderInvitationWithToken: jest.fn(async () => '

invitation

'), + renderCsvBookingRequest: jest.fn(async () => '

demande

'), + renderUserInvitation: jest.fn(async () => '

compte

'), + renderPasswordResetEmail: jest.fn(async () => '

reset

'), + }; + const adapter = new EmailAdapter(config as never, templates as never); + + // Parametre type : sans lui, Jest infere un appel sans argument et + // `mock.calls[0][0]` ne compile pas. + const sendMail = jest.fn(async (_mail: { from: string; to: string }) => ({ + messageId: 'm-1', + accepted: ['x'], + rejected: [], + })); + (adapter as unknown as { transporter: { sendMail: typeof sendMail } }).transporter = { sendMail }; + + return { adapter, sendMail }; +} + +const sentFrom = (sendMail: jest.Mock) => (sendMail.mock.calls[0][0] as { from: string }).from; + +describe('EmailAdapter — expediteur', () => { + beforeAll(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + it("envoie l'invitation depuis SMTP_FROM, avec le nom de l'equipe", async () => { + const { adapter, sendMail } = buildAdapter(); + + await adapter.sendInvitationWithToken( + 'nouveau@client.test', + 'Marie', + 'Dupont', + 'Acme', + 'Paul Martin', + 'https://app/register?token=t', + new Date('2026-09-21T10:00:00Z') + ); + + expect(sentFrom(sendMail)).toBe('"Équipe Xpeditis" '); + }); + + it('envoie la demande au transporteur depuis SMTP_FROM', async () => { + const { adapter, sendMail } = buildAdapter(); + + await adapter.sendCsvBookingRequest('booking@ssc.test', { + bookingId: 'b-1', + bookingNumber: 'XPD-2026-AAAAAA', + origin: 'FRLEH', + destination: 'EGEDK', + volumeCBM: 2.4, + weightKG: 850, + palletCount: 2, + priceUSD: 200, + priceEUR: 180, + primaryCurrency: 'EUR', + transitDays: 11, + containerType: 'LCL', + documents: [], + confirmationToken: 'token', + }); + + expect(sentFrom(sendMail)).toBe('"Xpeditis Bookings" '); + expect((sendMail.mock.calls[0][0] as { to: string }).to).toBe('booking@ssc.test'); + }); + + it("suit l'adresse configuree, pour tous les types d'email", async () => { + const { adapter, sendMail } = buildAdapter('contact@mondomaine.fr'); + + await adapter.sendUserInvitation('a@b.test', 'Acme', 'Paul', 'Temp-1234'); + await adapter.sendPasswordResetEmail('a@b.test', 'token'); + await adapter.send({ to: 'a@b.test', subject: 'Test', html: '

t

' }); + + const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from); + expect(froms).toEqual([ + '"Équipe Xpeditis" ', + '"Xpeditis Sécurité" ', + '"Xpeditis" ', + ]); + }); +}); diff --git a/apps/backend/src/infrastructure/email/email.adapter.spec.ts b/apps/backend/src/infrastructure/email/email.adapter.spec.ts index 617cf61..500fb3a 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.spec.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.spec.ts @@ -1,97 +1,138 @@ -import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; +import { createServer, Server, Socket } from 'net'; import { EmailAdapter } from './email.adapter'; +import { EmailTemplates } from './templates/email-templates'; -/** - * Tous les emails doivent partir de l'adresse SMTP_FROM, la seule validee chez - * le relais SMTP (Brevo). Les invitations et les demandes aux transporteurs - * partaient d'adresses codees en dur et etaient refusees. - */ +jest.mock('nodemailer', () => ({ createTransport: jest.fn() })); -function buildAdapter(smtpFrom = 'noreply@xpeditis.com') { - const settings: Record = { - SMTP_FROM: smtpFrom, - APP_URL: 'https://app.preprod.xpeditis.com', - }; - const config = { get: jest.fn((key: string, fallback?: unknown) => settings[key] ?? fallback) }; - const templates = { - renderInvitationWithToken: jest.fn(async () => '

invitation

'), - renderCsvBookingRequest: jest.fn(async () => '

demande

'), - renderUserInvitation: jest.fn(async () => '

compte

'), - renderPasswordResetEmail: jest.fn(async () => '

reset

'), - }; - const adapter = new EmailAdapter(config as never, templates as never); +const configuration = (values: Record) => + ({ + get: (key: string, fallback?: unknown) => values[key] ?? fallback, + }) as ConfigService; - // Parametre type : sans lui, Jest infere un appel sans argument et - // `mock.calls[0][0]` ne compile pas. - const sendMail = jest.fn(async (_mail: { from: string; to: string }) => ({ - messageId: 'm-1', - accepted: ['x'], - rejected: [], - })); - (adapter as unknown as { transporter: { sendMail: typeof sendMail } }).transporter = { sendMail }; - - return { adapter, sendMail }; -} - -const sentFrom = (sendMail: jest.Mock) => (sendMail.mock.calls[0][0] as { from: string }).from; - -describe('EmailAdapter — expediteur', () => { - beforeAll(() => { - jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); - }); - - it("envoie l'invitation depuis SMTP_FROM, avec le nom de l'equipe", async () => { - const { adapter, sendMail } = buildAdapter(); - - await adapter.sendInvitationWithToken( - 'nouveau@client.test', - 'Marie', - 'Dupont', - 'Acme', - 'Paul Martin', - 'https://app/register?token=t', - new Date('2026-09-21T10:00:00Z') +describe('SMTP transport security', () => { + const verify = jest.fn(); + const sendMail = jest.fn(); + const options = (environment: string, secure = false) => { + const adapter = new EmailAdapter( + configuration({ + NODE_ENV: environment, + SMTP_PORT: secure ? 465 : 587, + SMTP_SECURE: secure, + SMTP_USER: 'test-user', + SMTP_PASS: 'test-only-password', + }), + {} as EmailTemplates ); - - expect(sentFrom(sendMail)).toBe('"Équipe Xpeditis" '); + adapter['buildTransporter']('127.0.0.1', 'smtp.example.org'); + return { + adapter, + config: jest + .mocked(nodemailer.createTransport) + .mock.calls.at(-1)![0] as SMTPTransport.Options, + }; + }; + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(nodemailer.createTransport) + .mockReturnValue({ verify, sendMail } as unknown as nodemailer.Transporter); }); - it('envoie la demande au transporteur depuis SMTP_FROM', async () => { - const { adapter, sendMail } = buildAdapter(); - - await adapter.sendCsvBookingRequest('booking@ssc.test', { - bookingId: 'b-1', - bookingNumber: 'XPD-2026-AAAAAA', - origin: 'FRLEH', - destination: 'EGEDK', - volumeCBM: 2.4, - weightKG: 850, - palletCount: 2, - priceUSD: 200, - priceEUR: 180, - primaryCurrency: 'EUR', - transitDays: 11, - containerType: 'LCL', - documents: [], - confirmationToken: 'token', - }); - - expect(sentFrom(sendMail)).toBe('"Xpeditis Bookings" '); - expect((sendMail.mock.calls[0][0] as { to: string }).to).toBe('booking@ssc.test'); + it('requires production TLS and validates the original hostname after IP resolution', () => { + const { config } = options('production'); + expect(config.requireTLS).toBe(true); + expect(config.tls).toMatchObject({ rejectUnauthorized: true, servername: 'smtp.example.org' }); + expect(config.host).toBe('127.0.0.1'); + expect(config.secure).toBe(false); }); - - it("suit l'adresse configuree, pour tous les types d'email", async () => { - const { adapter, sendMail } = buildAdapter('contact@mondomaine.fr'); - - await adapter.sendUserInvitation('a@b.test', 'Acme', 'Paul', 'Temp-1234'); - await adapter.sendPasswordResetEmail('a@b.test', 'token'); - await adapter.send({ to: 'a@b.test', subject: 'Test', html: '

t

' }); - - const froms = sendMail.mock.calls.map(call => (call[0] as { from: string }).from); - expect(froms).toEqual([ - '"Équipe Xpeditis" ', - '"Xpeditis Sécurité" ', - '"Xpeditis" ', - ]); + it('preserves implicit TLS and local development plaintext support', () => { + expect(options('production', true).config.secure).toBe(true); + expect(options('development').config.requireTLS).toBe(false); + expect(options('development').config.tls?.rejectUnauthorized).toBe(true); + }); + it('propagates secure delivery failures', async () => { + const { adapter } = options('production'); + sendMail.mockRejectedValue(new Error('certificate verification failed: secret-fixture')); + await expect( + adapter.send({ to: 'test@example.org', subject: 'Test', text: 'Test' }) + ).rejects.toThrow('Email delivery failed'); + }); +}); + +describe('SMTP STARTTLS downgrade regression', () => { + let server: Server; + const sockets = new Set(); + const commands: string[] = []; + beforeAll(async () => { + server = createServer(socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.write('220 localhost test SMTP\r\n'); + let pending = ''; + socket.on('data', chunk => { + pending += chunk.toString(); + let end: number; + while ((end = pending.indexOf('\r\n')) >= 0) { + const command = pending.slice(0, end); + pending = pending.slice(end + 2); + commands.push(command.split(' ')[0]); + if (/^EHLO/.test(command)) socket.write('250-localhost\r\n250 AUTH PLAIN\r\n'); + else if (/^STARTTLS/.test(command)) socket.write('454 TLS unavailable\r\n'); + else if (/^AUTH/.test(command)) socket.write('235 Authentication successful\r\n'); + else socket.write('250 OK\r\n'); + } + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + }); + afterAll(async () => { + for (const socket of sockets) socket.destroy(); + if (server?.listening) await new Promise(resolve => server.close(() => resolve())); + }); + + it('refuses a downgrade before sending credentials, while the local test control can authenticate', async () => { + const actual = jest.requireActual('nodemailer'); + jest + .mocked(nodemailer.createTransport) + .mockReturnValue({ verify: jest.fn() } as unknown as nodemailer.Transporter); + const adapter = new EmailAdapter( + configuration({ + NODE_ENV: 'production', + SMTP_USER: 'test-user', + SMTP_PASS: 'test-password', + }), + {} as EmailTemplates + ); + adapter['buildTransporter']('127.0.0.1', 'localhost'); + const config = jest + .mocked(nodemailer.createTransport) + .mock.calls.at(-1)![0] as SMTPTransport.Options; + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test server'); + const transport = actual.createTransport({ ...config, port: address.port }); + try { + await expect(transport.verify()).rejects.toThrow(); + expect(commands).toContain('STARTTLS'); + expect(commands).not.toContain('AUTH'); + } finally { + transport.close(); + } + const localControl = actual.createTransport({ + ...config, + port: address.port, + requireTLS: false, + }); + try { + await expect(localControl.verify()).resolves.toBe(true); + expect(commands).toContain('AUTH'); + } finally { + localControl.close(); + } }); }); diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 66b5079..8032340 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -153,9 +153,10 @@ export class EmailAdapter implements EmailPort, OnModuleInit { host: actualHost, port, secure, + requireTLS: this.configService.get('NODE_ENV') === 'production', auth: { user, pass }, tls: { - rejectUnauthorized: false, + rejectUnauthorized: true, servername: serverName, }, connectionTimeout: 15000, @@ -212,8 +213,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit { `✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}` ); } catch (error) { - this.logger.error(`Failed to send email to ${options.to}`, error); - throw error; + this.logger.error('Email delivery failed'); + throw new Error('Email delivery failed'); } } @@ -317,11 +318,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit { this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error( - `[sendInvitationWithToken] ${errorMessage} | code: ${(error as any)?.code} | response: ${(error as any)?.response}` - ); - throw error; + this.logger.error('Invitation email delivery failed'); + + throw new Error('Invitation email delivery failed'); } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts b/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts index 9b0f5a6..e0562a0 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/data-source.ts @@ -4,9 +4,11 @@ * Used for migrations and CLI commands */ +import { SafeDatabaseLogger } from './safe-database-logger'; import { DataSource } from 'typeorm'; import { config } from 'dotenv'; import { join } from 'path'; +import { databaseTlsOptions } from './database-tls'; // Load environment variables config(); @@ -23,5 +25,10 @@ export const AppDataSource = new DataSource({ subscribers: [], synchronize: false, // Never use in production logging: process.env.NODE_ENV === 'development', - ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false, + logger: new SafeDatabaseLogger(process.env.NODE_ENV === 'development'), + ssl: databaseTlsOptions( + process.env.DATABASE_SSL, + process.env.DATABASE_SSL_CA, + process.env.DATABASE_HOST + ), }); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/database-startup.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/database-startup.spec.ts new file mode 100644 index 0000000..3d158f9 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/database-startup.spec.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'fs'; +import { resolve, join } from 'path'; +import { runInNewContext } from 'vm'; +import { databaseTlsOptions } from './database-tls'; + +describe('database bootstrap TLS wiring', () => { + it.each([true, false])( + 'applies identical TLS settings to readiness and migrations (packaged=%s)', + async packaged => { + const backendRoot = resolve(__dirname, '../../../..'); + const applicationRoot = packaged ? '/app' : '/workspace/apps/backend'; + const scriptDirectory = packaged ? '/app' : `${applicationRoot}/scripts/setup`; + const clients: Record[] = []; + const sources: Record[] = []; + const helperPaths: string[] = []; + const env = { + DATABASE_HOST: '10.10.1.20', + DATABASE_SSL: 'true', + DATABASE_SSL_CA: 'test-public-ca', + }; + const processExit = jest.fn(); + class Client { + constructor(config: Record) { + clients.push(config); + } + async connect() {} + async end() {} + } + class DataSource { + constructor(config: Record) { + sources.push(config); + } + async initialize() {} + async runMigrations() { + return []; + } + async destroy() {} + } + const mockRequire = (name: string): unknown => { + if (name === 'pg') return { Client }; + if (name === 'typeorm') return { DataSource }; + if (name === 'path') return { join, resolve }; + if (name === 'fs') return { existsSync: () => packaged }; + if (name === 'child_process') return { spawn: jest.fn() }; + helperPaths.push(name); + expect(name).toBe( + `${applicationRoot}/dist/infrastructure/persistence/typeorm/database-tls` + ); + return { databaseTlsOptions }; + }; + const module = { + exports: {} as { + waitForPostgres: () => Promise; + runMigrations: () => Promise; + }, + }; + const context = { + require: mockRequire, + module, + __dirname: scriptDirectory, + process: { env, exit: processExit }, + console: { log: jest.fn(), error: jest.fn() }, + }; + runInNewContext(readFileSync(join(backendRoot, 'scripts/setup/startup.js'), 'utf8'), context); + await module.exports.waitForPostgres(); + await module.exports.runMigrations(); + await runInNewContext( + readFileSync(join(backendRoot, 'scripts/setup/run-migrations.js'), 'utf8'), + { ...context, module: { exports: {} } } + ); + expect(helperPaths).toHaveLength(2); + expect(clients).toHaveLength(1); + expect(sources).toHaveLength(2); + for (const config of [...clients, ...sources]) { + expect(config.ssl).toEqual({ + rejectUnauthorized: true, + host: env.DATABASE_HOST, + ca: env.DATABASE_SSL_CA, + }); + } + for (const config of sources) { + expect(config.migrations).toEqual([ + `${applicationRoot}/dist/infrastructure/persistence/typeorm/migrations/*.js`, + ]); + } + expect(processExit).not.toHaveBeenCalledWith(1); + } + ); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/database-tls.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/database-tls.spec.ts new file mode 100644 index 0000000..6a905da --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/database-tls.spec.ts @@ -0,0 +1,109 @@ +import { databaseTlsOptions } from './database-tls'; +import { createServer, connect, TLSSocket, Server } from 'tls'; +import { connect as connectSocket } from 'net'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +describe('database TLS configuration', () => { + it.each([undefined, false, 'false', 'FALSE'])( + 'preserves explicit local non-TLS setting %j', + enabled => { + expect(databaseTlsOptions(enabled)).toBe(false); + } + ); + it.each([true, 'true', 'TRUE'])('requires certificate and host verification for %j', enabled => { + expect(databaseTlsOptions(enabled, undefined, '10.10.1.20')).toEqual({ + rejectUnauthorized: true, + host: '10.10.1.20', + }); + }); + it('rejects invalid flags and empty custom trust', () => { + expect(() => databaseTlsOptions('typo')).toThrow(); + expect(() => databaseTlsOptions(true, ' ')).toThrow(); + }); +}); + +describe('database TLS certificate and IP identity', () => { + let directory: string; + let cert: string; + let server: Server; + const sockets = new Set(); + beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'xpeditis-db-tls-test-')); + execFileSync( + 'openssl', + [ + 'req', + '-new', + '-x509', + '-nodes', + '-days', + '1', + '-newkey', + 'rsa:2048', + '-keyout', + join(directory, 'key.pem'), + '-out', + join(directory, 'cert.pem'), + '-subj', + '/CN=localhost', + '-addext', + 'subjectAltName=IP:127.0.0.1,DNS:localhost', + ], + { stdio: 'ignore' } + ); + cert = readFileSync(join(directory, 'cert.pem'), 'utf8'); + server = createServer({ key: readFileSync(join(directory, 'key.pem')), cert }, socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.end(); + }); + server.on('tlsClientError', () => undefined); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + }); + afterAll(async () => { + for (const socket of sockets) socket.destroy(); + if (server?.listening) await new Promise(resolve => server.close(() => resolve())); + if (directory) { + unlinkSync(join(directory, 'key.pem')); + unlinkSync(join(directory, 'cert.pem')); + rmdirSync(directory); + } + }); + + // Mimic pg: TLS wraps an existing socket, with no SNI when DATABASE_HOST is an IP. + function handshake(ca: string | undefined, host: string): Promise { + return new Promise((resolve, reject) => { + const address = server.address(); + if (!address || typeof address === 'string') return reject(new Error('Missing server')); + const socket = connectSocket(address.port, '127.0.0.1'); + const options = databaseTlsOptions(true, ca, host); + if (!options) return reject(new Error('TLS must be enabled')); + const client = connect({ socket, ...options }, () => { + client.end(); + resolve(client.authorized); + }); + client.once('error', error => { + client.destroy(); + socket.destroy(); + reject(error); + }); + }); + } + it('accepts a trusted certificate with the configured IP SAN', async () => { + await expect(handshake(cert, '127.0.0.1')).resolves.toBe(true); + }); + it('rejects an untrusted certificate', async () => { + await expect(handshake(undefined, '127.0.0.1')).rejects.toThrow(); + }); + it('rejects a trusted certificate for the wrong database IP', async () => { + await expect(handshake(cert, '127.0.0.2')).rejects.toMatchObject({ + code: 'ERR_TLS_CERT_ALTNAME_INVALID', + }); + }); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/database-tls.ts b/apps/backend/src/infrastructure/persistence/typeorm/database-tls.ts new file mode 100644 index 0000000..f90d401 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/database-tls.ts @@ -0,0 +1,20 @@ +/** Shared by the API, migration CLI and container startup clients. */ +export function databaseTlsOptions( + enabled: boolean | string | undefined, + certificateAuthority?: string, + host = 'localhost' +): false | { rejectUnauthorized: true; host: string; ca?: string } { + const flag = typeof enabled === 'string' ? enabled.toLowerCase() : enabled; + if (flag === undefined || flag === false || flag === 'false') return false; + if (flag !== true && flag !== 'true') { + throw new Error('DATABASE_SSL must be true or false'); + } + if (certificateAuthority !== undefined && !certificateAuthority.trim()) { + throw new Error('DATABASE_SSL_CA must contain a PEM certificate when supplied'); + } + return { + rejectUnauthorized: true, + host, + ...(certificateAuthority ? { ca: certificateAuthority } : {}), + }; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts index af8c8b5..c5ae8fa 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts @@ -46,14 +46,14 @@ export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort { } async findByToken(token: string): Promise { - this.logger.log(`Finding CSV booking by token: ${token}`); + this.logger.log('Finding CSV booking by token'); const ormEntity = await this.repository.findOne({ where: { confirmationToken: token }, }); if (!ormEntity) { - this.logger.log(`CSV booking not found for token: ${token}`); + this.logger.log('CSV booking not found for token'); return null; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts index 4830fb8..4fe7481 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts @@ -150,11 +150,14 @@ export class TypeOrmNotificationRepository implements NotificationRepository { return ormEntities.map(e => this.toDomain(e)); } - async markAsRead(id: string): Promise { - await this.ormRepository.update(id, { - read: true, - read_at: new Date(), - }); + async markAsRead(id: string, userId: string): Promise { + await this.ormRepository.update( + { id, user_id: userId }, + { + read: true, + read_at: new Date(), + } + ); } async markAllAsReadForUser(userId: string): Promise { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.spec.ts new file mode 100644 index 0000000..23468d9 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.spec.ts @@ -0,0 +1,30 @@ +import { Logger } from '@nestjs/common'; +import { SafeDatabaseLogger } from './safe-database-logger'; + +describe('Database credential logging boundary', () => { + afterEach(() => jest.restoreAllMocks()); + it('preserves events without SQL values, bind parameters or driver errors', () => { + const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const errors = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const warns = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const logger = new SafeDatabaseLogger('all'); + const secret = 'synthetic-carrier-token'; + logger.logQuery(`SELECT '${secret}'`, [secret]); + logger.logQueryError(secret, `SELECT '${secret}'`, [secret]); + logger.logQuerySlow(2000, `SELECT '${secret}'`, [secret]); + logger.logMigration(secret); + logger.logSchemaBuild(secret); + logger.log('warn', secret); + expect(logs).toHaveBeenCalled(); + expect(errors).toHaveBeenCalled(); + expect(warns).toHaveBeenCalled(); + expect(JSON.stringify([logs.mock.calls, errors.mock.calls, warns.mock.calls])).not.toContain( + secret + ); + }); + it('respects disabled query logging', () => { + const logs = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + new SafeDatabaseLogger(false).logQuery('SELECT 1'); + expect(logs).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.ts b/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.ts new file mode 100644 index 0000000..d9f4c78 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/safe-database-logger.ts @@ -0,0 +1,20 @@ +import { Logger } from '@nestjs/common'; +import { AbstractLogger, LogLevel, LogMessage } from 'typeorm'; + +/** Keep configured database events without credentials in SQL, parameters or driver errors. */ +export class SafeDatabaseLogger extends AbstractLogger { + private readonly logger = new Logger('Database'); + + protected writeLog(level: LogLevel, messages: LogMessage | LogMessage[]): void { + const first = Array.isArray(messages) ? messages[0] : messages; + const category = ['query', 'query-error', 'query-slow', 'schema-build', 'migration'].includes( + first?.type ?? '' + ) + ? first.type + : level; + const message = `Database event: ${category}`; + if (category === 'query-error' || level === 'error') this.logger.error(message); + else if (level === 'warn') this.logger.warn(message); + else this.logger.log(message); + } +} diff --git a/apps/frontend/src/__tests__/utils/export.test.ts b/apps/frontend/src/__tests__/utils/export.test.ts index 6937ec9..1b6df27 100644 --- a/apps/frontend/src/__tests__/utils/export.test.ts +++ b/apps/frontend/src/__tests__/utils/export.test.ts @@ -94,6 +94,14 @@ const makeBooking = (overrides: Partial = {}): Booking => ({ // ── Tests ───────────────────────────────────────────────────────────────────── describe('exportToCSV', () => { + it('neutralizes formulas in formatted values and header labels', () => { + exportToCSV( + [makeBooking()], + [{ key: 'bookingNumber', label: '=1+1', formatter: () => '\t=2+2' }] + ); + expect(capturedBlobParts.join('')).toContain('"\'=1+1"'); + expect(capturedBlobParts.join('')).toContain('"\'\t=2+2"'); + }); it('calls saveAs once', () => { exportToCSV([makeBooking()]); expect(mockSaveAs).toHaveBeenCalledTimes(1); diff --git a/apps/frontend/src/components/ExportButton.tsx b/apps/frontend/src/components/ExportButton.tsx index 43d184d..73a61d8 100644 --- a/apps/frontend/src/components/ExportButton.tsx +++ b/apps/frontend/src/components/ExportButton.tsx @@ -7,6 +7,7 @@ 'use client'; import { useState, useRef, useEffect } from 'react'; +import { csvCell } from '@/utils/csv-cell'; import { useTranslations, useLocale } from 'next-intl'; import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react'; import { useSubscription } from '@/lib/context/subscription-context'; @@ -63,14 +64,14 @@ export default function ExportButton>({ }; const generateCSV = (): string => { - const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';'); + const headers = columns.map(col => csvCell(col.label)).join(';'); const rows = data.map(row => { return columns .map(col => { const value = getNestedValue(row, col.key as string); const formattedValue = col.format ? col.format(value, row) : formatValue(value); - return `"${formattedValue.replace(/"/g, '""')}"`; + return csvCell(formattedValue); }) .join(';'); }); diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index 473e162..757863f 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -74,7 +74,6 @@ export interface CsvBookingResponse { url: string; }>; notes?: string; - confirmationToken: string; emailSentAt?: string; acceptedAt?: string; rejectedAt?: string; diff --git a/apps/frontend/src/lib/context/auth-context.test.tsx b/apps/frontend/src/lib/context/auth-context.test.tsx new file mode 100644 index 0000000..c65a8cb --- /dev/null +++ b/apps/frontend/src/lib/context/auth-context.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { AuthProvider, useAuth } from './auth-context'; +import { getCurrentUser, login } from '../api/auth'; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })); +jest.mock('../api/auth', () => ({ + login: jest.fn(), + getCurrentUser: jest.fn(), + register: jest.fn(), + logout: jest.fn(), +})); +jest.mock('../api/client', () => ({ hasSession: () => false, clearAuthTokens: jest.fn() })); + +describe('authenticated navigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(getCurrentUser) + .mockResolvedValue({ id: 'test-user' } as Awaited>); + }); + + it.each(['javascript:alert(1)', '//example.org', '/\\example.org', '/\n/example.org'])( + 'does not navigate to attacker destination %j', + async destination => { + const { result } = renderHook(useAuth, { wrapper: AuthProvider }); + await act(async () => { + await result.current.login('user@example.org', 'password', destination); + }); + expect(login).toHaveBeenCalledWith({ + email: 'user@example.org', + password: 'password', + rememberMe: false, + }); + expect(mockPush).toHaveBeenCalledWith('/dashboard'); + expect(result.current.isAuthenticated).toBe(true); + } + ); + + it('preserves localized navigation after successful login', async () => { + const { result } = renderHook(useAuth, { wrapper: AuthProvider }); + await act(async () => { + await result.current.login( + 'user@example.org', + 'password', + '/fr/dashboard?tab=1#bookings', + true + ); + }); + expect(mockPush).toHaveBeenCalledWith('/fr/dashboard?tab=1#bookings'); + }); +}); diff --git a/apps/frontend/src/lib/context/auth-context.tsx b/apps/frontend/src/lib/context/auth-context.tsx index 644c6a1..7cfacf5 100644 --- a/apps/frontend/src/lib/context/auth-context.tsx +++ b/apps/frontend/src/lib/context/auth-context.tsx @@ -16,6 +16,7 @@ import { } from '../api/auth'; import { hasSession, clearAuthTokens } from '../api/client'; import type { UserPayload } from '@/types/api'; +import { safeLoginRedirect } from '../safe-login-redirect'; interface AuthContextType { user: UserPayload | null; @@ -107,7 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Fetch complete user profile after login (session lives in httpOnly cookies) const currentUser = await getCurrentUser(); setUser(currentUser); - router.push(redirectTo); + router.push(safeLoginRedirect(redirectTo)); } catch (error) { throw error; } diff --git a/apps/frontend/src/lib/safe-login-redirect.test.ts b/apps/frontend/src/lib/safe-login-redirect.test.ts new file mode 100644 index 0000000..4ff5d80 --- /dev/null +++ b/apps/frontend/src/lib/safe-login-redirect.test.ts @@ -0,0 +1,23 @@ +import { safeLoginRedirect } from './safe-login-redirect'; + +describe('safeLoginRedirect', () => { + it.each([ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,test', + 'https://example.org', + '//example.org', + '/\\example.org', + '/\n/example.org', + '/\t/example.org', + ' /dashboard', + '', + ])('rejects unsafe navigation %j', destination => { + expect(safeLoginRedirect(destination)).toBe('/dashboard'); + }); + + it.each(['/dashboard', '/fr/dashboard?tab=bookings#recent', '/booking/123', '/search?q=a%20b'])( + 'preserves internal navigation %s', + destination => expect(safeLoginRedirect(destination)).toBe(destination) + ); +}); diff --git a/apps/frontend/src/lib/safe-login-redirect.ts b/apps/frontend/src/lib/safe-login-redirect.ts new file mode 100644 index 0000000..040c5b1 --- /dev/null +++ b/apps/frontend/src/lib/safe-login-redirect.ts @@ -0,0 +1,12 @@ +/** Only application paths may be used after authentication. */ +export function safeLoginRedirect(destination: string): string { + if ( + !destination.startsWith('/') || + destination.startsWith('//') || + /[\\\u0000-\u0020\u007f]/.test(destination) + ) { + return '/dashboard'; + } + + return destination; +} diff --git a/apps/frontend/src/utils/csv-cell.test.ts b/apps/frontend/src/utils/csv-cell.test.ts new file mode 100644 index 0000000..8497574 --- /dev/null +++ b/apps/frontend/src/utils/csv-cell.test.ts @@ -0,0 +1,15 @@ +import { csvCell } from './csv-cell'; + +describe('CSV spreadsheet safety', () => { + it.each(['=1+1', '+SUM(1)', '-1+1', '@SUM(1)', ' =1', '\t=1', '\r=1', '\n=1'])( + 'forces formula-like values to text: %j', + value => { + expect(csvCell(value)).toBe(`"'${value}"`); + } + ); + it('preserves ordinary text, numeric zero and CSV escaping', () => { + expect(csvCell('Paris; "France"')).toBe('"Paris; ""France"""'); + expect(csvCell(0)).toBe('"0"'); + expect(csvCell(null)).toBe('""'); + }); +}); diff --git a/apps/frontend/src/utils/csv-cell.ts b/apps/frontend/src/utils/csv-cell.ts new file mode 100644 index 0000000..bfdcaa8 --- /dev/null +++ b/apps/frontend/src/utils/csv-cell.ts @@ -0,0 +1,7 @@ +/** Quote a CSV cell and force spreadsheet formula prefixes to be treated as text. */ +export function csvCell(value: unknown): string { + const text = String(value ?? ''); + const safe = + /^[\s\u0000-\u001f]*[=+\-@]/.test(text) || /^[\t\r\n]/.test(text) ? `'${text}` : text; + return `"${safe.replace(/"/g, '""')}"`; +} diff --git a/apps/frontend/src/utils/export.ts b/apps/frontend/src/utils/export.ts index d2669f9..2327fc9 100644 --- a/apps/frontend/src/utils/export.ts +++ b/apps/frontend/src/utils/export.ts @@ -1,3 +1,4 @@ +import { csvCell } from './csv-cell'; /** * Client-side export utilities */ @@ -58,7 +59,7 @@ export function exportToCSV( filename: string = 'bookings-export.csv' ): void { // Create CSV header - const header = fields.map(f => f.label).join(','); + const header = fields.map(f => csvCell(f.label)).join(','); // Create CSV rows const rows = data.map(booking => { @@ -67,8 +68,7 @@ export function exportToCSV( const value = getNestedValue(booking, field.key); const formatted = field.formatter ? field.formatter(value) : value; // Escape quotes and wrap in quotes if contains comma - const escaped = String(formatted || '').replace(/"/g, '""'); - return `"${escaped}"`; + return csvCell(formatted); }) .join(','); }); diff --git a/audit_security/CORRECTIONS-2026-09-17.md b/audit_security/CORRECTIONS-2026-09-17.md new file mode 100644 index 0000000..be8f447 --- /dev/null +++ b/audit_security/CORRECTIONS-2026-09-17.md @@ -0,0 +1,49 @@ +# Corrections des constats connus — 17 septembre 2026 + +Branche `check_secu`. Les correctifs et modifications de travail antérieurs sont conservés. Aucun commit, déploiement, paiement, accès à la production ou envoi d'email réel n'a été réalisé. + +## SEC-07 — Secrets dans les journaux + +Les derniers messages interpolant les tokens transporteur sont supprimés du service et du repository. La vérification d'invitation ne journalise plus le token ; l'exception « réservation introuvable » ne le recopie plus. Les erreurs de livraison SMTP et d'invitation sont remplacées par des erreurs génériques avant leur propagation, pour éviter qu'un appelant journalise un lien ou un mot de passe contenu dans une réponse fournisseur. + +Les journaux HTTP utilisent des sérialiseurs à liste de champs autorisés : méthode et modèle de route côté serveur, statut de réponse et erreur générique. Ils n'incluent plus URL réelle, query string, paramètres, headers, cookies, corps ni détails d'erreur du pilote. Le filtre global et l'intercepteur de performance utilisent la même route statique. Pour une route non résolue, le journal indique `[unmatched route]`. Les erreurs HTTP délibérées conservent leur statut et leur message fonctionnel. + +La relecture indépendante a identifié un chemin supplémentaire lorsque `DATABASE_LOGGING` est activé : TypeORM affiche ses paramètres SQL directement. Un logger TypeORM dédié enregistre désormais les catégories d'événements sans SQL, valeurs, paramètres ni erreurs brutes. Il est utilisé par l'API et la source de données CLI. Les niveaux de journalisation configurés restent respectés. Ce choix réduit volontairement le détail diagnostic pour empêcher la copie de credentials ; les codes HTTP, événements et références d'erreur restent disponibles. + +Tests : sortie réelle Pino contenant des secrets synthétiques en URL, cookies, réponse et erreur ; chemins contrôleur/service/repository ; filtre d'erreur ; logger TypeORM avec logging activé et désactivé. Aucune copie de journaux de production n'est lue. Les anciens secrets déjà présents dans des journaux restent à révoquer lorsque nécessaire ; les copies historiques ne sont pas effacées par le correctif. + +## OBS-01 — Erreur de tarif transformée en gratuité + +Le comportement est confirmé au niveau du service avec des dépendances simulées. Avant correction, six cas de refus échouaient et cinq tarifs légitimes étaient conservés. Le service ne doit pas interpréter l'impossibilité de déterminer un tarif comme une offre gratuite. + +Une erreur de lecture ou un montant invalide produit désormais une erreur HTTP 503 générique. La résolution a lieu avant tout upload en création et avant `booking.accept()` en acceptation. Aucun document ni changement de statut n'est enregistré lorsque le tarif est inconnu. Les valeurs positives et les tarifs explicites zéro/-1 sur mesure conservent leur comportement ; les autres montants négatifs et les valeurs non finies sont refusés. + +Treize tests couvrent les échecs avant effets de bord, les montants valides et invalides, ainsi que la création payante en PENDING_PAYMENT et la création sur mesure en PENDING avec notification simulée. Cela démontre le comportement de panne et sa correction ; la capacité d'un attaquant à provoquer cette panne en production n'est pas établie. + +## OBS-02 — Destination des webhooks + +Aucun correctif de fonctionnement spéculatif n'est appliqué. Deux tests utilisant le véritable `I18nValidationPipe` et les types DTO déclarés par les contrôleurs confirment que création et modification rejettent actuellement une URL non validée avec HTTP 400. Cela ne prouve pas l'absence d'anciens webhooks dangereux dans la base ni d'autres voies d'écriture. Le risque reste documenté avant une future réparation des DTO. + +## SEC-19 — Clé d'API littérale en préproduction + +Une clé OpenAI était présente dans la modification locale de `docker/stack-portainer-preprod.yaml`. Seule sa valeur a été remplacée par une variable obligatoire `${OPENAI_API_KEY:?OPENAI_API_KEY must be supplied at deployment}` ; les autres modifications de ce fichier sont conservées. Le parsing YAML et l'absence de valeur littérale à cet emplacement sont vérifiés sans afficher la clé et sans charger de fichier `.env`. + +**Action externe indispensable : révoquer/remplacer la clé chez le fournisseur et renseigner le secret de déploiement.** Aucune validité ni consommation du compte fournisseur n'est vérifiée. Une clé retirée du fichier peut encore exister dans une copie, une trace ou l'historique de l'outil. + +## Autres constats et limites + +Les correctifs SEC-01 à SEC-06 et SEC-08 à SEC-18 étaient déjà présents dans Git ou dans les modifications locales, comme indiqué dans le README. Les tests backend correspondants restent inclus dans la suite générale. Aucune nouvelle exécution navigateur/tableur ni revalidation frontend complète n'est revendiquée pour cette passe backend. + +Restent hors des corrections locales : rotation SMTP (SEC-10), révocation des anciens liens transporteur/invitation (SEC-05/07), déploiement réel des correctifs et distribution de la CA PostgreSQL (SEC-16). Les scripts secondaires non couverts et l'audit externe des dépendances restent des lacunes de couverture, pas des vulnérabilités déclarées corrigées. + +## Vérifications finales et résultats + +- `npm test -- --runInBand` depuis apps/backend : **512 tests réussis, 5 ignorés ; 40 suites réussies, 1 ignorée**. +- `npm run build` depuis apps/backend : réussi. +- ESLint ciblé sur les fichiers backend modifiés/ajoutés : réussi. +- Parsing YAML local et contrôle de variable obligatoire OpenAI : réussis, sans interpolation des secrets ni contact fournisseur. +- `git diff --check -- apps/backend` et vérification des liens Markdown : réussis. Le fichier YAML conserve des espaces finaux préexistants à cette passe dans les autres changements utilisateur ; aucune correction globale de ce fichier n'a été effectuée. + +Résultats : **fixed** pour les chemins locaux SEC-07, le comportement de panne OBS-01 et le retrait du littéral SEC-19 ; **no_change** pour la voie API de configuration OBS-02, dont le refus est confirmé. Le volet opérationnel de SEC-05/07/10/16/19 reste **blocked** faute d'accès/preuve externe : aucune révocation ni configuration de production n'est revendiquée. + +Les tests d'erreur de frais échouaient avant la correction ; ils rejettent maintenant l'opération avant mutation/upload. Les contrôles de création payante et sur mesure passent. Les sorties Pino et TypeORM testées ne contiennent plus les secrets synthétiques alors que les événements, refus et usages légitimes restent observables. Les tests de niveau métier ne remplacent pas une recette de préproduction. diff --git a/audit_security/COUVERTURE.md b/audit_security/COUVERTURE.md new file mode 100644 index 0000000..f97a6e1 --- /dev/null +++ b/audit_security/COUVERTURE.md @@ -0,0 +1,43 @@ +# Couverture réelle et angles morts + +L'audit initial indique **95 fichiers suivis lus intégralement**, plus des lectures ciblées. Ce nombre ne représente pas la couverture de tout le monorepo. La passe documentaire ajoute des relectures ciblées ; elle n'a pas recalculé un pourcentage global et ne revendique pas de nouveau scan exhaustif réussi. + +## Surfaces examinées + +| Surface | Constats documentés | Ce qui reste hors de la preuve | +| --- | --- | --- | +| Connexion, récupération et sessions | SEC-01, SEC-03, SEC-08 | Reproduction navigateur complète, toutes les variantes OAuth et toutes les politiques de session du déploiement. | +| Organisations et utilisateurs | SEC-02, SEC-06, SEC-12, SEC-13 | Revue systématique de chaque route et de chaque matrice acteur/cible. | +| Notifications REST/WebSocket | SEC-03, SEC-04 | Mise à jour réelle PostgreSQL des critères historiques, tests à forte concurrence et déploiement multi-instance. | +| Réservations CSV et transporteurs | SEC-05, SEC-06, SEC-09, SEC-12, OBS-01 | Toutes les transitions, effets secondaires, reprise après erreur et gestion réelle des fichiers. | +| Stripe, licences et offres | SEC-11, SEC-17, SEC-18 | Webhooks réels, ordre et répétition d'événements, conditions de course en base réelle, cohérence de données déjà enregistrées. | +| Logs et credentials | SEC-07, SEC-10 | Contenu réel des journaux, ACL, rétention, copies externes et révocations fournisseur. | +| TLS SMTP/PostgreSQL | SEC-15, SEC-16 | Certificats et topologies en ligne, scripts de maintenance secondaires, validation effective de chaque secret de déploiement. | +| Exports CSV | SEC-14 | Tests d'ouverture dans les différents tableurs et configurations des utilisateurs. | +| MCP et assistant | SEC-18 pour droits déclarés/actuels | Pas d'outil premium identifié dans le catalogue courant ; toutes les nouvelles capacités et leurs évolutions nécessitent leur propre vérification. | +| Webhooks sortants | OBS-02 | Contrôle d'URL réellement accessible depuis une voie d'écriture, anciennes lignes en base et politique DNS/redirections. | + +## Zones à examiner en priorité après ce registre + +1. **Logs transporteur actuels** : compléter SEC-07 par le cycle de vie des tokens, l'accès aux journaux et la fenêtre précédant la décision ; le code d'interpolation est confirmé. +2. **Erreurs de facturation** : valider OBS-01 sur un scénario local où seule la récupération des frais échoue ; distinguer une réservation persistée sans paiement d'une panne empêchant toute sauvegarde. +3. **Documents et stockage** : comparer les buckets provisionnés, ceux utilisés par les adaptateurs, les ACL et les URLs de téléchargement. La présence de noms différents n'est pas une preuve que des documents sont publics. Aucun bucket de production n'a été interrogé. +4. **Scripts et migrations** : recenser les clients PostgreSQL hors démarrage principal, puis leurs politiques TLS ; les migrations appliquées ne doivent pas être modifiées pour documenter un défaut. +5. **Adaptateurs transporteurs, pages et composants restants** : poursuivre la lecture intégrale, tracer les données externes jusqu'aux rendus et requêtes sortantes. Leur présence dans le monorepo ne signifie pas qu'ils ont tous été audités. +6. **Dépendances** : audit des versions réellement verrouillées, avec séparation runtime/développement et examen de la portée de chaque avis. Aucun nombre de vulnérabilités npm n'est connu à cette date. +7. **Infrastructure réelle** : plafonds HTTP, accès réseau à la base et aux logs, CA distribuée, comptes de service et état des rotations. La configuration du dépôt ne suffit pas pour conclure sur le site en ligne. + +## Faux positifs et confusions à éviter + +- Les fichiers et descriptions historiques mentionnent localStorage, mais le contexte de connexion actif étudié utilise des cookies HttpOnly. Ne pas faire de l'ancien mécanisme un constat courant sans tracer son utilisation. +- Les requêtes paramétrées examinées dans recherche, GDPR et conversations n'ont pas permis d'établir une injection SQL. Ce constat limité ne couvre pas chaque requête du dépôt. +- L'export GDPR examiné exclut les hashes de mot de passe, TOTP et clés ; cela ne prouve pas à lui seul une conformité juridique globale. +- Les contrôles de statut, de mot de passe et d'appartenance documentaire limitent la portée d'un token transporteur. Une fuite de token de décision n'est pas une preuve de téléchargement sans mot de passe. +- Les routes webhook et l'appel HTTP sortant ne suffisent pas à établir un SSRF : la voie d'enregistrement est un élément manquant, détaillé dans OBS-02. +- Le statut historique « no_issue_found » d'une surface signifie seulement qu'aucune faille n'avait été retenue dans les chemins examinés. Il ne vaut pas garantie, notamment après les constats ultérieurs sur les droits d'abonnement. + +## Dépendances et services externes + +La précédente tentative `npm audit` a échoué sur l'accès au registre ; la revue automatique a ensuite refusé l'envoi externe des noms et versions sans autorisation explicite. Cette autorisation reste non reçue. Aucun contournement ni nouvel envoi n'est effectué pour ce dossier. + +Aucun test du site public, paiement, envoi d'email, connexion à la base réelle ou lecture des fichiers `.env` n'est inclus. Les constats locaux ne permettent pas d'attester que le site en ligne expose aujourd'hui chacun des comportements historiques. diff --git a/audit_security/JOURNAL.md b/audit_security/JOURNAL.md new file mode 100644 index 0000000..fb411d9 --- /dev/null +++ b/audit_security/JOURNAL.md @@ -0,0 +1,26 @@ +# Journal de poursuite de l'audit + +## 15 septembre 2026 — Échec de la tentative approfondie lancée le 14 septembre + +Objectif demandé : examiner intégralement le projet et poursuivre la documentation, sans nouveaux correctifs applicatifs. Périmètre demandé : tout le dépôt, avec exclusion explicite des fichiers `.env` et `.env.*`, analyses hors ligne et aucune opération de production. + +Le résultat terminal du coordinateur contient exactement : + +> Deep Scan stopped after 3 consecutive unsuccessful discovery workers (limit: 3); last failure (transient_error): You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 15th, 2026 12:20 AM. +> This is a terminal failure of this logical Deep Scan; no successful discovery manifest was returned. + +L'heure mentionnée est celle rapportée par le service ; aucune vérification d'un rétablissement actuel du quota n'est effectuée. Ce message ne signifie pas qu'une analyse réussie a eu lieu jusqu'à cette heure. + +### Résultats et couverture + +- **Constats déjà conservés :** 18 fiches SEC et 2 observations OBS dans le README, issus des passes antérieures. +- **Nouveaux constats validés de cette tentative :** aucun résultat exploitable retourné ; cela ne signifie pas absence de vulnérabilité. +- **Candidats éventuellement sauvegardés par le coordinateur :** inconnus. La réponse d'échec ne fournit aucun identifiant de scan, alors que la lecture de son contexte en exige un. Aucun identifiant d'un ancien audit n'est substitué. +- **Couverture supplémentaire mesurée :** indisponible. Le nombre de fichiers entièrement examinés n'est pas augmenté. +- **Consommation de tokens de cette tentative :** mesure indisponible, ni zéro ni estimation. + +### Conséquence + +Le coordinateur et le workflow Deep Security Scan imposent de ne pas relancer cette tentative terminale, de ne pas démarrer de remplacement dans cette réponse et de ne pas finaliser un scan sans manifeste réussi. Aucun scan n'est donc déclaré complet. Seul cet état administratif est ajouté au dossier ; aucune nouvelle fiche de vulnérabilité ni correction applicative n'est produite. + +La prochaine poursuite doit tenir compte de ce blocage et conserver les limites de [COUVERTURE.md](COUVERTURE.md). L'analyse externe des dépendances demeure par ailleurs soumise à l'autorisation déjà en attente ; cet échec de quota ne change pas cette restriction. diff --git a/audit_security/METHODOLOGIE.md b/audit_security/METHODOLOGIE.md new file mode 100644 index 0000000..6ac83e1 --- /dev/null +++ b/audit_security/METHODOLOGIE.md @@ -0,0 +1,37 @@ +# Méthode, versions et lecture des preuves + +Ce dossier est un registre documentaire de sécurité Xpeditis établi le **14 septembre 2026**. Il rassemble les constats connus, leur état actuel et les questions ouvertes. Il n'affirme pas recenser toutes les vulnérabilités possibles du dépôt. + +## Références de code + +| Référence | Signification | +| --- | --- | +| `8446f879b676b303fdb2891388f88ff7e43f5fea` | Snapshot préaudit, issu de `preparation_prod`, utilisé pour relire les 14 constats initiaux et les deux constats TLS. | +| `c09b8be9ae1d399e2c374750bfb161e7a4ad0015` | HEAD de `check_secu` pendant la rédaction. Contient les correctifs des premières passes. | +| État local du 14 septembre | Contient en plus les correctifs de rattachement Stripe et de droits des abonnements, leurs tests et les ajustements de quota. Ces changements applicatifs étaient déjà présents avant la demande de documentation et restent non commités. | + +Aucun tag n'est présent dans l'inventaire Git local. Les numéros de package ne permettent pas de connaître la release déployée. L'introduction exacte de chaque problème, les branches publiées affectées et d'éventuels backports ne sont pas établis ; la version préaudit est la plus ancienne vérifiée ici, pas nécessairement la première vulnérable. La comparaison entre les deux snapshots établit l'état avant/après des chemins cités, sans inventer une chronologie de releases. + +## Nature des preuves + +- **Code vérifié** : chemin décisif relu dans le snapshot vulnérable et comparaison avec le fichier courant. Les extraits historiques portent leur révision ; leurs lignes ne doivent pas être recherchées telles quelles dans le fichier corrigé. +- **Tests locaux observés lors des passes précédentes** : refus, maintien des usages autorisés, simulations de services et certaines connexions HTTP/TLS sur loopback. Ils n'attestent pas qu'un incident a existé. +- **Analyse statique sans exploitation exécutée** : mécanisme établi à partir des appels et contrôles, sans observer le résultat sur un produit complet ou la production. +- **Observation à valider** : comportement problématique possible, mais prérequis attaquant ou parcours complet non démontré. Les fiches OBS ne sont pas additionnées aux failles confirmées. +- **Inconnu en production** : version déployée, rotation des credentials, copies de logs, ACL de stockage, limites proxy ou règles réseau non vérifiées. + +Les références d'origine comportaient quelques lignes devenues inexactes ou trop larges. Les fiches privilégient les fonctions et les extraits effectivement relus ; elles ne reprennent pas automatiquement tous les numéros du fichier findings.json. Le comportement de dépendances inspecté dans l'audit initial est distingué d'une nouvelle reproduction dynamique. + +## Gravité et statut + +Une gravité qualifie le mécanisme vulnérable sous ses prérequis ; elle ne prouve pas qu'il reste accessible dans la version courante. Les gravités élevées/moyennes/faibles des 14 constats initiaux sont conservées avec leurs limites. Les constats TLS et Stripe supplémentaires sont qualifiés sans attribuer de CVSS numérique ni de CVE. + +« Corrigé dans le code » ne signifie pas « déployé », « exploité », « tous les anciens secrets révoqués » ou « testé contre chaque infrastructure ». SEC-07 reste partiellement corrigé en raison des tokens transporteur encore journalisés. SEC-05 et SEC-10 nécessitent une vérification opérationnelle des credentials antérieurement exposés. + +## Limites de cette passe documentaire + +La demande actuelle autorise la documentation détaillée, pas de nouveaux correctifs applicatifs. Aucun code applicatif n'est modifié pour ces fiches, aucun déploiement ni attaque de production n'est lancé. Les fichiers `.env` et `.env.*` ne sont pas lus. Aucun secret ni contenu de journal réel n'est recopié. + +La rédaction déléguée a échoué sur une limite d'usage. Les fiches constituent donc une compilation relue directement, sans nouvelle revue indépendante par fiche. Les revues indépendantes des correctifs antérieurs restent celles décrites dans les comptes rendus existants ; elles ne doivent pas être présentées comme une validation indépendante de ce dossier. + +Sources historiques : [rapport initial](../docs/security/check-secu/report.md), [constats structurés](../docs/security/check-secu/findings.json), [corrections initiales](../docs/security/check-secu/REMEDIATION.md), [10 septembre](../docs/security/check-secu/REMEDIATION-2026-09-10.md), [14 septembre](../docs/security/check-secu/REMEDIATION-2026-09-14.md). Les mentions « non commité » des anciens comptes rendus décrivent leur date et ne priment pas sur le tableau de versions ci-dessus. diff --git a/audit_security/README.md b/audit_security/README.md new file mode 100644 index 0000000..d3f765a --- /dev/null +++ b/audit_security/README.md @@ -0,0 +1,71 @@ +# Audit de sécurité — Xpeditis + +Ce dossier rassemble **19 constats de sécurité connus**, chacun expliqué dans une fiche, et **2 observations à valider**. Il décrit l'état du code, mis à jour le **17 septembre 2026**, sur la branche `check_secu`. + +**Ce n'est pas une liste exhaustive de toutes les failles possibles ni une attestation de sécurité de la production.** Une grande partie des constats a déjà été corrigée dans le code ; les fiches expliquent le problème historique, la preuve, les limites et le risque restant. Après la passe documentaire, les corrections restantes ont été demandées et traitées le 17 septembre ; voir le [compte rendu](CORRECTIONS-2026-09-17.md). + +## État de la poursuite — 15 septembre 2026 + +La nouvelle tentative d'audit approfondi de tout le dépôt a échoué sur une limite d'usage après trois workers de découverte infructueux. Aucun manifeste réussi ni identifiant de scan n'a été retourné ; les éventuels résultats intermédiaires ne sont donc pas consultables depuis cette réponse. **La couverture reste partielle, sans nouveau constat validé à ajouter.** Les 18 constats et 2 observations ci-dessous restent ceux documentés précédemment. Voir le [journal de poursuite](JOURNAL.md) pour l'erreur exacte et les limites. + +## Corrections — 17 septembre 2026 + +SEC-07 est corrigé dans le code local, y compris les logs HTTP/SMTP/TypeORM. OBS-01 refuse désormais un tarif inconnu avant effets de bord ; ses cas de panne et ses contrôles légitimes sont testés. OBS-02 reste une observation : les tests du pipe confirment le refus actuel de création/modification. SEC-19 documente une clé d'API retirée de la configuration locale, dont la révocation externe reste nécessaire. Aucun déploiement n'est effectué. + +## À regarder d'abord + +- **SEC-07 : correction locale appliquée.** Les credentials ne doivent plus être recopiés par les chemins de journalisation corrigés ; les secrets déjà présents dans les anciens logs restent à traiter. +- **Actions opérationnelles non vérifiées : SEC-05, SEC-10 et SEC-19.** La suppression d'un token dans une réponse ou d'un secret SMTP dans le dernier fichier ne révoque pas les copies déjà distribuées. +- **Version et configuration en ligne inconnues : SEC-15/16/17/18.** Les correctifs TLS principaux sont suivis dans Git ; les correctifs Stripe et droits d'abonnement sont locaux au moment de cette rédaction. Aucun déploiement n'est confirmé. +- **OBS-01 corrigé ; OBS-02 à surveiller.** La gestion du tarif inconnu est corrigée et testée. Une éventuelle voie d’écriture de destination webhook reste à établir. + +## Index des constats + +La gravité décrit le comportement vulnérable avant correction. Elle ne doit pas être lue comme le risque résiduel du site en ligne. Les IDs sont stables dans ce dossier ; les chemins ouvrent les fiches détaillées. + +| ID | Problème | Gravité | État actuel | +| --- | --- | --- | --- | +| SEC-01 | [La redirection de connexion permet une XSS DOM](xss-redirection-connexion/xss-redirection-connexion.md) | Élevée | Corrigé dans Git ; déploiement inconnu | +| SEC-02 | [Un manager peut modifier une autre organisation](modification-inter-organisations/modification-inter-organisations.md) | Élevée | Corrigé dans Git ; déploiement inconnu | +| SEC-03 | [Les WebSockets acceptent des sessions révoquées ou désactivées](sessions-websocket/sessions-websocket.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-04 | [Un membre peut marquer toutes les notifications comme lues](notifications-propriete-et-criteres/notifications-propriete-et-criteres.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-05 | [Le client reçoit le jeton de réponse du transporteur](jeton-transporteur-dans-reponses/jeton-transporteur-dans-reponses.md) | Moyenne | Réponses corrigées ; anciens tokens à traiter | +| SEC-06 | [VIEWER peut créer et modifier des réservations](mutations-role-viewer/mutations-role-viewer.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-07 | [Les logs contiennent mots de passe et invitations](secrets-dans-les-journaux/secrets-dans-les-journaux.md) | Moyenne | Correctif local testé ; anciennes copies à traiter | +| SEC-08 | [Le changement de mot de passe conserve les anciennes sessions](sessions-apres-reset-mot-de-passe/sessions-apres-reset-mot-de-passe.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-09 | [Les téléversements ne bornent pas la mémoire utilisée](televersements-memoire/televersements-memoire.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-10 | [Une clé SMTP figure dans un fichier suivi](secret-smtp-versionne/secret-smtp-versionne.md) | Moyenne | Littéral retiré ; rotation fournisseur non vérifiée | +| SEC-11 | [La résiliation peut conserver les avantages payants](resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | +| SEC-12 | [Les dossiers des collègues sont accessibles sans rôle de gestion](lecture-dossiers-collegues/lecture-dossiers-collegues.md) | Faible | Corrigé dans Git ; déploiement inconnu | +| SEC-13 | [Un manager peut rétrograder un administrateur de son organisation](manager-modifie-administrateur/manager-modifie-administrateur.md) | Faible | Corrigé dans Git ; déploiement inconnu | +| SEC-14 | [Les exports CSV conservent les formules injectées](injection-formules-csv/injection-formules-csv.md) | Faible | Corrigé dans Git ; déploiement inconnu | +| SEC-15 | [SMTP : identité du serveur non vérifiée et STARTTLS facultatif](smtp-tls-non-verifie/smtp-tls-non-verifie.md) | Moyenne | Code corrigé ; configuration effective à confirmer | +| SEC-16 | [PostgreSQL : TLS incohérent et certificat non authentifié](postgresql-tls-incoherent/postgresql-tls-incoherent.md) | Moyenne | Chemins principaux corrigés ; CA/scripts à vérifier | +| SEC-17 | [Stripe : session Checkout non liée à son organisation](stripe-session-organisation/stripe-session-organisation.md) | Moyenne | Correctif local non commité | +| SEC-18 | [Droits payants conservés sur un abonnement inactif](droits-abonnements-inactifs/droits-abonnements-inactifs.md) | Moyenne | Correctif local non commité | +| SEC-19 | [Clé d’API littérale en préproduction](cle-api-preproduction/cle-api-preproduction.md) | Moyenne, validité inconnue | Littéral retiré ; révocation externe nécessaire | + +## Suivi des observations initiales + +| ID | Analyse | Élément manquant | +| --- | --- | --- | +| OBS-01 | [Frais ramenés à zéro après erreur de lecture de l'abonnement](frais-erreur-abonnement/frais-erreur-abonnement.md) | Panne confirmée au service ; correctif local avec 13 tests. Contrôle de la panne par un attaquant non établi. | +| OBS-02 | [Destinations webhook et risque de requêtes internes](webhooks-destination-sortante/webhooks-destination-sortante.md) | Deux tests confirment le refus du pipe ; autres voies et données anciennes inconnues. | + +Ces observations ne sont pas ajoutées aux constats SEC. Une lacune de couverture, une dépendance non analysée ou une configuration de production inconnue n'est pas automatiquement une vulnérabilité. + +## Comment lire les fiches + +Chaque fiche SEC expose le scénario, les droits nécessaires, le chemin dans le code, les contrôles insuffisants, l'impact étroit, les preuves disponibles et l'état de correction. Les titres techniques suivent un format commun ; le contenu est en français. Les extraits historiques sont explicitement distingués des liens vers le code courant. + +- [METHODOLOGIE.md](METHODOLOGIE.md) : snapshots Git, statuts, gravité et nature des preuves. +- [COUVERTURE.md](COUVERTURE.md) : zones examinées, angles morts, hypothèses écartées et ordre de poursuite de l'analyse. +- [VALIDATION.md](VALIDATION.md) : tests déjà observés, limites et commandes locales reproductibles. + +La dernière suite backend du 17 septembre compte **512 tests réussis et 5 ignorés**, avec compilation et lint ciblé réussis. Ce résultat ne signifie pas que tout le monorepo a été audité. L'audit de dépendances reste non réalisé ; aucun résultat npm ni CVE n'est inventé. + +## Maintenance du registre + +Conserver un ID pour chaque cause, mettre à jour son statut avec une preuve datée et distinguer toujours correction locale, commit et déploiement. Lorsqu'un nouveau chemin révèle la même cause, compléter la fiche existante, comme pour les logs transporteur de SEC-07. Une hypothèse devient un constat seulement après vérification de son entrée contrôlable, des contrôles traversés et de son effet protégé. + +Ne jamais joindre de mot de passe, token réel, clé SMTP, fichier `.env` ou journal contenant un secret à ces fiches. Les documents décrivent les champs et opérations nécessaires à la vérification, pas leurs valeurs de production. diff --git a/audit_security/VALIDATION.md b/audit_security/VALIDATION.md new file mode 100644 index 0000000..86a7ce7 --- /dev/null +++ b/audit_security/VALIDATION.md @@ -0,0 +1,46 @@ +# Preuves disponibles et reproductibilité + +## Résultats déjà observés + +Ces résultats regroupent les passes antérieures et la reprise des corrections du 17 septembre. + +| Date | Vérification | Résultat et limite | +| --- | --- | --- | +| 9 septembre | Correctifs initiaux, tests ciblés backend/frontend | Le compte rendu initial détaille les contrôles par constat ; aucun test d'exploitation du site en ligne. | +| 10 septembre | Suite backend complète | 451 réussis, 5 ignorés ; TLS/HTTP sur serveurs locaux, services métier simulés. | +| 14 septembre | Suite backend complète après les correctifs Stripe et droits | **491 réussis, 5 ignorés ; 36 suites réussies, 1 ignorée.** | +| 14 septembre | Compilation backend et lint ciblé | Réussis sur l'état applicatif préalable à ce dossier. | +| 14 septembre | Stripe binding avant correction | Deux cas de refus échouaient parce que GOLD était enregistré ; après correction, les six cas ciblés passent. | +| 14 septembre | Matrice domaine/garde avant correction | 12 cas de refus échouaient, 7 contrôles légitimes passaient ; les 19 passent après correction. | +| 17 septembre | Suite backend complète après corrections des logs et frais | **512 réussis, 5 ignorés ; 40 suites réussies, 1 ignorée.** | +| 17 septembre | Compilation, lint ciblé, parsing YAML, liens documentaires | Réussis ; aucune connexion à la production. | + +Les cinq tests ignorés ne sont pas comptés comme réussis. Les chiffres de suites unitaires ne constituent pas une couverture de code mesurée ni une réussite des tests d'intégration contre PostgreSQL/Redis réels. + +## Principaux artefacts + +Les liens précis figurent dans les fiches. Ils couvrent notamment : + +- **Propriété et rôles** : refus hors organisation, refus VIEWER en création et lecture conservée, refus d'un USER sur la liste globale, protection des cibles ADMIN. +- **Sessions** : type access sur WebSocket, compte actif, expiration, changement de mot de passe invalidant les anciens tokens et maintien des usages légitimes. +- **Notifications** : rejet des objets/tableaux/UUID invalides et prédicat `{id,user_id}` sur un repository simulé. Pas de preuve d'une mise à jour globale exécutée sur une vraie base. +- **Transporteur** : absence du token dans le mapper de réponse, données métier conservées. Le test ne vérifie pas l'effacement des anciens tokens ni les logs résiduels SEC-07. +- **CSV** : encodage des préfixes de formule et taille excessive rejetée avant le service. Pas d'ouverture réelle dans Excel ni test de saturation mémoire. +- **TLS** : véritables handshakes locaux PostgreSQL avec certificat de test ; options SMTP et refus STARTTLS avant AUTH sur serveur local. Pas de vérification du certificat SMTP du fournisseur. +- **Abonnements** : annulation malgré licences surnuméraires, erreur webhook non acquittée comme succès, liaison Checkout à l'organisation, droits courants et révocation d'accès API après suspension. + +## Relancer des tests dans un environnement de développement + +Les commandes ci-dessous sont des recettes pour les tests existants, à lancer depuis la racine du dépôt sur un environnement isolé déjà configuré. Elles ne sont pas des exploitations de production et n'ont pas été exécutées de nouveau pour ce README. + +```sh +npm test --prefix apps/backend -- --runInBand subscription-sync.security.spec.ts subscription-access.security.spec.ts +npm test --prefix apps/backend -- --runInBand api-keys-entitlement.security.spec.ts mcp-entitlement.security.spec.ts +npm test --prefix apps/backend -- --runInBand organizations.controller.spec.ts users.security.spec.ts notification.security.spec.ts +``` + +Certains tests HTTP/TLS ouvrent des ports sur loopback et demandent un environnement autorisant cette opération. Ne pas remplacer les doubles de Stripe, SMTP ou de base par des identifiants réels pour obtenir une prétendue preuve plus forte. Ne pas lancer automatiquement les configurations frontend chargeant des fichiers `.env` sans respecter les restrictions du projet. + +## Vérifications propres au dossier + +La vérification documentaire porte sur l'existence des fiches et liens locaux, la séparation entre constats et observations, les extraits relus et l'absence de valeurs de secrets copiées. Aucun validateur formel de rapports fourni par le projet n'a été utilisé. Aucune nouvelle relecture indépendante des fiches n'a pu être menée après la limite d'usage de délégation. diff --git a/audit_security/cle-api-preproduction/cle-api-preproduction.md b/audit_security/cle-api-preproduction/cle-api-preproduction.md new file mode 100644 index 0000000..ca626d4 --- /dev/null +++ b/audit_security/cle-api-preproduction/cle-api-preproduction.md @@ -0,0 +1,19 @@ +# SEC-19 — Clé d'API littérale dans une configuration de préproduction + +**Découvert le 17 septembre 2026 dans une modification locale. Gravité potentielle : moyenne, sous réserve de validité et de permissions du credential.** + +## Constat + +`docker/stack-portainer-preprod.yaml` contenait une valeur littérale pour `OPENAI_API_KEY`. Une personne obtenant ce fichier ou sa copie pouvait obtenir le credential sans disposer de l'autorité d'administration du fournisseur. La clé n'est pas reproduite ici. Aucune requête d'authentification ni vérification de sa validité n'a été effectuée. + +L'impact possible est l'utilisation de l'API dans les limites des permissions et quotas accordés à cette clé. Aucun accès aux autres données du fournisseur, aucune consommation frauduleuse et aucun incident réel ne sont établis. La présence dans une modification locale ne prouve pas une publication Git ou un déploiement. + +## Correction locale + +La valeur est remplacée par une variable obligatoire de déploiement. Le parseur YAML charge correctement le fichier, et un contrôle vérifie que l'emplacement ne contient plus de valeur littérale. Les autres changements utilisateur du fichier sont conservés. La configuration échouera volontairement si la variable n'est pas fournie au déploiement. + +## Action restante + +Révoquer la clé exposée chez le fournisseur, générer un remplacement selon les permissions nécessaires et renseigner le stockage de secrets du déploiement. Cette opération n'est pas effectuée depuis le dépôt et la clé ne doit pas être recopiée dans un ticket ou un rapport. La suppression du littéral ne détruit pas les copies déjà produites. + +Source : [configuration](../../docker/stack-portainer-preprod.yaml). Voir le [compte rendu de correction](../CORRECTIONS-2026-09-17.md). diff --git a/audit_security/droits-abonnements-inactifs/droits-abonnements-inactifs.md b/audit_security/droits-abonnements-inactifs/droits-abonnements-inactifs.md new file mode 100644 index 0000000..2e9ed2a --- /dev/null +++ b/audit_security/droits-abonnements-inactifs/droits-abonnements-inactifs.md @@ -0,0 +1,56 @@ +# SEC-18 — Droits payants conservés sur un abonnement inactif + +**Gravité : Moyenne, avant correction.** + +**État au 14 septembre 2026 :** Correctif local non commité au début de cette rédaction ; déploiement inconnu. + +## Executive Summary + +Un utilisateur d'une organisation dont l'abonnement est passé dans un état sans droits conserve une offre facturée GOLD ou PLATINIUM en base. Il utilise une fonctionnalité payante ou une clé API déjà créée. Il n'a pas besoin de modifier Stripe ni de falsifier son rôle. + +Le commit `c09b8be` contient encore le comportement vulnérable ; le correctif examiné est dans les modifications locales du 14 septembre. Aucun tag local ni version de production vérifiée ne permet d'annoncer une première release affectée ou une release déployée corrigée. La validation combine relecture du source et tests locaux documentés ; aucun incident réel n'est affirmé. + +## Background + +La frontière de sécurité est celle décrite par les prérequis ci-dessus. Le paramétrage fourni par le dépôt ne permet pas de connaître la topologie et les valeurs effectivement en ligne. Les preuves disponibles doivent donc être lues séparément des conditions de déploiement restant à vérifier. + +## Vulnerability Details + +`SubscriptionStatus.allowsAccess` exclut UNPAID, PAUSED, INCOMPLETE, INCOMPLETE_EXPIRED et CANCELED. Mais `Subscription.hasFeature` consultait uniquement props.plan ; plusieurs consommateurs lisaient directement subscription.plan pour les quotas et les frais. `FeatureFlagGuard` acceptait aussi un tableau planFeatures présent sur request.user avant la lecture en base. Le JwtStrategy HTTP courant ne transporte pas nécessairement ces claims, tandis que d'autres contextes d'authentification peuvent en disposer : la branche de confiance de claims est un défaut défensif confirmé, pas une preuve que toute requête JWT normale exploite ce raccourci. La branche DB était elle-même insuffisante puisqu'elle ignorait le statut. L'API key service, le JWT émis, l'aperçu et le résolveur MCP partageaient cette confusion entre offre facturée et droits actuels. + +Sources courantes, fonctions et tests concernés : + +- [apps/backend/src/domain/entities/subscription.entity.ts](../../apps/backend/src/domain/entities/subscription.entity.ts) +- [apps/backend/src/domain/value-objects/subscription-status.vo.ts](../../apps/backend/src/domain/value-objects/subscription-status.vo.ts) +- [apps/backend/src/application/guards/feature-flag.guard.ts](../../apps/backend/src/application/guards/feature-flag.guard.ts) +- [apps/backend/src/application/api-keys/api-keys.service.ts](../../apps/backend/src/application/api-keys/api-keys.service.ts) +- [apps/backend/src/application/auth/auth.service.ts](../../apps/backend/src/application/auth/auth.service.ts) +- [apps/backend/src/application/mcp/mcp.controller.ts](../../apps/backend/src/application/mcp/mcp.controller.ts) +- [apps/backend/src/application/guards/subscription-access.security.spec.ts](../../apps/backend/src/application/guards/subscription-access.security.spec.ts) +- [apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts](../../apps/backend/src/application/api-keys/api-keys-entitlement.security.spec.ts) +- [apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts](../../apps/backend/src/application/mcp/mcp-entitlement.security.spec.ts) +- [apps/backend/src/application/controllers/csv-bookings.security.spec.ts](../../apps/backend/src/application/controllers/csv-bookings.security.spec.ts) + +Pour comparer au snapshot vulnérable, consulter ces mêmes chemins dans la révision citée, sans supposer que les numéros de lignes actuels correspondent à l'ancienne version. + +## Exploitability Analysis + +Conservation de fonctionnalités, clés API et avantages de quota/frais au-delà de l'état qui doit les autoriser. La frontière est celle de l'abonnement de sa propre organisation ; aucune élévation de rôle ou lecture inter-tenant n'est nécessaire. Le catalogue MCP actuel n'impose pas de fonctionnalité payante à ses outils : son défaut porte sur la résolution/annonce de l'offre et la cohérence du futur contrôle, pas sur un outil premium identifié et exploité aujourd'hui. + +Le problème ne requiert pas de supprimer les contrôles métier ou cryptographiques voisins. Il exploite précisément la différence entre le contrôle attendu et celui effectivement exécuté. Les contre-exemples ci-dessous précisent ce que les tests isolent ; ils ne constituent pas un test de pénétration du site en ligne. + +## Proof of Concept + +Avant correction, la matrice domaine/garde présentait 12 refus attendus qui échouaient et 7 contrôles légitimes réussis. Les 19 passent ensuite. Huit tests API vérifient refus de création et perte d'usage après suspension, ainsi que ACTIVE/TRIALING/PAST_DUE. Six tests MCP vérifient l'offre courante de whoami et l'exception ADMIN. Le quota Bronze après suspension est testé au contrôleur avec la véritable entité. Les dépôts et Stripe restent simulés. + +Les artefacts sont déjà dans les fichiers de test liés ci-dessus. Aucun faux journal d'exploitation ni nouvelle commande d'attaque de production n'est fourni. Voir [VALIDATION.md](../VALIDATION.md) pour le périmètre et les résultats consolidés. + +## Remediation + +`Subscription.accessPlan` conserve l'offre payante seulement pour ACTIVE, TRIALING et PAST_DUE, conformément à la grâce existante ; les autres états donnent Bronze. Le plan de facturation reste persisté. hasFeature, quota et frais utilisent accessPlan ; les consommateurs ont été alignés. Le garde consulte les droits actuels plutôt qu'une déclaration ancienne. Les exceptions ADMIN déjà présentes restent inchangées et aucune exception ADMIN n'a été ajoutée aux clés API. Les allocations de licences possédaient déjà des contrôles isActive distincts. + +La présente passe documente ce changement antérieur ; elle n'ajoute aucun correctif applicatif et ne confirme pas son déploiement. + +## Summary + +Le mécanisme décrit est confirmé dans la révision vulnérable citée, et la portée du correctif local est bornée par les tests disponibles. Correctif local non commité au début de cette rédaction ; déploiement inconnu. Les limites de couverture générale sont détaillées dans [COUVERTURE.md](../COUVERTURE.md). diff --git a/audit_security/frais-erreur-abonnement/frais-erreur-abonnement.md b/audit_security/frais-erreur-abonnement/frais-erreur-abonnement.md new file mode 100644 index 0000000..4952940 --- /dev/null +++ b/audit_security/frais-erreur-abonnement/frais-erreur-abonnement.md @@ -0,0 +1,47 @@ +# OBS-01 — Une erreur de lecture d'abonnement devient un forfait gratuit + +> **Mise à jour du 17 septembre 2026 :** voir le [compte rendu de correction](../CORRECTIONS-2026-09-17.md). Le texte ci-dessous conserve le constat avant cette passe. La panne de tarif est désormais refusée avant les effets de bord ; contrôles légitimes préservés. + + +**Statut : comportement confirmé dans le code, exploitation contrôlable non validée.** Pas de gravité de vulnérabilité attribuée à ce stade. Cette observation n'entre pas dans le nombre des 18 constats historiques confirmés. + +## Ce que fait le code actuel + +Dans `apps/backend/src/application/services/csv-booking.service.ts`, `resolveBookingFeeEur`, lignes 253–262 de l'état local du 14 septembre, attrape toute erreur de `getOrCreateSubscription`, écrit un message et retourne zéro : + +```typescript + } catch (error: any) { + this.logger.error(`Failed to resolve booking fee: ${error?.message}`); + return 0; + } +``` + +Le même fichier, `createBooking`, lignes 168–172, utilise ce montant pour choisir la nécessité du paiement : + +```typescript + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); + const requiresPayment = bookingFeeEur > 0; + const initialStatus = requiresPayment + ? CsvBookingStatus.PENDING_PAYMENT + : CsvBookingStatus.PENDING; +``` + +Le montant zéro est aussi employé par l'offre sur mesure : l'erreur technique devient donc indiscernable d'une décision commerciale légitime. La réservation est ensuite créée dans le repository. `acceptBooking` appelle également le calcul des frais et applique le résultat à la réservation. + +## Scénario à vérifier et limites + +Un utilisateur habilité à créer une réservation pourrait bénéficier de l'absence de paiement automatique si la récupération de son abonnement échoue à cet instant et que les étapes suivantes fonctionnent. Mais le contrôleur lit déjà l'abonnement pour son quota et la création persiste ensuite dans la base. Une panne totale et permanente de PostgreSQL pourrait empêcher toute la réservation ; elle ne démontre pas ce contournement. + +Il faut donc établir une erreur sélective, transitoire ou propre à la résolution de l'abonnement, suivie d'une sauvegarde réussie. La possibilité pour un attaquant ordinaire de provoquer ou d'exploiter cette situation n'est pas démontrée. Une simple lecture du `catch` ne permet pas d'affirmer qu'un utilisateur peut réserver gratuitement à volonté. + +## Validation nécessaire + +Sur des doubles locaux, faire réussir la lecture du quota, échouer uniquement le calcul des frais, puis autoriser la sauvegarde. Observer le statut et le montant réellement transmis au repository. Comparer avec une offre réellement gratuite/sur mesure, puis avec une base totalement indisponible. Le contrôle déterminant doit montrer l'absence de paiement uniquement dans le cas d'erreur ciblé et non par une offre légitimement sans forfait. + +Aucun de ces nouveaux tests n'a été exécuté pendant cette passe documentaire. Il n'est pas nécessaire de provoquer une panne de production. + +## Principe recommandé, sans modification appliquée + +Une impossibilité de déterminer le prix doit rester une erreur ou un état explicitement non payable tant que le tarif n'est pas connu ; elle ne devrait pas devenir un tarif nul. Le choix exact doit préserver les offres réellement sur mesure et empêcher une notification transporteur prématurée. + +Sources : [service](../../apps/backend/src/application/services/csv-booking.service.ts), [contrôleur CSV](../../apps/backend/src/application/controllers/csv-bookings.controller.ts). L'observation est distincte de [SEC-18](../droits-abonnements-inactifs/droits-abonnements-inactifs.md), qui corrige un statut ignoré et non la gestion d'une panne. diff --git a/audit_security/injection-formules-csv/injection-formules-csv.md b/audit_security/injection-formules-csv/injection-formules-csv.md new file mode 100644 index 0000000..1acd9f5 --- /dev/null +++ b/audit_security/injection-formules-csv/injection-formules-csv.md @@ -0,0 +1,73 @@ +# SEC-14 — Les exports CSV conservent les formules injectées + +**Gravité historique : Faible.** Classification : CWE-1236. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi par `csvCell`, utilisé pour valeurs formatées et en-têtes des deux exports CSV. Les préfixes de formule, y compris derrière des espaces/caractères de contrôle, sont forcés au texte. La compatibilité avec chaque version de tableur n'a pas été testée. + +## Executive Summary + +Un manager peut modifier un nom d'utilisateur de son organisation, puis un autre utilisateur habilité exporte la liste en CSV et ouvre ce fichier dans un tableur. Le déclencheur n'est pas la simple consultation de la page web. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Échapper les séparateurs et guillemets préserve la syntaxe CSV. Forcer une cellule à rester du texte préserve son interprétation ; les deux propriétés sont distinctes. + +## Vulnerability Details + +Le nom est une chaîne métier acceptée par l'API puis passée à ExportButton sur la page de gestion des utilisateurs. L'ancien générateur entourait les cellules de guillemets et doublait les guillemets internes, ce qui produit un CSV syntaxiquement valide mais ne neutralise pas les expressions commençant par `=`, `+`, `-` ou `@`. Un exemple inoffensif est `=1+1` : le tableur peut calculer une expression au lieu d'afficher le texte. Le même principe concerne l'export utilitaire CSV ; les cellules explicitement typées texte dans l'export Excel XML ne doivent pas être assimilées à ce cas. + +Extrait historique vérifié, `apps/frontend/src/components/ExportButton.tsx`, lignes 65–80 du snapshot préaudit : + +```tsx + const generateCSV = (): string => { + const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';'); + + const rows = data.map(row => { + return columns + .map(col => { + const value = getNestedValue(row, col.key as string); + const formattedValue = col.format ? col.format(value, row) : formatValue(value); + return `"${formattedValue.replace(/"/g, '""')}"`; + }) + .join(';'); + }); + + return [headers, ...rows].join('\n'); + }; + +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/users.controller.ts](../../apps/backend/src/application/controllers/users.controller.ts) +- [apps/frontend/app/[locale]/dashboard/settings/users/page.tsx](../../apps/frontend/app/%5Blocale%5D/dashboard/settings/users/page.tsx) +- [apps/frontend/src/components/ExportButton.tsx](../../apps/frontend/src/components/ExportButton.tsx) + +## Exploitability Analysis + +Interprétation de données contrôlées comme formules dans le contexte du tableur du destinataire. Les effets dépendent du logiciel et de ses protections. Aucune exécution système ni exfiltration automatique n'a été démontrée. Le besoin d'un export puis d'une ouverture manuelle et le périmètre intra-organisation limitent la gravité historique à faible. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/frontend/src/utils/csv-cell.test.ts](../../apps/frontend/src/utils/csv-cell.test.ts) +- [apps/frontend/src/__tests__/utils/export.test.ts](../../apps/frontend/src/__tests__/utils/export.test.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi par `csvCell`, utilisé pour valeurs formatées et en-têtes des deux exports CSV. Les préfixes de formule, y compris derrière des espaces/caractères de contrôle, sont forcés au texte. La compatibilité avec chaque version de tableur n'a pas été testée. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Les exports CSV conservent les formules injectées est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/jeton-transporteur-dans-reponses/jeton-transporteur-dans-reponses.md b/audit_security/jeton-transporteur-dans-reponses/jeton-transporteur-dans-reponses.md new file mode 100644 index 0000000..7d92cfa --- /dev/null +++ b/audit_security/jeton-transporteur-dans-reponses/jeton-transporteur-dans-reponses.md @@ -0,0 +1,61 @@ +# SEC-05 — Le client reçoit le jeton de réponse du transporteur + +**Gravité historique : Moyenne.** Classification : CWE-863. + +**État au 14 septembre 2026 :** Exposition dans les réponses ordinaires corrigée dans le code suivi : retrait du DTO, du mapper et du type frontend. Les liens envoyés au transporteur conservent leur fonctionnement. Risque résiduel : les tokens déjà copiés ne sont pas invalidés par le changement de sérialisation. Leur révocation ou expiration réelle n'a pas été vérifiée. Voir aussi SEC-07 pour les logs actuels. + +## Executive Summary + +Le créateur d'une réservation peut lire sa réponse métier. Dans l'ancienne version, cette réponse contient également `confirmationToken`, un secret destiné au transporteur. Un membre accédant à la liste de son organisation pouvait aussi le recevoir ; ce second défaut de visibilité est SEC-12. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Le token est un credential de décision transporteur, et non une simple métadonnée de réservation. Le demandeur et le transporteur sont deux autorités différentes même s’ils interviennent sur le même dossier. + +## Vulnerability Details + +`CsvBookingService.toResponseDto` sérialise le token avec le statut et les documents. Les routes publiques `GET /api/v1/csv-booking-actions/accept/:token` et `reject/:token` utilisent ce même secret pour retrouver puis accepter ou refuser la réservation. Le demandeur possède alors l'autorité censée appartenir au transporteur, sans avoir accès à sa boîte mail. Le domaine impose cependant un statut compatible, une absence d'expiration et une réservation encore non résolue. La protection documentaire par mot de passe ne s'applique pas à la décision d'acceptation. + +Extrait historique vérifié, `apps/backend/src/application/services/csv-booking.service.ts`, lignes 1608–1612 du snapshot préaudit : + +```typescript + status: booking.status, + documents: booking.documents.map(this.toDocumentDto), + confirmationToken: booking.confirmationToken, + requestedAt: booking.requestedAt, + respondedAt: booking.respondedAt || null, +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/services/csv-booking.service.ts](../../apps/backend/src/application/services/csv-booking.service.ts) +- [apps/backend/src/application/controllers/csv-booking-actions.controller.ts](../../apps/backend/src/application/controllers/csv-booking-actions.controller.ts) +- [apps/backend/src/domain/entities/csv-booking.entity.ts](../../apps/backend/src/domain/entities/csv-booking.entity.ts) + +## Exploitability Analysis + +Fausse décision transporteur et effets métier associés sur la réservation accessible à l'attaquant. Cela ne permet pas, à lui seul, de lire toutes les réservations, de contourner le paiement préalable, ni de télécharger les documents protégés par un mot de passe. La possession d'un secret valide reste indispensable. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/services/csv-booking-response.security.spec.ts](../../apps/backend/src/application/services/csv-booking-response.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Exposition dans les réponses ordinaires corrigée dans le code suivi : retrait du DTO, du mapper et du type frontend. Les liens envoyés au transporteur conservent leur fonctionnement. Risque résiduel : les tokens déjà copiés ne sont pas invalidés par le changement de sérialisation. Leur révocation ou expiration réelle n'a pas été vérifiée. Voir aussi SEC-07 pour les logs actuels. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Le client reçoit le jeton de réponse du transporteur est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/lecture-dossiers-collegues/lecture-dossiers-collegues.md b/audit_security/lecture-dossiers-collegues/lecture-dossiers-collegues.md new file mode 100644 index 0000000..e599b9d --- /dev/null +++ b/audit_security/lecture-dossiers-collegues/lecture-dossiers-collegues.md @@ -0,0 +1,64 @@ +# SEC-12 — Les dossiers des collègues sont accessibles sans rôle de gestion + +**Gravité historique : Faible.** Classification : CWE-862. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. La liste et les statistiques globales CSV sont réservées à ADMIN/MANAGER. Les listes personnelles restent disponibles selon les droits existants. + +## Executive Summary + +Un USER ou VIEWER connecté appartient à une organisation possédant des réservations CSV créées par d'autres utilisateurs. Il appelle la liste globale de l'organisation alors que celle-ci est décrite comme réservée aux managers et administrateurs. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +La liste d’organisation est une opération plus large qu’une lecture personnelle. L’identité du tenant est correctement dérivée de la session, mais il manque historiquement le droit de consulter les dossiers des collègues. + +## Vulnerability Details + +`GET /api/v1/csv-bookings/organization/all` ne portait que JwtAuthGuard. Le contrôleur prend correctement `organizationId` dans la session puis appelle `getOrganizationBookings`, qui retourne les réservations de ce tenant. À l'inverse, la lecture individuelle vérifie le propriétaire ou le transporteur assigné. Cette divergence permet de contourner la restriction de lecture individuelle par un endpoint de collection. C'est une autorisation manquante sur une opération de groupe, pas une manipulation de l'organisation de session. + +Extrait historique vérifié, `apps/backend/src/application/controllers/csv-bookings.controller.ts`, lignes 313–321 du snapshot préaudit : + +```typescript + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/csv-bookings.controller.ts](../../apps/backend/src/application/controllers/csv-bookings.controller.ts) +- [apps/backend/src/application/services/csv-booking.service.ts](../../apps/backend/src/application/services/csv-booking.service.ts) + +## Exploitability Analysis + +Consultation des prix, notes, données transporteur et métadonnées documentaires des collègues. Aucun franchissement entre organisations n'est démontré ; les réservations non CSV ont une politique distincte et ne sont pas automatiquement concernées. Les tokens anciennement exposés par cette liste relèvent de SEC-05 et ne sont pas comptés comme une seconde fuite indépendante ici. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/csv-bookings.security.spec.ts](../../apps/backend/src/application/controllers/csv-bookings.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. La liste et les statistiques globales CSV sont réservées à ADMIN/MANAGER. Les listes personnelles restent disponibles selon les droits existants. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Les dossiers des collègues sont accessibles sans rôle de gestion est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/manager-modifie-administrateur/manager-modifie-administrateur.md b/audit_security/manager-modifie-administrateur/manager-modifie-administrateur.md new file mode 100644 index 0000000..e4f9276 --- /dev/null +++ b/audit_security/manager-modifie-administrateur/manager-modifie-administrateur.md @@ -0,0 +1,63 @@ +# SEC-13 — Un manager peut rétrograder un administrateur de son organisation + +**Gravité historique : Faible.** Classification : CWE-863. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. Le contrôleur refuse toute modification d'une cible actuellement ADMIN par un acteur non ADMIN avant mutation de ses champs. + +## Executive Summary + +Un MANAGER partage l'organisation d'un ADMIN et connaît son UUID. L'accès au contrôleur utilisateurs est lui-même conditionné par la fonctionnalité `user_management`. Le manager ne doit pas pouvoir neutraliser le compte de niveau supérieur. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +La hiérarchie doit vérifier le rôle de la cible avant modification, en plus du nouveau rôle demandé. Interdire l’attribution d’ADMIN ne suffit pas à protéger un compte déjà ADMIN. + +## Vulnerability Details + +`PATCH /api/v1/users/:id` interdisait déjà à un non-ADMIN d'attribuer le rôle ADMIN et à un manager d'agir hors de son organisation. Il ne vérifiait pas le rôle actuel de la cible. Changer le rôle d'un ADMIN vers USER, ou passer `isActive` à false, franchissait donc les tests puis était persisté. Masquer les administrateurs dans la liste d'utilisateurs ne protège pas la route directe lorsqu'un UUID est connu. + +Extrait historique vérifié, `apps/backend/src/application/controllers/users.controller.ts`, lignes 256–264 du snapshot préaudit : + +```typescript + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/users.controller.ts](../../apps/backend/src/application/controllers/users.controller.ts) + +## Exploitability Analysis + +Rétrogradation ou désactivation d'un administrateur du même tenant. Aucun mécanisme d'auto-promotion vers ADMIN n'est démontré : l'impact est la perte d'autorité/disponibilité du compte cible. La gravité historique faible du rapport initial reflète ces prérequis restreints ; l'importance opérationnelle peut augmenter si l'organisation héberge un administrateur indispensable. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/users.security.spec.ts](../../apps/backend/src/application/controllers/users.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. Le contrôleur refuse toute modification d'une cible actuellement ADMIN par un acteur non ADMIN avant mutation de ses champs. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Un manager peut rétrograder un administrateur de son organisation est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/modification-inter-organisations/modification-inter-organisations.md b/audit_security/modification-inter-organisations/modification-inter-organisations.md new file mode 100644 index 0000000..e26f32d --- /dev/null +++ b/audit_security/modification-inter-organisations/modification-inter-organisations.md @@ -0,0 +1,72 @@ +# SEC-02 — Un manager peut modifier une autre organisation + +**Gravité historique : Élevée.** Classification : CWE-863. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. Tout acteur autre que `UserRole.ADMIN` est refusé lorsque l'organisation cible diffère de celle de sa session, indépendamment de la casse qui avait déclenché le problème. + +## Executive Summary + +Un MANAGER connecté connaît l'UUID d'une autre organisation. Il peut appeler `PATCH /api/v1/organizations/:id`. Le rôle de gestion est légitime pour sa propre organisation ; il ne doit pas permettre de modifier celle d'Alice. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Le rôle autorise une catégorie d’opérations, tandis que organizationId délimite le client concerné. Les deux conditions doivent tenir ensemble, sauf exception explicite pour l’administrateur de plateforme. + +## Vulnerability Details + +`JwtStrategy` expose le rôle de l'utilisateur tel qu'il est stocké. `RolesGuard` compare les rôles sans tenir compte de la casse, mais ne transforme pas la valeur portée par la requête. Dans `updateOrganization`, l'ancien test `user.role === 'manager'` ne s'applique donc pas à `MANAGER`. Après lecture de l'organisation par son UUID, le contrôleur met à jour les champs demandés, y compris le statut actif, puis appelle `organizationRepository.save`. Le contrôle global du rôle laisse passer la requête tandis que le contrôle local du tenant est sauté. + +Extrait historique vérifié, `apps/backend/src/application/controllers/organizations.controller.ts`, lignes 241–256 du snapshot préaudit : + +```typescript + async updateOrganization( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Managers can only update their own organization + if (user.role === 'manager' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only update your own organization'); + } +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/auth/jwt.strategy.ts](../../apps/backend/src/application/auth/jwt.strategy.ts) +- [apps/backend/src/application/guards/roles.guard.ts](../../apps/backend/src/application/guards/roles.guard.ts) +- [apps/backend/src/application/controllers/organizations.controller.ts](../../apps/backend/src/application/controllers/organizations.controller.ts) + +## Exploitability Analysis + +Modification de données d'une autre organisation et retour de sa fiche : l'atteinte à l'isolation entre clients est directe. L'attaquant ne devient pas ADMIN et ne peut pas contourner l'authentification. La connaissance de l'UUID cible reste un prérequis ; aucune méthode universelle de découverte de ces UUID n'est établie ici. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/organizations.controller.spec.ts](../../apps/backend/src/application/controllers/organizations.controller.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. Tout acteur autre que `UserRole.ADMIN` est refusé lorsque l'organisation cible diffère de celle de sa session, indépendamment de la casse qui avait déclenché le problème. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Un manager peut modifier une autre organisation est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/mutations-role-viewer/mutations-role-viewer.md b/audit_security/mutations-role-viewer/mutations-role-viewer.md new file mode 100644 index 0000000..4b71cc0 --- /dev/null +++ b/audit_security/mutations-role-viewer/mutations-role-viewer.md @@ -0,0 +1,59 @@ +# SEC-06 — VIEWER peut créer et modifier des réservations + +**Gravité historique : Moyenne.** Classification : CWE-862. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. Les routes mutantes CSV exigent ADMIN, MANAGER ou USER via RolesGuard ; les lectures prévues pour VIEWER restent disponibles. + +## Executive Summary + +Un compte VIEWER actif, y compris un ancien USER rétrogradé, doit pouvoir consulter mais ne pas créer ni modifier une réservation. L'attaquant peut envoyer directement les requêtes HTTP même si l'interface masque les boutons. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Le rôle VIEWER est explicitement en lecture seule dans l’entité User. Le contrôle de propriétaire et le quota ne remplacent pas l’autorisation d’écriture. + +## Vulnerability Details + +Le contrôleur CSV applique l'authentification mais omettait le contrôle de rôle sur la création multipart et plusieurs mutations. La méthode vérifie la présence de documents, l'identité et le quota ; le service crée ensuite l'entité et ses fichiers. Les contrôles de propriété restent appliqués sur les mutations de dossiers existants, mais ils répondent à une autre question : posséder une réservation ne rétablit pas les droits d'écriture après passage en VIEWER. + +Extrait historique vérifié, `apps/backend/src/application/controllers/csv-bookings.controller.ts`, lignes 86–88 du snapshot préaudit : + +```typescript + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/csv-bookings.controller.ts](../../apps/backend/src/application/controllers/csv-bookings.controller.ts) +- [apps/backend/src/domain/entities/user.entity.ts](../../apps/backend/src/domain/entities/user.entity.ts) +- [apps/backend/src/application/services/csv-booking.service.ts](../../apps/backend/src/application/services/csv-booking.service.ts) + +## Exploitability Analysis + +Écriture non autorisée pour un rôle explicitement en lecture seule : création et opérations sur ses propres réservations, dont modification, paiement ou annulation suivant la route. Aucun accès arbitraire aux dossiers d'une autre organisation n'est établi par cette faille. Les limites d'offre et contraintes du domaine restent en place. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/csv-bookings.security.spec.ts](../../apps/backend/src/application/controllers/csv-bookings.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. Les routes mutantes CSV exigent ADMIN, MANAGER ou USER via RolesGuard ; les lectures prévues pour VIEWER restent disponibles. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +VIEWER peut créer et modifier des réservations est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/notifications-propriete-et-criteres/notifications-propriete-et-criteres.md b/audit_security/notifications-propriete-et-criteres/notifications-propriete-et-criteres.md new file mode 100644 index 0000000..f1a6a70 --- /dev/null +++ b/audit_security/notifications-propriete-et-criteres/notifications-propriete-et-criteres.md @@ -0,0 +1,66 @@ +# SEC-04 — Un membre peut marquer toutes les notifications comme lues + +**Gravité historique : Moyenne.** Classification : CWE-639. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. Le service valide les deux UUID et exige `userId`. Le repository utilise exclusivement `{ id, user_id: userId }`. REST et WebSocket transmettent l'identité authentifiée. + +## Executive Summary + +Tout utilisateur autorisé à ouvrir un WebSocket pouvait envoyer `mark_as_read`. Un identifiant de notification d'un autre utilisateur ou un objet JSON à la place de l'identifiant franchissait l'interface sans validation effective. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +L’état de lecture appartient au destinataire de la notification. L’identifiant externe doit rester un UUID ; aucun objet fourni par le client ne doit devenir un prédicat de sélection ORM. + +## Vulnerability Details + +Le type TypeScript `{ notificationId: string }` ne valide pas un message réseau. Le gateway transmet `data.notificationId` au service sans l'identité du destinataire. Le repository appelle ensuite `ormRepository.update(id, ...)`. Dans TypeORM, un objet non vide peut représenter un prédicat de mise à jour : `{read:false}` ne désigne plus une notification, mais les lignes non lues. Il ne s'agit pas d'une injection de texte SQL ; l'API de sélection du repository accepte une forme trop large. L'endpoint REST avait un contrôle de propriétaire, ce qui ne protégeait pas le point d'entrée WebSocket. + +Extrait historique vérifié, `apps/backend/src/application/gateways/notifications.gateway.ts`, lignes 117–124 du snapshot préaudit : + +```typescript + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/gateways/notifications.gateway.ts](../../apps/backend/src/application/gateways/notifications.gateway.ts) +- [apps/backend/src/application/services/notification.service.ts](../../apps/backend/src/application/services/notification.service.ts) +- [apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts](../../apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts) + +La dépendance locale relue est **TypeORM 0.3.27**. `entity-manager/EntityManager.js`, méthode `update`, rejette les critères vides, utilise `whereInIds` pour les primitives et `.where(criteria)` pour les autres formes. Un objet non vide tel que `{read:false}` n’est donc pas protégé par le rejet des critères vides. Ce détail a été vérifié dans le code installé ; aucune requête destructive n’a été exécutée. + +## Exploitability Analysis + +La primitive étroite est une modification de l'état lu/non lu hors du compte appelant ; avec un critère objet, elle peut concerner plusieurs organisations. Aucun contenu de notification n'est exfiltré par cette opération. La portée globale est établie par le chemin statique de critères TypeORM, pas par une mise à jour réellement exécutée contre PostgreSQL en production. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/services/notification.security.spec.ts](../../apps/backend/src/application/services/notification.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. Le service valide les deux UUID et exige `userId`. Le repository utilise exclusivement `{ id, user_id: userId }`. REST et WebSocket transmettent l'identité authentifiée. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Un membre peut marquer toutes les notifications comme lues est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/postgresql-tls-incoherent/postgresql-tls-incoherent.md b/audit_security/postgresql-tls-incoherent/postgresql-tls-incoherent.md new file mode 100644 index 0000000..b08bbeb --- /dev/null +++ b/audit_security/postgresql-tls-incoherent/postgresql-tls-incoherent.md @@ -0,0 +1,53 @@ +# SEC-16 — PostgreSQL : TLS incohérent et certificat non authentifié + +**Gravité : Moyenne, avant correction.** + +**État au 14 septembre 2026 :** Chemins principaux corrigés dans le code suivi ; confiance CA et scripts secondaires à vérifier. + +## Executive Summary + +Un intermédiaire capable de détourner la connexion d'un client PostgreSQL est la menace pour l'identité du serveur. Un second effet concerne simplement la disponibilité : un client n'activant pas TLS ne peut pas joindre une base configurée pour exiger hostssl. Ces deux effets ne doivent pas être confondus avec une injection SQL. + +Le snapshot préaudit `8446f87` est la version vulnérable vérifiée ; le correctif est présent dans `c09b8be`. Aucun tag local ni version de production vérifiée ne permet d'annoncer une première release affectée ou une release déployée corrigée. La validation combine relecture du source et tests locaux documentés ; aucun incident réel n'est affirmé. + +## Background + +La frontière de sécurité est celle décrite par les prérequis ci-dessus. Le paramétrage fourni par le dépôt ne permet pas de connaître la topologie et les valeurs effectivement en ligne. Les preuves disponibles doivent donc être lues séparément des conditions de déploiement restant à vérifier. + +## Vulnerability Details + +Dans le snapshot préaudit, la CLI TypeORM active `ssl` si DATABASE_SSL vaut true mais fournit `rejectUnauthorized: false`. L'API et les clients de démarrage n'appliquent pas ce même drapeau. Le même opérateur pouvait donc croire que DATABASE_SSL sécurisait tous les accès alors que chaque chemin avait une politique différente. La configuration de production fournie dans le dépôt décrit une base hostssl, mais son application effective n'a pas été testée. Un chiffrement sans authentification du certificat ne suffit pas contre un relais actif ; une connexion sans TLS à une base qui l'exige échoue plutôt que de devenir implicitement sûre. + +Sources courantes, fonctions et tests concernés : + +- [apps/backend/src/infrastructure/persistence/typeorm/database-tls.ts](../../apps/backend/src/infrastructure/persistence/typeorm/database-tls.ts) +- [apps/backend/src/infrastructure/persistence/typeorm/data-source.ts](../../apps/backend/src/infrastructure/persistence/typeorm/data-source.ts) +- [apps/backend/src/app.module.ts](../../apps/backend/src/app.module.ts) +- [apps/backend/scripts/setup/startup.js](../../apps/backend/scripts/setup/startup.js) +- [apps/backend/scripts/setup/run-migrations.js](../../apps/backend/scripts/setup/run-migrations.js) +- [apps/backend/src/infrastructure/persistence/typeorm/database-tls.spec.ts](../../apps/backend/src/infrastructure/persistence/typeorm/database-tls.spec.ts) +- [apps/backend/src/infrastructure/persistence/typeorm/database-startup.spec.ts](../../apps/backend/src/infrastructure/persistence/typeorm/database-startup.spec.ts) + +Pour comparer au snapshot vulnérable, consulter ces mêmes chemins dans la révision citée, sans supposer que les numéros de lignes actuels correspondent à l'ancienne version. + +## Exploitability Analysis + +Risque d'interception ou de modification du trafic pour les clients sans vérification d'identité, sous contrôle réseau actif ; risque d'échec de démarrage ou de migration pour les chemins incompatibles. Aucune base réelle, aucun credential de production et aucune migration en ligne n'ont été utilisés. Le port accessible publiquement, les règles réseau et le certificat réel restent inconnus. + +Le problème ne requiert pas de supprimer les contrôles métier ou cryptographiques voisins. Il exploite précisément la différence entre le contrôle attendu et celui effectivement exécuté. Les contre-exemples ci-dessous précisent ce que les tests isolent ; ils ne constituent pas un test de pénétration du site en ligne. + +## Proof of Concept + +La passe du 10 septembre documente 11 tests du helper et de handshakes TLS locaux : certificat approuvé pour la bonne IP accepté, chaîne non approuvée et identité IP incorrecte refusées. Deux tests de démarrage vérifient la transmission des options en simulant PostgreSQL/TypeORM. Aucun test contre le serveur de production ne prouve la distribution effective de sa CA. + +Les artefacts sont déjà dans les fichiers de test liés ci-dessus. Aucun faux journal d'exploitation ni nouvelle commande d'attaque de production n'est fourni. Voir [VALIDATION.md](../VALIDATION.md) pour le périmètre et les résultats consolidés. + +## Remediation + +`databaseTlsOptions` est partagé par app.module, data-source, startup, run-migrations et l'entrypoint historique. Avec DATABASE_SSL=true, il exige la chaîne approuvée et l'identité de DATABASE_HOST. DATABASE_SSL_CA permet d'ajouter le certificat public de confiance ; son absence conserve les autorités Node et ne désactive pas la vérification. Le mode false demeure possible et doit être réservé aux topologies qui le justifient. Ne pas distribuer de clé privée. Les scripts de maintenance hors chemins principaux n'ont pas tous été recensés et alignés. + +La présente passe documente ce changement antérieur ; elle n'ajoute aucun correctif applicatif et ne confirme pas son déploiement. + +## Summary + +Le mécanisme décrit est confirmé dans la révision vulnérable citée, et la portée du correctif local est bornée par les tests disponibles. Chemins principaux corrigés dans le code suivi ; confiance CA et scripts secondaires à vérifier. Les limites de couverture générale sont détaillées dans [COUVERTURE.md](../COUVERTURE.md). diff --git a/audit_security/resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md b/audit_security/resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md new file mode 100644 index 0000000..8d49c52 --- /dev/null +++ b/audit_security/resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md @@ -0,0 +1,70 @@ +# SEC-11 — La résiliation peut conserver les avantages payants + +**Gravité historique : Moyenne.** Classification : CWE-841. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi : `cancel()` applique Bronze/CANCELED indépendamment du nombre de licences, et une erreur de traitement webhook renvoie une erreur HTTP afin de permettre une nouvelle livraison. Aucun effacement de comptes surnuméraires n'est nécessaire à cette révocation. + +## Executive Summary + +Une organisation payante possède au moins deux licences actives non ADMIN puis son abonnement est résilié. Stripe transmet un événement signé `customer.subscription.deleted`. L'événement est authentique : l'attaque ne consiste pas à forger un webhook. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +La résiliation supprime des droits ; le quota de licences limite au contraire les capacités d’une offre. Une contrainte d’effectif ne doit pas empêcher la fin d’accès, et le webhook doit refléter la réussite réelle de la transition. + +## Vulnerability Details + +L'ancien `handleSubscriptionDeleted` commence par `updatePlan(BRONZE, countActiveLicenses)`, puis seulement `updateStatus(CANCELED)` et la sauvegarde. L'offre Bronze tolère une seule licence ; `updatePlan` peut donc lever `InvalidSubscriptionDowngradeException` avant de retirer le plan payant. Le contrôleur attrape l'erreur et retourne `{received:false}` sous une réponse HTTP de succès. Stripe peut considérer l'événement livré alors que la transition locale n'a pas eu lieu. La politique de capacité de licences bloquait une transition de fin d'accès qui doit pourtant être inconditionnelle. + +Extrait historique vérifié, `apps/backend/src/application/services/subscription.service.ts`, lignes 608–619 du snapshot préaudit : + +```typescript + } + + // Downgrade to FREE plan - count only non-ADMIN licenses + const canceledSubscription = subscription + .updatePlan( + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) + ) + .updateStatus(SubscriptionStatus.canceled()); + + await this.subscriptionRepository.save(canceledSubscription); + +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/services/subscription.service.ts](../../apps/backend/src/application/services/subscription.service.ts) +- [apps/backend/src/domain/entities/subscription.entity.ts](../../apps/backend/src/domain/entities/subscription.entity.ts) +- [apps/backend/src/domain/value-objects/subscription-plan.vo.ts](../../apps/backend/src/domain/value-objects/subscription-plan.vo.ts) +- [apps/backend/src/application/controllers/subscriptions.controller.ts](../../apps/backend/src/application/controllers/subscriptions.controller.ts) + +## Exploitability Analysis + +Conservation locale de l'offre payante et de ses avantages malgré la résiliation externe. Les prérequis sont un abonnement réellement lié, des licences surnuméraires et l'arrivée de l'événement. Aucun traitement réel Stripe en production n'a été observé. Distinguer ce défaut de SEC-18, où le statut est enregistré mais ignoré par les consommateurs. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/services/subscription-cancellation.spec.ts](../../apps/backend/src/application/services/subscription-cancellation.spec.ts) +- [apps/backend/src/domain/entities/subscription.entity.spec.ts](../../apps/backend/src/domain/entities/subscription.entity.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi : `cancel()` applique Bronze/CANCELED indépendamment du nombre de licences, et une erreur de traitement webhook renvoie une erreur HTTP afin de permettre une nouvelle livraison. Aucun effacement de comptes surnuméraires n'est nécessaire à cette révocation. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +La résiliation peut conserver les avantages payants est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/secret-smtp-versionne/secret-smtp-versionne.md b/audit_security/secret-smtp-versionne/secret-smtp-versionne.md new file mode 100644 index 0000000..a5f7de6 --- /dev/null +++ b/audit_security/secret-smtp-versionne/secret-smtp-versionne.md @@ -0,0 +1,45 @@ +# SEC-10 — Une clé SMTP figure dans un fichier suivi + +**Gravité historique : Moyenne.** Classification : CWE-798. + +**État au 14 septembre 2026 :** Littéral retiré du fichier courant et remplacé par une variable obligatoire, correction suivie dans Git. État opérationnel NON SOLDÉ : révocation/rotation chez le fournisseur non vérifiée. L'historique contient toujours l'ancienne configuration ; ne pas copier sa valeur dans des tickets, commandes ou captures. + +## Executive Summary + +Toute personne obtenant une copie du dépôt ou de sa configuration suivie peut lire un identifiant SMTP présent en clair dans l'ancien `docker/docker-compose.full.yml`. La fiche ne reproduit jamais sa valeur et n'a pas tenté de l'utiliser. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Un dépôt, même privé, est copié et conservé indépendamment du cycle de vie d’un secret. Le droit de lire le code ne doit pas devenir un droit d’utiliser le compte fournisseur. + +## Vulnerability Details + +La configuration de la pile de développement injectait un `SMTP_PASS` littéral à côté d'un fournisseur externe et d'un compte SMTP concret. Un secret de fournisseur était ainsi distribué avec le code. Le fait que le fichier serve au développement ne prouve ni que le credential est fictif, ni qu'il reste valide. La suppression dans le dernier snapshot ne supprime pas les anciens commits, clones, archives ou caches. + + + +Sources à examiner ensemble : + +- [docker/docker-compose.full.yml](../../docker/docker-compose.full.yml) + +## Exploitability Analysis + +Possibilité d'envoi sous les droits du compte SMTP si le fournisseur accepte encore ce credential ; le quota, les permissions et la validité ne sont pas connus. Aucun email usurpé, accès à une boîte mail ou compromission du compte fournisseur n'est démontré. Le constat sûr est la présence historique d'une valeur ressemblant à un secret opérationnel dans un fichier suivi. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Aucun test d'utilisation du credential n'a été lancé : seul le code/configuration est examiné. Une tentative d'authentification fournisseur ne serait pas une vérification documentaire. + +## Remediation + +Littéral retiré du fichier courant et remplacé par une variable obligatoire, correction suivie dans Git. État opérationnel NON SOLDÉ : révocation/rotation chez le fournisseur non vérifiée. L'historique contient toujours l'ancienne configuration ; ne pas copier sa valeur dans des tickets, commandes ou captures. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Une clé SMTP figure dans un fichier suivi est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/secrets-dans-les-journaux/secrets-dans-les-journaux.md b/audit_security/secrets-dans-les-journaux/secrets-dans-les-journaux.md new file mode 100644 index 0000000..986bbce --- /dev/null +++ b/audit_security/secrets-dans-les-journaux/secrets-dans-les-journaux.md @@ -0,0 +1,66 @@ +# SEC-07 — Les logs contiennent mots de passe et invitations + +> **Mise à jour du 17 septembre 2026 :** voir le [compte rendu de correction](../CORRECTIONS-2026-09-17.md). Le texte ci-dessous conserve le constat avant cette passe. Les chemins de fuite décrits sont corrigés localement ; les anciennes copies de secrets restent à traiter. + + +**Gravité historique : Moyenne.** Classification : CWE-532. + +**État au 14 septembre 2026 :** PARTIELLEMENT CORRIGÉ. Les traces de mot de passe et d'invitation ciblées ont été retirées dans le code suivi ; les interpolations de tokens transporteur restent présentes. Aucun nouveau correctif n'est effectué pendant cette passe documentaire. La rotation des secrets historiquement exposés et le traitement des copies de logs restent à confirmer. + +## Executive Summary + +Le lecteur des journaux applicatifs n'est pas nécessairement autorisé à se connecter sous l'identité d'un utilisateur ni à décider à la place d'un transporteur. L'attaquant doit déjà obtenir l'accès aux logs ; aucune exposition publique des journaux de production n'est démontrée. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Les logs sont destinés à l’exploitation et peuvent avoir une rétention ou des lecteurs différents des données métier. Les filtres sur champs structurés n’inspectent pas nécessairement les valeurs incorporées à une chaîne libre. + +## Vulnerability Details + +Historiquement, `UsersController.createUser` inscrivait le mot de passe temporaire en clair avec l'adresse email après stockage du hash Argon2. `InvitationService.sendInvitationEmail` inscrivait l'URL contenant le token d'inscription. La redaction Pino des champs structurés `req.body.password` ne supprime pas une valeur interpolée dans le texte d'un message. La relecture actuelle montre une autre occurrence de la même cause : `CsvBookingService.getDocumentsForCarrier` journalise `${token}` avant vérification du statut et du mot de passe ; `acceptBooking` et `rejectBooking` le journalisent avant résolution. Un accès documentaire tenté trop tôt peut donc inscrire un token d'une réservation encore PENDING ; si un lecteur récupère ce token valide, les routes publiques de décision peuvent l'accepter. Un appel normal après acceptation peut aussi journaliser un token déjà inutilisable pour une seconde décision : l'exploitation n'est pas automatique. + +Extrait historique vérifié, `apps/backend/src/application/controllers/users.controller.ts`, lignes 163–166 du snapshot préaudit : + +```typescript + // TODO: Send invitation email with temporary password + this.logger.warn( + `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` + ); +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/users.controller.ts](../../apps/backend/src/application/controllers/users.controller.ts) +- [apps/backend/src/application/services/invitation.service.ts](../../apps/backend/src/application/services/invitation.service.ts) +- [apps/backend/src/app.module.ts](../../apps/backend/src/app.module.ts) + +**Preuve actuelle complémentaire :** [apps/backend/src/application/services/csv-booking.service.ts](../../apps/backend/src/application/services/csv-booking.service.ts), `getDocumentsForCarrier` ligne 717, `acceptBooking` ligne 887 et `rejectBooking` ligne 961 contiennent encore des messages interpolant le token. Ces lignes sont celles de l'état local du 14 septembre. Cette observation élargit le constat historique au risque résiduel, sans prétendre que les tests mot de passe/invitation couvrent ces trois méthodes. + +## Exploitability Analysis + +Les anciens mots de passe ou invitations valides donnaient une autorité de connexion. Les tokens transporteur résiduels donnent uniquement l'autorité associée au token, sous les contraintes du domaine. Ni la collecte effective des logs, ni leur rétention, ni un détournement réel n'ont été observés. La fuite vers le logger est présente dans le code actuel ; la fenêtre d'usage abusive dépend d'un token encore valide et de l'accès du lecteur aux journaux. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/users.security.spec.ts](../../apps/backend/src/application/controllers/users.security.spec.ts) +- [apps/backend/src/application/services/invitation.security.spec.ts](../../apps/backend/src/application/services/invitation.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +PARTIELLEMENT CORRIGÉ. Les traces de mot de passe et d'invitation ciblées ont été retirées dans le code suivi ; les interpolations de tokens transporteur restent présentes. Aucun nouveau correctif n'est effectué pendant cette passe documentaire. La rotation des secrets historiquement exposés et le traitement des copies de logs restent à confirmer. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Les logs contiennent mots de passe et invitations est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/sessions-apres-reset-mot-de-passe/sessions-apres-reset-mot-de-passe.md b/audit_security/sessions-apres-reset-mot-de-passe/sessions-apres-reset-mot-de-passe.md new file mode 100644 index 0000000..b4f4373 --- /dev/null +++ b/audit_security/sessions-apres-reset-mot-de-passe/sessions-apres-reset-mot-de-passe.md @@ -0,0 +1,62 @@ +# SEC-08 — Le changement de mot de passe conserve les anciennes sessions + +**Gravité historique : Moyenne.** Classification : CWE-613. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi. Access et refresh portent une `credentialVersion` calculée par HMAC sur l'identifiant et le hash courant du mot de passe, sous JWT_SECRET. `validateUser` recalcule cette valeur ; un changement de hash invalide les anciennes sessions. Le hash du mot de passe n'est pas publié dans le JWT. Les anciens tokens sans ce champ nécessitent une nouvelle connexion. + +## Executive Summary + +Un attaquant possède déjà un refresh token volé avant que la victime réinitialise son mot de passe. Le problème concerne la sortie d'un incident de session compromise, pas la robustesse du générateur de token de réinitialisation. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +La récupération du compte remplace le credential de connexion. Les sessions déjà émises doivent être évaluées par rapport à cette nouvelle version, sans confondre ce contrôle avec la validation du lien de récupération. + +## Vulnerability Details + +`resetPassword` vérifie le token de récupération, son hash, sa date d'expiration et son usage, puis remplace le hash du mot de passe. `refreshAccessToken` vérifiait seulement signature, type refresh, blacklist de déconnexion et utilisateur actif. Aucun lien n'existait entre le jeton précédent et le nouveau credential. Un refresh antérieur encore valide pouvait donc produire de nouveaux tokens après récupération du compte. Les contrôles sur le lien de réinitialisation ne répondent pas à cette révocation de session. + +Extrait historique vérifié, `apps/backend/src/application/auth/auth.service.ts`, lignes 386–392 du snapshot préaudit : + +```typescript + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); + +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/auth/auth.service.ts](../../apps/backend/src/application/auth/auth.service.ts) +- [apps/backend/src/domain/entities/user.entity.ts](../../apps/backend/src/domain/entities/user.entity.ts) + +## Exploitability Analysis + +Maintien de l'accès déjà compromis malgré un changement de mot de passe réussi ; le renouvellement peut prolonger cet accès. Cela ne démontre pas comment le premier token a été volé, ni un contournement des contrôles du lien de reset. L'impact dépend de la durée de validité et de l'absence d'une révocation distincte. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/auth/auth-session.spec.ts](../../apps/backend/src/application/auth/auth-session.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi. Access et refresh portent une `credentialVersion` calculée par HMAC sur l'identifiant et le hash courant du mot de passe, sous JWT_SECRET. `validateUser` recalcule cette valeur ; un changement de hash invalide les anciennes sessions. Le hash du mot de passe n'est pas publié dans le JWT. Les anciens tokens sans ce champ nécessitent une nouvelle connexion. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Le changement de mot de passe conserve les anciennes sessions est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/sessions-websocket/sessions-websocket.md b/audit_security/sessions-websocket/sessions-websocket.md new file mode 100644 index 0000000..0856ad1 --- /dev/null +++ b/audit_security/sessions-websocket/sessions-websocket.md @@ -0,0 +1,59 @@ +# SEC-03 — Les WebSockets acceptent des sessions révoquées ou désactivées + +**Gravité historique : Moyenne.** Classification : CWE-287. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi : stratégie commune vérifiant type access, utilisateur actif et version de credentials. Les messages et les émissions sortantes réauthentifient la connexion. Un changement de mot de passe est également couvert par SEC-08. + +## Executive Summary + +Le détenteur d'un JWT encore signé et non expiré tente de rejoindre le canal Socket.IO de notifications après désactivation du compte, ou réemploie un refresh token révoqué pour le renouvellement HTTP. Il possède déjà ce jeton : ce n'est pas une falsification de signature. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Un jeton peut être signé correctement tout en représentant un type de session inadapté ou un compte devenu inactif. L’authentification du transport doit rester cohérente avec celle des requêtes HTTP. + +## Vulnerability Details + +L'ancien `NotificationsGateway.handleConnection` se limite à `jwtService.verifyAsync(token)`, extrait `payload.sub` et rejoint la salle de cet utilisateur. La clé de signature est partagée avec les jetons HTTP. Le gateway ne requiert pas le type `access`, ne consulte pas le compte actif et ne réutilise pas la stratégie JWT HTTP. Il transmet le compteur puis les notifications récentes. Une connexion déjà ouverte ne réévalue pas non plus l'expiration avant chaque utilisation. La validité cryptographique d'un JWT était confondue avec le droit actuel d'utiliser cette surface. + +Extrait historique vérifié, `apps/backend/src/application/gateways/notifications.gateway.ts`, lignes 60–61 du snapshot préaudit : + +```typescript + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/gateways/notifications.gateway.ts](../../apps/backend/src/application/gateways/notifications.gateway.ts) +- [apps/backend/src/application/notifications/notifications.module.ts](../../apps/backend/src/application/notifications/notifications.module.ts) +- [apps/backend/src/application/auth/auth.service.ts](../../apps/backend/src/application/auth/auth.service.ts) +- [apps/backend/src/application/auth/jwt.strategy.ts](../../apps/backend/src/application/auth/jwt.strategy.ts) + +## Exploitability Analysis + +La frontière démontrée concerne les notifications, leurs messages et métadonnées. Elle ne prouve pas une prise de contrôle générale des routes REST. La signature et la date d'expiration restent vérifiées à la connexion initiale. La révocation d'un refresh token à la déconnexion ne signifie pas qu'un access token valide était lui aussi révoqué ; ne pas confondre ces politiques. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/gateways/notifications.gateway.spec.ts](../../apps/backend/src/application/gateways/notifications.gateway.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi : stratégie commune vérifiant type access, utilisateur actif et version de credentials. Les messages et les émissions sortantes réauthentifient la connexion. Un changement de mot de passe est également couvert par SEC-08. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Les WebSockets acceptent des sessions révoquées ou désactivées est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/smtp-tls-non-verifie/smtp-tls-non-verifie.md b/audit_security/smtp-tls-non-verifie/smtp-tls-non-verifie.md new file mode 100644 index 0000000..10db374 --- /dev/null +++ b/audit_security/smtp-tls-non-verifie/smtp-tls-non-verifie.md @@ -0,0 +1,48 @@ +# SEC-15 — SMTP : identité du serveur non vérifiée et STARTTLS facultatif + +**Gravité : Moyenne, avant correction.** + +**État au 14 septembre 2026 :** Corrigé dans le code suivi ; configuration de production à confirmer. + +## Executive Summary + +Un intermédiaire actif sur le trajet réseau entre le backend et le serveur SMTP pouvait présenter un certificat non approuvé. Lorsque le transport utilise STARTTLS plutôt que TLS implicite, il pouvait aussi supprimer ou refuser l'annonce STARTTLS. Il faut contrôler le réseau ou le serveur contacté ; un simple utilisateur du site n'obtient pas cette capacité. + +Le snapshot préaudit `8446f87` est la version vulnérable vérifiée ; le correctif est présent dans `c09b8be`. Aucun tag local ni version de production vérifiée ne permet d'annoncer une première release affectée ou une release déployée corrigée. La validation combine relecture du source et tests locaux documentés ; aucun incident réel n'est affirmé. + +## Background + +La frontière de sécurité est celle décrite par les prérequis ci-dessus. Le paramétrage fourni par le dépôt ne permet pas de connaître la topologie et les valeurs effectivement en ligne. Les preuves disponibles doivent donc être lues séparément des conditions de déploiement restant à vérifier. + +## Vulnerability Details + +`EmailAdapter.buildTransporter` construit l'unique transport Nodemailer. Dans le snapshot `8446f87`, l'option TLS est explicitement `rejectUnauthorized: false` et `requireTLS` est absent. Le nom SMTP reste configuré, mais sans validation de chaîne il ne suffit pas à authentifier le serveur. Pour un port en mode STARTTLS avec `secure=false`, l'absence d'obligation de chiffrement laisse en outre possible une authentification en clair si le serveur n'offre pas STARTTLS. Les emails contiennent notamment des liens d'invitation et de récupération : l'identité du relais protège à la fois le credential SMTP et ces messages. + +Sources courantes, fonctions et tests concernés : + +- [apps/backend/src/infrastructure/email/email.adapter.ts](../../apps/backend/src/infrastructure/email/email.adapter.ts) +- [apps/backend/src/infrastructure/email/email.adapter.spec.ts](../../apps/backend/src/infrastructure/email/email.adapter.spec.ts) + +Pour comparer au snapshot vulnérable, consulter ces mêmes chemins dans la révision citée, sans supposer que les numéros de lignes actuels correspondent à l'ancienne version. + +## Exploitability Analysis + +La perte de confidentialité concerne les messages et l'authentification lorsque les prérequis réseau sont réunis. Aucun fournisseur SMTP réel n'a été contacté et aucun email n'a été intercepté. L'acceptation d'une chaîne de confiance arbitraire est établie par la configuration ; le test dynamique disponible démontre le refus du déclassement STARTTLS après correction, pas une interception TLS de bout en bout avant correction. + +Le problème ne requiert pas de supprimer les contrôles métier ou cryptographiques voisins. Il exploite précisément la différence entre le contrôle attendu et celui effectivement exécuté. Les contre-exemples ci-dessous précisent ce que les tests isolent ; ils ne constituent pas un test de pénétration du site en ligne. + +## Proof of Concept + +Quatre tests sont documentés dans la passe du 10 septembre. Un serveur éphémère loopback refusant STARTTLS est rejeté avant AUTH en production ; le contrôle explicitement en développement peut s'authentifier. Les options de certificat et le nom original sont inspectés, tandis que l'erreur de certificat SMTP est simulée. Aucun message réel n'est envoyé. + +Les artefacts sont déjà dans les fichiers de test liés ci-dessus. Aucun faux journal d'exploitation ni nouvelle commande d'attaque de production n'est fourni. Voir [VALIDATION.md](../VALIDATION.md) pour le périmètre et les résultats consolidés. + +## Remediation + +La version courante active `rejectUnauthorized: true`, conserve le nom original pour vérifier le certificat après résolution IP et exige `requireTLS` lorsque NODE_ENV vaut exactement `production`. Un environnement nommé autrement conserve la politique de développement concernant STARTTLS : vérifier les valeurs de déploiement sans supposer que le nom commercial « préproduction » configure automatiquement NODE_ENV. Le TLS implicite reste supporté. Le test local autorise explicitement le SMTP sans TLS en développement, ce qui n'est pas une politique à transposer en production. + +La présente passe documente ce changement antérieur ; elle n'ajoute aucun correctif applicatif et ne confirme pas son déploiement. + +## Summary + +Le mécanisme décrit est confirmé dans la révision vulnérable citée, et la portée du correctif local est bornée par les tests disponibles. Corrigé dans le code suivi ; configuration de production à confirmer. Les limites de couverture générale sont détaillées dans [COUVERTURE.md](../COUVERTURE.md). diff --git a/audit_security/stripe-session-organisation/stripe-session-organisation.md b/audit_security/stripe-session-organisation/stripe-session-organisation.md new file mode 100644 index 0000000..ecef8e0 --- /dev/null +++ b/audit_security/stripe-session-organisation/stripe-session-organisation.md @@ -0,0 +1,50 @@ +# SEC-17 — Stripe : session Checkout non liée à son organisation + +**Gravité : Moyenne, avant correction.** + +**État au 14 septembre 2026 :** Correctif local non commité au début de cette rédaction ; déploiement inconnu. + +## Executive Summary + +Un MANAGER connecté connaît l'identifiant d'une session Checkout créée pour une autre organisation. Il appelle `POST /api/v1/subscriptions/sync` avec ce sessionId. La session doit désigner un abonnement qui n'est pas déjà lié à une autre ligne locale protégée par l'unicité. La connaissance de cet identifiant est un prérequis ; aucune fuite générique n'est démontrée. + +Le commit `c09b8be` contient encore le comportement vulnérable ; le correctif examiné est dans les modifications locales du 14 septembre. Aucun tag local ni version de production vérifiée ne permet d'annoncer une première release affectée ou une release déployée corrigée. La validation combine relecture du source et tests locaux documentés ; aucun incident réel n'est affirmé. + +## Background + +La frontière de sécurité est celle décrite par les prérequis ci-dessus. Le paramétrage fourni par le dépôt ne permet pas de connaître la topologie et les valeurs effectivement en ligne. Les preuves disponibles doivent donc être lues séparément des conditions de déploiement restant à vérifier. + +## Vulnerability Details + +Le contrôleur impose ADMIN/MANAGER et fournit l'organisation de la session authentifiée au service. `StripeAdapter.createCheckoutSession` écrit pourtant une métadonnée organizationId fiable, issue de l'appel serveur. L'ancien `SubscriptionService.syncFromStripe` récupérait la session Stripe, utilisait ses identifiants customer/subscription puis mettait à jour l'offre locale sans comparer cette métadonnée. Le succès de la récupération chez Stripe prouve l'existence de l'objet, pas son appartenance à l'appelant. La contrainte UNIQUE stripe_subscription_id interdit un rattachement déjà enregistré, mais ne protège pas avant livraison du webhook, après son échec ou pour un ancien identifiant libéré lors d'un changement d'offre. + +Sources courantes, fonctions et tests concernés : + +- [apps/backend/src/application/controllers/subscriptions.controller.ts](../../apps/backend/src/application/controllers/subscriptions.controller.ts) +- [apps/backend/src/application/services/subscription.service.ts](../../apps/backend/src/application/services/subscription.service.ts) +- [apps/backend/src/infrastructure/stripe/stripe.adapter.ts](../../apps/backend/src/infrastructure/stripe/stripe.adapter.ts) +- [apps/backend/src/application/services/subscription-sync.security.spec.ts](../../apps/backend/src/application/services/subscription-sync.security.spec.ts) + +Pour comparer au snapshot vulnérable, consulter ces mêmes chemins dans la révision citée, sans supposer que les numéros de lignes actuels correspondent à l'ancienne version. + +## Exploitability Analysis + +Attribution locale d'une offre et d'identifiants de facturation d'un autre tenant à l'organisation appelante dans la fenêtre décrite. Aucun vol de carte bancaire, accès au compte Stripe global ou exploitation fiable de course en production n'est établi. La menace exige un identifiant Checkout étranger connu et l'absence de conflit d'unicité ; ces conditions expliquent la gravité moyenne retenue. + +Le problème ne requiert pas de supprimer les contrôles métier ou cryptographiques voisins. Il exploite précisément la différence entre le contrôle attendu et celui effectivement exécuté. Les contre-exemples ci-dessous précisent ce que les tests isolent ; ils ne constituent pas un test de pénétration du site en ligne. + +## Proof of Concept + +Avant correction, deux tests malveillants échouaient parce que le service résolvait la promesse et persistait GOLD ; le contrôle même organisation réussissait. Après correction, six tests passent : métadonnées étrangères ou absentes, customer identique mais organisation étrangère, première synchronisation légitime, changement d'abonnement légitime et rafraîchissement sans session. Stripe est simulé ; la contrainte d'unicité n'est pas testée par une course contre une vraie base. + +Les artefacts sont déjà dans les fichiers de test liés ci-dessus. Aucun faux journal d'exploitation ni nouvelle commande d'attaque de production n'est fourni. Voir [VALIDATION.md](../VALIDATION.md) pour le périmètre et les résultats consolidés. + +## Remediation + +La comparaison `checkoutSession.metadata?.organizationId !== organizationId` provoque désormais un refus avant de consommer les identifiants Stripe. Les métadonnées absentes sont également refusées. On n'impose pas une égalité stricte avec un ancien customerId local : plusieurs sessions concurrentes d'une même organisation peuvent légitimement exister. La synchronisation sans session conserve son comportement en utilisant l'abonnement local déjà lié. Ce problème est distinct d'une signature webhook invalide, déjà contrôlée ailleurs. + +La présente passe documente ce changement antérieur ; elle n'ajoute aucun correctif applicatif et ne confirme pas son déploiement. + +## Summary + +Le mécanisme décrit est confirmé dans la révision vulnérable citée, et la portée du correctif local est bornée par les tests disponibles. Correctif local non commité au début de cette rédaction ; déploiement inconnu. Les limites de couverture générale sont détaillées dans [COUVERTURE.md](../COUVERTURE.md). diff --git a/audit_security/televersements-memoire/televersements-memoire.md b/audit_security/televersements-memoire/televersements-memoire.md new file mode 100644 index 0000000..0bc612b --- /dev/null +++ b/audit_security/televersements-memoire/televersements-memoire.md @@ -0,0 +1,61 @@ +# SEC-09 — Les téléversements ne bornent pas la mémoire utilisée + +**Gravité historique : Moyenne.** Classification : CWE-400. + +**État au 14 septembre 2026 :** Corrigé au niveau des intercepteurs suivis : limite de 10 Mio par fichier et bornes sur fichiers, champs et parties. Ce n'est pas une preuve de résistance globale à des uploads concurrents : le stockage reste en mémoire, et 10 fichiers autorisés peuvent représenter environ 100 Mio de contenu avant surcoût par requête. Le plafond du proxy et les limites de concurrence restent à vérifier. + +## Executive Summary + +Un compte authentifié peut envoyer un document multipart très volumineux sur les routes CSV de création, ajout ou remplacement. Les rôles applicables ont depuis été restreints ; cela ne remplace pas une limite de taille par requête. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +Les intercepteurs multipart lisent le flux avant le handler. La protection mémoire doit donc se situer à cette étape ou en amont, plutôt que dans le contrôle métier après réception. + +## Vulnerability Details + +Les anciens FilesInterceptor limitaient le nombre de fichiers mais pas `fileSize`. En l'absence d'autre stockage configuré, Multer utilise le stockage mémoire et bufferise le fichier avant de le transmettre au contrôleur. Le quota d'expéditions ou la vérification du propriétaire dans le handler arrivent après cette étape. Le throttling limite le nombre de requêtes, pas les octets d'une seule requête. La configuration théorique d'une taille de document ailleurs dans le dépôt ne suffit pas si elle n'est pas transmise à l'intercepteur. + +Extrait historique vérifié, `apps/backend/src/application/controllers/csv-bookings.controller.ts`, lignes 86–88 du snapshot préaudit : + +```typescript + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/backend/src/application/controllers/csv-bookings.controller.ts](../../apps/backend/src/application/controllers/csv-bookings.controller.ts) +- [apps/backend/src/application/csv-bookings/csv-bookings.module.ts](../../apps/backend/src/application/csv-bookings/csv-bookings.module.ts) +- [apps/backend/src/infrastructure/security/security.config.ts](../../apps/backend/src/infrastructure/security/security.config.ts) + +Les dépendances locales relues sont **Multer 2.0.2** et **Busboy 1.6.0**. Le constructeur Multer choisit `memoryStorage()` lorsqu’aucun storage/dest n’est défini ; `storage/memory.js` concatène le flux dans un Buffer. Busboy utilise une limite de taille infinie quand `limits.fileSize` n’est pas fourni. Ces versions et chemins installés sont vérifiés sans audit externe des dépendances. + +## Exploitability Analysis + +Pression mémoire susceptible de ralentir ou arrêter le processus Node et d'affecter les autres utilisateurs. Aucun crash, consommation maximale ou attaque de charge n'a été exécuté. Un proxy peut réduire la portée en plafonnant les corps HTTP ; sa configuration effective en production n'a pas été vérifiée. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/backend/src/application/controllers/csv-bookings.security.spec.ts](../../apps/backend/src/application/controllers/csv-bookings.security.spec.ts) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé au niveau des intercepteurs suivis : limite de 10 Mio par fichier et bornes sur fichiers, champs et parties. Ce n'est pas une preuve de résistance globale à des uploads concurrents : le stockage reste en mémoire, et 10 fichiers autorisés peuvent représenter environ 100 Mio de contenu avant surcoût par requête. Le plafond du proxy et les limites de concurrence restent à vérifier. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +Les téléversements ne bornent pas la mémoire utilisée est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/audit_security/webhooks-destination-sortante/webhooks-destination-sortante.md b/audit_security/webhooks-destination-sortante/webhooks-destination-sortante.md new file mode 100644 index 0000000..c90fa00 --- /dev/null +++ b/audit_security/webhooks-destination-sortante/webhooks-destination-sortante.md @@ -0,0 +1,33 @@ +# OBS-02 — Destinations webhook sans politique réseau démontrée + +> **Mise à jour du 17 septembre 2026 :** voir le [compte rendu de correction](../CORRECTIONS-2026-09-17.md). Le texte ci-dessous conserve le constat avant cette passe. Deux tests du pipe réel confirment le rejet actuel des URL en création et modification ; aucune voie SSRF complète n’est établie. + + +**Statut : hypothèse d'exploitation non confirmée ; chemin de configuration actuellement contrarié par la validation des DTO.** Aucun SSRF exploitable depuis l'API n'est affirmé. SSRF signifie qu'un utilisateur fait émettre au serveur une requête vers une destination qu'il ne devrait pas pouvoir joindre. + +## Destination sensible + +`WebhookService` appelle `httpService.post(webhook.url, payload, {headers, timeout:10000})`, ligne 209 du fichier courant. La signature HMAC sert à authentifier le message auprès du destinataire ; elle ne garantit pas que ce destinataire est autorisé. Le délai de dix secondes borne une tentative mais n'interdit pas une adresse interne. Le chemin inspecté n'applique pas de politique d'adresse IP ou de destination avant l'envoi. + +Une URL contrôlée qui atteindrait ce stockage puis un événement déclencheur pourrait ainsi faire contacter une destination interne accessible au backend. Il reste à vérifier les résolutions DNS, redirections et contraintes réseau du déploiement ; aucun accès à une adresse interne ou à un service de métadonnées n'a été testé. + +## Pourquoi ce n'est pas un SSRF confirmé + +Les classes `CreateWebhookDto` et `UpdateWebhookDto`, dans `webhooks.controller.ts` lignes 27–39, déclarent des propriétés TypeScript sans décorateurs de validation. `main.ts` active `I18nValidationPipe` avec `whitelist:true` et `forbidNonWhitelisted:true`. Dans ce chemin, les champs non déclarés à la validation sont rejetés, ce qui empêche d'assumer qu'un MANAGER peut enregistrer librement une URL depuis le contrôleur. + +Les routes exigent ADMIN/MANAGER. Le fait qu'un service possède un paramètre URL ne prouve pas qu'un attaquant peut le renseigner dans la version courante. L'existence d'anciens webhooks en base, d'un import ou d'une autre voie d'écriture reste inconnue. Le rapport initial a donc écarté la présentation d'une exploitation confirmée par enregistrement API. + +## Risque lors d'un changement futur + +Ajouter les décorateurs manquants aux DTO peut rendre la création fonctionnelle et ouvrir simultanément le chemin vers l'envoi HTTP. Il faut alors analyser ensemble validation fonctionnelle et politique de destinations ; une correction de formulaire isolée peut retirer l'obstacle actuel sans résoudre le risque réseau. + +## Vérifications à effectuer + +1. Tester la création et la modification via une application locale possédant le véritable pipe global ; ne pas appeler directement le service en prétendant avoir validé la route HTTP. +2. Inventorier les autres écritures du repository et les données préexistantes sans publier les URL sensibles. +3. Si une voie d'entrée est confirmée, vérifier avec des serveurs locaux les restrictions d'adresses, la résolution DNS et chaque redirection avant d'attribuer une gravité. +4. Préserver un endpoint public autorisé comme contrôle positif. + +Aucune requête réseau nouvelle ni correction n'a été effectuée pour cette fiche. + +Sources : [contrôleur](../../apps/backend/src/application/controllers/webhooks.controller.ts), [service](../../apps/backend/src/application/services/webhook.service.ts), [validation globale](../../apps/backend/src/main.ts). diff --git a/audit_security/xss-redirection-connexion/xss-redirection-connexion.md b/audit_security/xss-redirection-connexion/xss-redirection-connexion.md new file mode 100644 index 0000000..3bc5d46 --- /dev/null +++ b/audit_security/xss-redirection-connexion/xss-redirection-connexion.md @@ -0,0 +1,64 @@ +# SEC-01 — La redirection de connexion permet une XSS DOM + +**Gravité historique : Élevée.** Classification : CWE-79. + +**État au 14 septembre 2026 :** Corrigé dans le code suivi de `check_secu`. `safeLoginRedirect` n'accepte que les chemins internes commençant par un seul slash et rejette antislashs et caractères de contrôle ; la validation est appliquée par le contexte actif. Déploiement inconnu. + +## Executive Summary + +Un attaquant sans compte peut envoyer à une victime un lien de connexion contenant un paramètre `redirect` malveillant. La victime doit ouvrir ce lien et réussir sa connexion. Le contexte actif utilise des cookies HttpOnly : il ne faut donc pas décrire ce problème comme une lecture automatique de jetons dans localStorage. + +Cette fiche repose sur la relecture du code historique accessible et du correctif courant, ainsi que des preuves documentées lors des passes précédentes. Elle ne constate aucun incident réel. La plus ancienne version ici vérifiée est le snapshot `8446f87` ; les corrections historiques figurent dans `c09b8be`. Aucun tag local ni version déployée n'a permis d'établir une première release vulnérable ou corrigée. Voir [METHODOLOGIE.md](../METHODOLOGIE.md). + +## Background + +La destination fournie par un lien de connexion est une donnée non fiable. Seule une navigation interne au produit doit être permise une fois la session ouverte ; HttpOnly protège la lecture du cookie, pas toutes les actions d’un script de même origine. + +## Vulnerability Details + +La page `app/[locale]/login/page.tsx` lit `searchParams.get('redirect')`, puis transmet cette valeur au `login` du contexte. Après `apiLogin` et le chargement du profil, `AuthProvider` passe la destination directement à `router.push`. Le contrôle manquant est une validation de destination au moment où la session devient active. Un schéma actif comme `javascript:` ne doit jamais être traité comme une navigation métier. Le rapport initial a inspecté le chemin dans Next installé ; il ne contient pas de démonstration d'exécution dans un navigateur réel. La fiche conserve donc la distinction entre chemin de code confirmé et exécution navigateur non reproduite. + +Extrait historique vérifié, `apps/frontend/src/lib/context/auth-context.tsx`, lignes 105–110 du snapshot préaudit : + +```tsx + try { + await apiLogin({ email, password, rememberMe }); + // Fetch complete user profile after login (session lives in httpOnly cookies) + const currentUser = await getCurrentUser(); + setUser(currentUser); + router.push(redirectTo); +``` + +Les numéros ci-dessus décrivent le snapshot ancien ; les liens suivants ouvrent les fichiers courants, où les lignes peuvent avoir changé. + +Sources à examiner ensemble : + +- [apps/frontend/app/[locale]/login/page.tsx](../../apps/frontend/app/%5Blocale%5D/login/page.tsx) +- [apps/frontend/src/lib/context/auth-context.tsx](../../apps/frontend/src/lib/context/auth-context.tsx) + +La dépendance locale relue pendant cette rédaction est **Next 14.2.35**. Dans `dist/client/components/app-router.js`, `useNavigate` construit une URL ; le reducer de navigation envoie les URLs externes vers `handleExternalUrl`, puis le mode MPA utilise `window.location.assign(canonicalUrl)` ou `replace`. C’est le parcours source qui soutient le risque de schéma actif. La version installée actuelle est vérifiée, mais aucune plage de releases Next vulnérables n’est déduite de ce constat applicatif. + +## Exploitability Analysis + +L'impact attendu est l'exécution d'un script dans le contexte du site après connexion, sous réserve du comportement réel du navigateur et du routage. Des requêtes authentifiées seraient alors possibles même si le script ne peut pas lire le cookie HttpOnly. Aucun vol effectif de données, contournement de mot de passe ou exploitation sans interaction de la victime n'est démontré. Gravité historique élevée en raison du contexte authentifié atteint. + +Le code applicatif établit le chemin décrit, mais ne renseigne pas les protections effectives d'un déploiement donné, sa version en ligne ou les accès déjà exercés par un attaquant. La gravité ci-dessus est celle du mécanisme vulnérable avant correction ; elle n'est pas un score de risque résiduel calculé pour la production. + +## Proof of Concept + +Tests de non-régression existants, inspectables dans le dépôt : + +- [apps/frontend/src/lib/safe-login-redirect.test.ts](../../apps/frontend/src/lib/safe-login-redirect.test.ts) +- [apps/frontend/src/lib/context/auth-context.test.tsx](../../apps/frontend/src/lib/context/auth-context.test.tsx) + +Ils testent les refus et les usages autorisés avec des données locales. Ils ne prouvent pas qu'une ancienne exploitation a eu lieu en production. Les commandes et résultats consolidés sont dans [VALIDATION.md](../VALIDATION.md) ; aucun nouvel exploit n'a été exécuté pendant la rédaction. + +## Remediation + +Corrigé dans le code suivi de `check_secu`. `safeLoginRedirect` n'accepte que les chemins internes commençant par un seul slash et rejette antislashs et caractères de contrôle ; la validation est appliquée par le contexte actif. Déploiement inconnu. + +Pour solder le constat en exploitation, vérifier le comportement sur la version effectivement déployée et conserver les contrôles légitimes décrits. La présente passe ajoute uniquement de la documentation ; les correctifs mentionnés existaient avant sa rédaction. + +## Summary + +La redirection de connexion permet une XSS DOM est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml index b031e76..8d9fddf 100644 --- a/docker/docker-compose.full.yml +++ b/docker/docker-compose.full.yml @@ -134,7 +134,7 @@ services: SMTP_HOST: smtp-relay.brevo.com SMTP_PORT: 587 SMTP_USER: 9637ef001@smtp-brevo.com - SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu + SMTP_PASS: ${SMTP_PASS:?Set SMTP_PASS in the deployment environment} SMTP_SECURE: "false" SMTP_FROM: noreply@xpeditis.com networks: diff --git a/docker/stack-portainer-preprod.yaml b/docker/stack-portainer-preprod.yaml index 8fbbe64..5b0ae05 100644 --- a/docker/stack-portainer-preprod.yaml +++ b/docker/stack-portainer-preprod.yaml @@ -214,6 +214,7 @@ services: STRIPE_PRO_YEARLY_PRICE_ID: "price_1SrItG4atifoBlu1CiSKold0" STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: "price_1SrNj94atifoBlu1F6axOXrR" STRIPE_ENTERPRISE_YEARLY_PRICE_ID: "price_1SrNiA4atifoBlu11RJD0ocG" + OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY must be supplied at deployment} networks: - xpeditis_internal diff --git a/docs/security/check-secu/REMEDIATION-2026-09-10.md b/docs/security/check-secu/REMEDIATION-2026-09-10.md new file mode 100644 index 0000000..f129f30 --- /dev/null +++ b/docs/security/check-secu/REMEDIATION-2026-09-10.md @@ -0,0 +1,61 @@ +# Corrections supplémentaires — 10 septembre 2026 + +Branche `check_secu`. Les modifications antérieures sont conservées. Deux problèmes supplémentaires ont été traités successivement ; aucun commit, déploiement, accès à une base réelle ou envoi d’email réel n’a été effectué. + +## 1. SMTP : certificat non vérifié et STARTTLS facultatif + +**Résultat local : corrigé (`fixed`).** + +L’unique transport Nodemailer désactivait `rejectUnauthorized`. La production et la préproduction utilisent le port 587 avec `SMTP_SECURE=false`, donc STARTTLS plutôt que TLS implicite. Sans `requireTLS`, un serveur ou intermédiaire refusant STARTTLS pouvait conduire à une authentification sans chiffrement. Les messages concernés comprennent les liens de réinitialisation et d’invitation. + +Le correctif dans `apps/backend/src/infrastructure/email/email.adapter.ts` active la vérification des certificats et impose STARTTLS lorsque `NODE_ENV=production`. Le nom SMTP d’origine reste utilisé pour vérifier le certificat après résolution de l’adresse IP. Le TLS implicite et les serveurs SMTP locaux de développement sans TLS restent supportés ; cette exception de développement ne doit pas servir en production. + +Preuve : `email.adapter.spec.ts` contient quatre tests. Un serveur SMTP éphémère sur loopback, qui n’annonce pas STARTTLS et refuse sa commande, est rejeté avant AUTH. Le contrôle local explicitement sans TLS peut s’authentifier sur ce même serveur simulé. Les options de vérification de certificat, le nom d’origine, le TLS implicite et la propagation d’un échec d’envoi sont vérifiés. Aucun email n’est transmis. + +Limite : le rejet d’un certificat SMTP invalide est vérifié via les options de transport et une erreur d’envoi simulée, pas par une connexion au fournisseur réel. Une investigation indépendante et une revue du correctif n’ont retenu aucun contournement confirmé. + +## 2. PostgreSQL : paramètres TLS incohérents entre clients + +**Résultat local : corrigé (`fixed`). Déploiement conditionné à la configuration de confiance.** + +L’API et le script de démarrage ignoraient `DATABASE_SSL`, tandis que la CLI TypeORM acceptait les certificats non authentifiés. La configuration de production exige pourtant `hostssl`. Outre l’absence de vérification d’identité côté CLI, cette incohérence pouvait empêcher le démarrage de l’API face à la configuration PostgreSQL fournie. + +Une fonction commune `databaseTlsOptions` est désormais utilisée par : + +- la configuration TypeORM de l’API dans `app.module.ts` ; +- la source de données de la CLI TypeORM ; +- les clients de disponibilité et de migration de `scripts/setup/startup.js` ; +- le script `scripts/setup/run-migrations.js` ; +- le client du script d’entrypoint historique, bien que l’image actuelle utilise `startup.js`. + +Lorsque `DATABASE_SSL=true`, tous ces clients vérifient le certificat et l’identité de `DATABASE_HOST`, y compris une adresse IP. `DATABASE_SSL_CA` accepte le certificat public PEM de confiance pour le certificat auto-signé déjà généré par l’infrastructure. L’absence de CA spécifique conserve les autorités reconnues par Node ; elle n’entraîne jamais une désactivation de la vérification. Une valeur de drapeau invalide est refusée. Les chaînes majuscules/minuscules suivent le comportement de validation Joi. Le mode local explicitement sans TLS reste disponible. + +Les chemins des scripts ont également été alignés sur la racine backend : copie Docker à `/app/startup.js` et copie du dépôt sous `scripts/setup`. Les modules compilés, entités et migrations sont ainsi résolus au même endroit. Le chargement de `startup.js` depuis un test n’établit aucune connexion et ne lance pas de migration. + +Preuves : + +- 11 tests dans `database-tls.spec.ts` : drapeaux de configuration et véritables handshakes TLS locaux avec certificat de test éphémère. Le certificat approuvé avec le SAN correspondant à l’IP est accepté ; un certificat non approuvé ou une IP incorrecte sont refusés. +- 2 tests dans `database-startup.spec.ts` : mêmes paramètres SSL transmis au client de disponibilité et aux deux scripts de migration, dans les dispositions Docker et dépôt. PostgreSQL et TypeORM sont simulés, aucune migration réelle n’est exécutée. +- Chargement du script avec le helper réellement compilé : réussi, sans connexion. +- Investigation et revue indépendantes : aucun contournement ou régression confirmé. Le reviewer était limité par le sandbox pour ses handshakes ; les handshakes du parent ont été exécutés avec autorisation sur loopback et ont réussi. + +**Avant déploiement :** renseigner `DATABASE_SSL_CA` dans le Secret backend SOPS avec le certificat **public** de db-01 récupéré via un canal d’administration authentifié. Ne jamais copier sa clé privée. L’API et le Job de migration consomment ce même Secret. Le gabarit de secrets et le README de production décrivent cette préparation. Aucun certificat de production n’a été lu ou modifié. Ne pas déployer le nouveau client contre un certificat auto-signé sans avoir distribué cette confiance. + +Les scripts ponctuels de maintenance hors des chemins de démarrage n’ont pas tous été harmonisés ; leur revue reste à effectuer avant une utilisation sur une base TLS. + +## Vérifications finales + +- `npm test -- --runInBand` (backend) : **451 tests réussis**, 5 tests déjà ignorés, 32 suites réussies et 1 ignorée. Les tests réseau utilisent uniquement des serveurs éphémères sur `127.0.0.1`. +- `npm run build` (backend) : réussi. +- ESLint sur les fichiers TypeScript concernés : réussi. +- Vérifications de syntaxe Node des deux scripts de démarrage/migration : réussies. +- Vérification de syntaxe de l’entrypoint shell : réussie. +- `git diff --check` : réussi. + +Les essais ont détecté puis permis de corriger une configuration de test masquée par `NODE_ENV=test`, ainsi qu’un type TLS trop large pour les options TypeORM. Aucun contrôle de sécurité n’a été assoupli pour contourner ces échecs. + +## Analyse des dépendances en attente + +La tentative initiale de `npm audit --package-lock-only --omit=dev --json` n’a pas pu joindre le registre npm (`ENOTFOUND`). La demande d’accès réseau a ensuite été refusée par la validation automatique : la liste des dépendances et versions aurait été envoyée vers une destination externe non explicitement autorisée. + +Une demande d’autorisation est en attente. Aucun contournement ni nouvel envoi n’a été effectué. Cette analyse ne transmettrait ni le code source ni les fichiers `.env`. L’audit exhaustif du dépôt et des dépendances n’est donc toujours pas déclaré terminé. diff --git a/docs/security/check-secu/REMEDIATION-2026-09-14.md b/docs/security/check-secu/REMEDIATION-2026-09-14.md new file mode 100644 index 0000000..0436a77 --- /dev/null +++ b/docs/security/check-secu/REMEDIATION-2026-09-14.md @@ -0,0 +1,38 @@ +# Poursuite des corrections — 14 septembre 2026 + +Branche : `check_secu`, base de cette passe : `c09b8be`. +Cette passe traite deux problèmes ciblés. Elle ne constitue pas une nouvelle couverture exhaustive du dépôt et ne remplace pas les limites du rapport initial. + +## 1. Rattachement des sessions Stripe à leur organisation + +**Problème confirmé, correction vérifiée localement.** Un ADMIN/MANAGER pouvait présenter à `sync-from-stripe` une session Checkout appartenant à une autre organisation. Le service consommait ses identifiants sans comparer les métadonnées créées côté serveur avec l'organisation authentifiée. La contrainte d'unicité empêchait un double rattachement déjà enregistré, mais pas un abonnement avant traitement du webhook, après échec de celui-ci ou un ancien identifiant libéré. L'exploitation nécessite de connaître une session étrangère : aucune fuite de cet identifiant n'a été établie dans cette passe. + +Le service refuse désormais les métadonnées absentes ou étrangères avant de récupérer ou enregistrer l'abonnement Stripe. Les changements d'offre d'une même organisation et la synchronisation sans session restent possibles. + +Preuve : avant correction, deux tests de refus échouaient parce que la synchronisation réussissait et enregistrait l'offre GOLD. Après correction, les six tests ciblés passent. Une investigation indépendante puis une revue indépendante en lecture seule n'ont relevé aucun problème concret résiduel sur cette frontière. + +## 2. Droits payants conservés après suspension + +**Problème confirmé, correction vérifiée localement.** Plusieurs consommateurs utilisaient l'offre facturée sans tenir compte de son état : garde de fonctionnalités, clés API, quotas, frais, aperçu d'abonnement et déclarations de droits. Le garde acceptait également des fonctionnalités présentes dans un ancien jeton avant de consulter la base. + +`Subscription.accessPlan` centralise les droits courants : ACTIVE, TRIALING et PAST_DUE conservent l'offre payante conformément à la période de grâce existante ; UNPAID, PAUSED, INCOMPLETE, INCOMPLETE_EXPIRED et CANCELED retrouvent les droits Bronze. L'offre persistée et les données Stripe restent disponibles pour la facturation et la reprise. Les exceptions ADMIN existantes sont conservées, sans en ajouter aux clés API. + +Le garde consulte toujours les droits actuels. Les clés API existantes deviennent inutilisables dès que les droits API disparaissent et leur création est refusée. Les quotas de réservation, les frais, les claims de connexion, l'aperçu et la résolution MCP utilisent également les droits courants. Le catalogue MCP actuel ne déclare pas de fonctionnalité payante obligatoire : son correctif évite notamment d'annoncer une ancienne offre via `whoami` et prépare correctement les contrôles du registre. + +Preuve : avant correction, 12 cas de refus échouaient dans la matrice domaine/garde, contre 7 contrôles légitimes réussis. Après correction, cette matrice passe, ainsi que les tests de révocation/création des clés API, les contrôles MCP et le quota Bronze après suspension. Une investigation indépendante et une revue indépendante n'ont relevé aucun contournement concret dans cette correction. + +## Validation + +- Suite backend complète : **491 tests réussis, 5 ignorés**, 36 suites réussies, 1 ignorée. +- Compilation backend réussie. +- ESLint sur les fichiers modifiés réussi ; `git diff --check` réussi. +- Les tests utilisent des doubles de services et des serveurs locaux pour les vérifications HTTP/TLS existantes ; aucun appel réel Stripe, SMTP ou à la base de production. +- Aucun déploiement ni migration nécessaire pour ces changements. La vérification en environnement de préproduction avec Stripe reste à effectuer. + +## Limites et suites identifiées + +- L'audit exhaustif et la couverture des zones restantes ne sont pas terminés ; voir les rapports précédents. +- L'audit des dépendances reste en attente d'une autorisation explicite d'envoyer les noms et versions au registre npm, après le refus de la revue automatique précédente. Aucun nouvel envoi tenté. +- Les actions de production déjà documentées (rotation des secrets, révocation des liens exposés, configuration CA PostgreSQL) restent distinctes de ces corrections locales. +- Observation à analyser séparément : `CsvBookingService.resolveBookingFeeEur` retourne encore zéro en cas d'erreur de récupération de l'abonnement. Les préconditions et l'impact financier de ce comportement ne sont pas validés dans cette passe. +- Le dépassement de quota lève actuellement une exception métier qui n'hérite pas de `DomainException` ; sa présentation HTTP mérite une correction fonctionnelle distincte. Le refus avant création est vérifié. diff --git a/docs/security/check-secu/REMEDIATION.md b/docs/security/check-secu/REMEDIATION.md new file mode 100644 index 0000000..4076468 --- /dev/null +++ b/docs/security/check-secu/REMEDIATION.md @@ -0,0 +1,65 @@ +# Suivi des corrections de sécurité — 9 septembre 2026 + +Branche : `check_secu`, issue de `preparation_prod`, base `8446f879b676b303fdb2891388f88ff7e43f5fea`. + +Des correctifs ont été appliqués aux 14 constats du rapport initial. Ils sont locaux, non commités et non déployés. Cela ne constitue pas une attestation de sécurité complète du site. + +## Audit approfondi : échec, couverture incomplète + +Le rapport initial reste un audit statique partiel : 95 fichiers lus intégralement, plus des lectures ciblées. La tentative d’audit approfondi n’a renvoyé aucun manifeste de découverte réussi. Aucun nouveau scan réussi ni absence de vulnérabilités ne sont revendiqués. + +Erreur retournée par le coordinateur : + +> Deep Scan stopped after 3 consecutive unsuccessful discovery workers (limit: 3); last failure (transient_error): You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at 5:47 PM. +> This is a terminal failure of this logical Deep Scan; no successful discovery manifest was returned. + +Le coordinateur impose l’arrêt de cette tentative. Les corrections ci-dessous reposent sur les constats déjà conservés et leur vérification locale, pas sur une prétendue couverture exhaustive. Aucun nouveau candidat issu de cette tentative n’a été retourné. Son identifiant n’a pas été fourni dans la réponse d’échec ; son contexte durable n’a donc pas pu être relu par identifiant. + +## Corrections et preuves + +| Constat | Modification | Vérification / limite | +| --- | --- | --- | +| Modification inter-organisations | Toute cible étrangère est refusée à un acteur non ADMIN. | Tests MANAGER majuscule/minuscule, USER, VIEWER ; mises à jour propres et ADMIN conservées ; 404 conservé. | +| XSS après connexion | Redirections limitées aux chemins internes ; protocoles, doubles slashs, antislashs et contrôles refusés. | Tests du helper et du contexte React, avec destinations malveillantes et navigation localisée légitime. | +| Authentification WebSocket | Réutilisation de JwtStrategy ; type access, expiration et compte courant contrôlés à la connexion, aux messages et avant émission. | Tests refresh, type inconnu, expiration, désactivation après connexion et connexion valide. L’émission sortante a aussi été revue statiquement. | +| Notifications d’autres utilisateurs | UUID validés dans le service ; prédicat ORM `{ id, user_id }` ; propagation de l’utilisateur REST et WS. | Objets, tableaux, chaînes invalides rejetés ; mise à jour du propriétaire conservée, autre destinataire exclu. Test du prédicat avec repository simulé, sans PostgreSQL réel. | +| Jeton transporteur divulgué | Retrait du jeton du DTO client, du mapper et du type frontend. | Test du mapper ; données métier conservées ; appels d’email transporteur conservés. Les anciens jetons déjà exposés ne sont pas invalidés par cette suppression. | +| Mutations VIEWER | Rôles ADMIN/MANAGER/USER requis pour création, paiements, modifications et documents CSV. | Test HTTP : VIEWER refusé en création et lecture personnelle conservée. Les autres routes ont été vérifiées par inspection des gardes. | +| Liste organisation sans rôle | Rôles ADMIN/MANAGER requis pour les listes et statistiques globales de l’organisation. | Test HTTP refusant la liste globale à USER. | +| Secrets dans les logs | Suppression du mot de passe temporaire et du lien d’invitation ; journalisation par ID ; erreurs d’email sans contenu secret ; suppression des traces brutes de réservation. | Tests de création de compte et d’invitation, y compris exception contenant un token ; lien transmis au service d’email conservé, sans envoi réel. | +| Anciennes sessions après changement de mot de passe | Tokens liés par HMAC au hash courant du mot de passe ; vérification pour access et refresh. | Anciennes sessions rejetées après changement ; nouvelle connexion/refresh et modification de profil légitimes préservés. Aucun hash de mot de passe n’est mis dans le JWT. | +| Téléversements mémoire | Limites Multer : 10 Mio/fichier, nombre de fichiers, champs, parties et taille des champs. | Test HTTP d’un fichier dépassant la limite : 413 avant service ; petit fichier accepté. Pas de test de charge ni mesure de mémoire en production. | +| Manager modifiant un ADMIN | Refus de toute modification d’un compte ADMIN par un acteur non ADMIN. | Tests refus sans mutation/persistance et modification d’un utilisateur ordinaire autorisée. | +| Clé SMTP littérale | Remplacement par une variable obligatoire de déploiement dans `docker-compose.full.yml`. | Vérification statique ; valeur non reproduite dans les rapports. Pas de validation fournisseur ni révocation effectuée. | +| Formules CSV | Encodeur commun aux deux exports CSV, appliqué aux valeurs formatées et aux en-têtes. | Tests des préfixes de formule, espaces/contrôles, échappement et zéro ; exports XLSX existants préservés par leurs tests. | +| Résiliation conservant un plan payant | Transition de résiliation vers Bronze indépendante des plafonds et du statut précédent ; erreur HTTP en cas d’échec du webhook. | Tests état inactif, événement répété, nombre excessif de licences et échec de traitement non acquitté comme succès. Signature Stripe reste vérifiée par l’adaptateur existant ; Stripe réel non appelé. | + +## Stratégie et compatibilité + +Les corrections sont placées aux frontières partagées : contexte d’authentification frontend, validation de session backend, service/repository de notifications, gardes HTTP, sérialisation des réponses et transition de domaine de résiliation. Une investigation indépendante avant modification et une revue indépendante du diff ont été effectuées. La revue n’a retenu aucun contournement ou régression confirmé ; elle reste statique. + +La liaison des sessions utilise le hash déjà stocké et un HMAC avec le secret JWT, plutôt qu’une nouvelle colonne de base de données. Un changement de mot de passe change cette liaison. Les tokens antérieurs au correctif n’ont pas cette liaison et seront refusés : une reconnexion générale est attendue au déploiement. Une modification de profil ne révoque pas les sessions. + +Les chaînes CSV ressemblant à des nombres négatifs sont volontairement exportées comme texte afin de neutraliser les formules. Le chemin XLSX conserve ses valeurs typées. + +## Vérifications exécutées + +- Backend : `npm test -- --runInBand --testPathIgnorePatterns=csv-bookings.security.spec.ts` : 427 tests réussis, 5 déjà ignorés. Après ajout des tests de logs : `users.security.spec.ts` (3 réussis, dont 1 nouveau) et `invitation.security.spec.ts` (1 réussi). Total distinct vérifié hors HTTP : 429 tests. +- HTTP backend : `npm test -- --runInBand csv-bookings.security.spec.ts` : 5 tests réussis avec serveur éphémère sur `127.0.0.1`. Première tentative bloquée par le sandbox (`listen EPERM`), puis exécution autorisée réussie. +- Frontend : suites `safe-login-redirect.test.ts`, `auth-context.test.tsx`, `csv-cell.test.ts` et `export.test.ts` : 62 tests réussis. Exécutées via Jest/ts-jest et une configuration temporaire explicite pour éviter le chargement automatique des fichiers `.env` par `next/jest`. +- `npm run build` backend : réussi. +- `npm run type-check` frontend : réussi. +- ESLint sans correction automatique sur les fichiers backend/frontend modifiés : réussi. +- Prettier sur les fichiers TypeScript modifiés et `git diff --check` : réussis. + +Des échecs intermédiaires ont été corrigés : insertion d’un contrôle au mauvais emplacement détectée par TypeScript, et en-têtes XLSX altérés détectés par les tests d’export. Les vérifications concernées ont ensuite réussi. Aucun assouplissement de sécurité n’a été introduit pour faire passer les tests. + +## Actions de production et travail restant + +1. Renouveler la clé SMTP chez le fournisseur, configurer la nouvelle valeur hors du dépôt, puis déployer. L’historique Git contient encore l’ancienne valeur ; sa suppression du fichier courant n’est pas une révocation. +2. Invalider/remplacer les jetons transporteur et invitations susceptibles d’avoir été exposés avant le correctif ; prévoir les nouveaux liens légitimes. Aucun email réel n’a été envoyé ni jeton de production modifié. +3. Prévoir la reconnexion des utilisateurs au déploiement et vérifier les parcours navigateur, transporteur et paiement en préproduction. +4. Vérifier en environnement réel les limites du reverse proxy, le stockage S3, TLS, les permissions et les journaux historiques. Aucune modification de configuration en production n’a été effectuée. +5. Terminer l’audit des fichiers non couverts et l’inventaire des vulnérabilités des dépendances lorsque le scanner redevient disponible. Aucun audit exhaustif de dépendances ni pentest déployé n’est revendiqué ici. + +Les fichiers `.env` et `.env.*` n’ont pas été lus. Aucune migration, fusion, publication, modification de compte fournisseur ou réécriture d’historique n’a été effectuée. diff --git a/docs/security/check-secu/coverage.json b/docs/security/check-secu/coverage.json new file mode 100644 index 0000000..f12909f --- /dev/null +++ b/docs/security/check-secu/coverage.json @@ -0,0 +1,149 @@ +{ + "completeness": "partial", + "deferred": [ + { + "candidateId": "remaining-source", + "id": "remaining-source", + "reason": "Agents interrompus par limites d\u2019usage. Pages/composants frontend, migrations/scripts, adaptateurs transporteurs et portions CSV restent non lus int\u00e9gralement ; couverture non exhaustive." + }, + { + "candidateId": "subscription-sync-binding", + "id": "subscription-sync-binding", + "reason": "syncFromStripe ne lie pas metadata.organizationId ; UNIQUE stripe_subscription_id bloque le sc\u00e9nario normal. Course avant webhook ou ancien abonnement non li\u00e9 non valid\u00e9s." + }, + { + "candidate": { + "evidence": "login/page.tsx:97 redirect query, auth-context.tsx:110 router.push without URL validation", + "title": "Untrusted login redirect reaches router.push" + }, + "candidateId": "login-redirect", + "id": "login-redirect", + "reason": "Recovering interrupted investigator result for validation" + }, + { + "candidate": { + "evidence": "NotificationsGateway forwards notificationId without userId into repository update", + "title": "WebSocket mark_as_read lacks ownership" + }, + "candidateId": "notification-owner", + "id": "notification-owner", + "reason": "Recovering interrupted baseline result" + }, + { + "candidate": { + "evidence": "CsvBookingService.toResponseDto exposes confirmationToken used by public accept/reject", + "title": "Creator receives carrier confirmation token" + }, + "candidateId": "carrier-token", + "id": "carrier-token", + "reason": "Recovering interrupted baseline result" + } + ], + "documentType": "codex-security.coverage", + "excludePaths": [], + "explicitExclusions": [ + { + "pattern": "**/.env*", + "reason": "Restriction explicite de lecture." + }, + { + "pattern": "**/.env*", + "reason": "User prohibits .env and .env.* reads." + } + ], + "includePaths": [ + "." + ], + "inventoryStrategy": "repository", + "mode": "repository", + "openQuestions": [ + { + "question": "Compl\u00e9ter les fichiers non lus int\u00e9gralement avant de qualifier la couverture d\u2019exhaustive." + }, + { + "question": "V\u00e9rifier rotation SMTP et plafond multipart au proxy sans r\u00e9utiliser le secret." + }, + { + "question": "V\u00e9rifier liaison Stripe session/organisation et droits des abonnements UNPAID/PAUSED : plusieurs consommateurs lisent seulement plan." + }, + { + "question": "Aligner DATABASE_SSL, validation TLS SMTP/SQL et buckets provisionn\u00e9s/ACL." + } + ], + "scanId": "4c194468-0b5f-4f24-9005-5be211dc0e47", + "schemaVersion": "1.0", + "surfaces": [ + { + "disposition": "reported", + "id": "surface_authentification-recuperation-et-websockets", + "label": "Authentification, r\u00e9cup\u00e9ration et WebSockets", + "notes": "JWT HTTP v\u00e9rifie le type access et le compte actif ; inscription li\u00e9e \u00e0 invitation v\u00e9rifi\u00e9e. Bypass WebSocket, sessions apr\u00e8s reset et secrets dans logs confirm\u00e9s.", + "receiptRefs": [], + "riskArea": "Sessions" + }, + { + "disposition": "reported", + "id": "surface_organisations-et-roles-csv", + "label": "Organisations et r\u00f4les CSV", + "notes": "Contr\u00f4le inter-organisations cass\u00e9 par casse du r\u00f4le ; liste CSV sans r\u00f4le et mutations VIEWER. Les mutations individuelles CSV v\u00e9rifient le propri\u00e9taire.", + "receiptRefs": [], + "riskArea": "Isolation et permissions" + }, + { + "disposition": "reported", + "id": "surface_liens-transporteurs-et-documents", + "label": "Liens transporteurs et documents", + "notes": "Jeton divulgu\u00e9 au client. T\u00e9l\u00e9chargements v\u00e9rifient ACCEPTED, mot de passe si configur\u00e9 et appartenance du document ; PDFKit rend du texte sans navigateur ni chargement HTML.", + "receiptRefs": [], + "riskArea": "Autorit\u00e9 et stockage" + }, + { + "disposition": "reported", + "id": "surface_souscriptions-stripe", + "label": "Souscriptions Stripe", + "notes": "Signatures v\u00e9rifi\u00e9es ; r\u00e9siliation bloqu\u00e9e par licences. Sync ne compare pas metadata.organizationId mais UNIQUE stripe_subscription_id bloque la r\u00e9association normale ; sc\u00e9nario de course non confirm\u00e9.", + "receiptRefs": [], + "riskArea": "Int\u00e9grit\u00e9 financi\u00e8re" + }, + { + "disposition": "no_issue_found", + "id": "surface_mcp-et-assistant-ia", + "label": "MCP et assistant IA", + "notes": "R\u00f4le/offre contr\u00f4l\u00e9s \u00e0 chaque invocation ; acteur li\u00e9 \u00e0 session, SQL des conversations param\u00e9tr\u00e9 avec user_id, quota atomique et tours IA born\u00e9s.", + "receiptRefs": [], + "riskArea": "Outils et donn\u00e9es" + }, + { + "disposition": "reported", + "id": "surface_frontend-et-exports", + "label": "Frontend et exports", + "notes": "Redirection brute v\u00e9rifi\u00e9e dans Next install\u00e9. Formules CSV non neutralis\u00e9es. Contexte actif avec cookies HttpOnly, distinct de l\u2019ancien client localStorage.", + "receiptRefs": [], + "riskArea": "XSS et CSV" + }, + { + "disposition": "reported", + "id": "surface_logs-et-deploiements", + "label": "Logs et d\u00e9ploiements", + "notes": "Cl\u00e9 SMTP litt\u00e9rale masqu\u00e9e, validit\u00e9 inconnue. Logs de production internes avec NetworkPolicy ; Compose dev expose 3100/3200 sans authentification, sans preuve d\u2019exposition Internet.", + "receiptRefs": [], + "riskArea": "Secrets et r\u00e9seau" + }, + { + "disposition": "no_issue_found", + "id": "surface_persistance-gdpr-et-configuration", + "label": "Persistance, GDPR et configuration", + "notes": "Requ\u00eates recherche/GDPR/conversations param\u00e9tr\u00e9es ; export GDPR exclut hash mot de passe, TOTP et hash de cl\u00e9. DATABASE_SSL ignor\u00e9 par runtime/startup et validation de certificat d\u00e9sactiv\u00e9e dans CLI ; buckets distincts, \u00e9tat r\u00e9el externe non test\u00e9. 95 fichiers suivis lus int\u00e9gralement ; lectures cibl\u00e9es suppl\u00e9mentaires non compt\u00e9es.", + "receiptRefs": [], + "riskArea": "Injection et donn\u00e9es" + }, + { + "disposition": "rejected", + "id": "surface_webhook-ssrf-a-l-enregistrement", + "label": "Webhook SSRF \u00e0 l\u2019enregistrement", + "notes": "WebhookService poste vers la destination enregistr\u00e9e sans filtre IP, mais les DTO CreateWebhookDto/UpdateWebhookDto n\u2019ont aucun d\u00e9corateur de validation ; la validation globale whitelist + forbidNonWhitelisted de main.ts rejette leurs champs. Aucune voie actuelle de cr\u00e9ation par un attaquant n\u2019a \u00e9t\u00e9 \u00e9tablie. Corriger les DTO doit imp\u00e9rativement ajouter aussi une politique de destination.", + "receiptRefs": [], + "riskArea": "Requ\u00eates sortantes" + } + ] +} diff --git a/docs/security/check-secu/findings.json b/docs/security/check-secu/findings.json new file mode 100644 index 0000000..3cefbbd --- /dev/null +++ b/docs/security/check-secu/findings.json @@ -0,0 +1,2614 @@ +{ + "documentType": "codex-security.findings", + "findings": [ + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5" + ], + "sink": "apps/frontend/src/lib/context/auth-context.tsx", + "source": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL", + "summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5" + ], + "reachability": { + "attacker": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL", + "entrypoint": "apps/frontend/app/[locale]/login/page.tsx", + "summary": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically." + }, + "summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication." + }, + "codeEvidence": [ + { + "code": " const { login } = useAuth();\n const searchParams = useSearchParams();\n const redirectTo = searchParams.get('redirect') || '/dashboard';\n const tLogin = useTranslations('auth.login');\n const tPanel = useTranslations('auth.sidePanel');", + "endLine": 99, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/frontend/app/[locale]/login/page.tsx", + "role": "evidence", + "startLine": 95 + }, + { + "code": " setIsLoading(true);\n\n try {\n await login(email, password, redirectTo, rememberMe);\n } catch (err: any) {\n const { message, field } = mapLoginError(err, tLogin);", + "endLine": 167, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/frontend/app/[locale]/login/page.tsx", + "role": "evidence", + "startLine": 162 + }, + { + "code": " try {\n await apiLogin({ email, password, rememberMe });\n // Fetch complete user profile after login (session lives in httpOnly cookies)\n const currentUser = await getCurrentUser();\n setUser(currentUser);\n router.push(redirectTo);", + "endLine": 110, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/frontend/src/lib/context/auth-context.tsx", + "role": "root_control", + "startLine": 105 + }, + { + "code": "function useNavigate(dispatch) {\n return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{\n const url = new URL((0, _addbasepath.addBasePath)(href), location.href);\n return dispatch({\n type: _routerreducertypes.ACTION_NAVIGATE,\n url,\n isExternalUrl: isExternalURL(url),\n locationSearch: location.search,\n shouldScroll: shouldScroll != null ? shouldScroll : true,", + "endLine": 175, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/frontend/node_modules/next/dist/client/components/app-router.js", + "role": "evidence", + "startLine": 167 + }, + { + "code": " const pendingPush = navigateType === \"push\";\n // we want to prune the prefetch cache on every navigation to avoid it growing too large\n (0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache);\n mutable.preserveCustomHistoryState = false;\n if (isExternalUrl) {\n return handleExternalUrl(state, mutable, url.toString(), pendingPush);\n }\n const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({", + "endLine": 105, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e4", + "label": "Source 5", + "path": "apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js", + "role": "evidence", + "startLine": 98 + }, + { + "code": " if (pushRef.mpaNavigation) {\n // if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL\n if (globalMutable.pendingMpaPath !== canonicalUrl) {\n const location1 = window.location;\n if (pushRef.pendingPush) {\n location1.assign(canonicalUrl);\n } else {\n location1.replace(canonicalUrl);", + "endLine": 403, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e5", + "label": "Source 6", + "path": "apps/frontend/node_modules/next/dist/client/components/app-router.js", + "role": "evidence", + "startLine": 396 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_920528f644bd65099e2bac54", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:41eed20bc71a10cbe19b8b489995cf4d337d4ccd8722484a88070bd9ef3b94d1" + }, + "identity": { + "anchor": "la-redirection-de-connexion-permet-une-xss-dom" + }, + "locations": [ + { + "endLine": 99, + "path": "apps/frontend/app/[locale]/login/page.tsx", + "role": "evidence", + "startLine": 95 + }, + { + "endLine": 167, + "path": "apps/frontend/app/[locale]/login/page.tsx", + "role": "evidence", + "startLine": 162 + }, + { + "endLine": 110, + "path": "apps/frontend/src/lib/context/auth-context.tsx", + "role": "root_control", + "startLine": 105 + }, + { + "endLine": 175, + "path": "apps/frontend/node_modules/next/dist/client/components/app-router.js", + "role": "evidence", + "startLine": 167 + }, + { + "endLine": 105, + "path": "apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js", + "role": "evidence", + "startLine": 98 + }, + { + "endLine": 403, + "path": "apps/frontend/node_modules/next/dist/client/components/app-router.js", + "role": "evidence", + "startLine": 396 + } + ], + "occurrenceId": "occ_1147b6fa6f91560290ea0748", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "front-0", + "originalCandidates": [ + { + "attacker": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL", + "confidence": "high", + "control": "No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation.", + "counterevidence": "HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically.", + "cwe": "CWE-79", + "evidence": "A redirect value javascript:alert(document.domain) reaches router.push unchanged. Layout imports Providers whose AuthProvider alias resolves to src/lib via tsconfig.", + "flow": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.", + "line": 110, + "path": "apps/frontend/src/lib/context/auth-context.tsx", + "remediation": "Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary.", + "severity": "high", + "title": "Unsanitized login redirect executes JavaScript after authentication" + } + ], + "source": "local_plugin" + }, + "remediation": "Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5" + ], + "summary": "No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation. login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication." + }, + "ruleId": "xss.login-redirect", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "high", + "rationale": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically." + }, + "summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-79" + ] + }, + "title": "La redirection de connexion permet une XSS DOM", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. Contre-preuves : HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/controllers/organizations.controller.ts", + "source": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible", + "summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible", + "entrypoint": "apps/backend/src/application/auth/jwt.strategy.ts", + "summary": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role." + }, + "summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche." + }, + "codeEvidence": [ + { + "code": " role: user.role,\n organizationId: user.organizationId,\n firstName: user.firstName,\n lastName: user.lastName,\n };\n }\n}", + "endLine": 81, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/auth/jwt.strategy.ts", + "role": "evidence", + "startLine": 75 + }, + { + "code": "\n // Case-insensitive role comparison\n const userRole = user.role.toLowerCase();\n const requiredRolesLower = requiredRoles.map(r => r.toLowerCase());\n\n return requiredRolesLower.includes(userRole);\n }\n}", + "endLine": 50, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/guards/roles.guard.ts", + "role": "evidence", + "startLine": 43 + }, + { + "code": " async updateOrganization(\n @Param('id', ParseUUIDPipe) id: string,\n @Body() dto: UpdateOrganizationDto,\n @CurrentUser() user: UserPayload\n ): Promise {\n this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);\n\n const organization = await this.organizationRepository.findById(id);\n if (!organization) {\n throw new NotFoundException(`Organization ${id} not found`);\n }\n\n // Authorization: Managers can only update their own organization\n if (user.role === 'manager' && organization.id !== user.organizationId) {\n throw new ForbiddenException('You can only update your own organization');\n }", + "endLine": 256, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/controllers/organizations.controller.ts", + "role": "root_control", + "startLine": 241 + }, + { + "code": " if (dto.isActive !== undefined) {\n if (dto.isActive) {\n organization.activate();\n } else {\n organization.deactivate();\n }\n }\n\n // Save updated organization\n const updatedOrg = await this.organizationRepository.save(organization);\n\n this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);\n\n return OrganizationMapper.toDto(updatedOrg);", + "endLine": 304, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/controllers/organizations.controller.ts", + "role": "evidence", + "startLine": 291 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_a99d96667b01f080ce1662eb", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:d66394ca698616dc90984ad1b467a4f1b4b75e295992332f83d852404abea340" + }, + "identity": { + "anchor": "un-manager-peut-modifier-une-autre-organisation" + }, + "locations": [ + { + "endLine": 81, + "path": "apps/backend/src/application/auth/jwt.strategy.ts", + "role": "evidence", + "startLine": 75 + }, + { + "endLine": 50, + "path": "apps/backend/src/application/guards/roles.guard.ts", + "role": "evidence", + "startLine": 43 + }, + { + "endLine": 256, + "path": "apps/backend/src/application/controllers/organizations.controller.ts", + "role": "root_control", + "startLine": 241 + }, + { + "endLine": 304, + "path": "apps/backend/src/application/controllers/organizations.controller.ts", + "role": "evidence", + "startLine": 291 + } + ], + "occurrenceId": "occ_2bd06c4e81d5cdbd4052ec66", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "org-role-case", + "originalCandidates": [ + { + "evidence": "organizations.controller.ts:254 uses lowercase manager; persisted role is uppercase MANAGER", + "title": "Cross-tenant organization update" + } + ], + "source": "local_plugin" + }, + "remediation": "Refuser tout appel non ADMIN dont la cible diff\u00e8re de l\u2019organisation de la session ; utiliser l\u2019enum de r\u00f4le et appliquer le pr\u00e9dicat dans le service.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "Seul ADMIN peut modifier une autre organisation. PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche." + }, + "ruleId": "authorization.organization-role-case", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "high", + "rationale": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role." + }, + "summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-863" + ] + }, + "title": "Un manager peut modifier une autre organisation", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche. Contre-preuves : UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "sink": "apps/backend/src/application/gateways/notifications.gateway.ts", + "source": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout", + "summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "reachability": { + "attacker": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout", + "entrypoint": "apps/backend/src/application/gateways/notifications.gateway.ts", + "summary": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover." + }, + "summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired." + }, + "codeEvidence": [ + { + "code": " const payload = await this.jwtService.verifyAsync(token);\n const userId = payload.sub;", + "endLine": 61, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "root_control", + "startLine": 60 + }, + { + "code": " const payload = await this.jwtService.verifyAsync(token);\n const userId = payload.sub;\n\n // Store socket connection for user\n if (!this.userSockets.has(userId)) {\n this.userSockets.set(userId, new Set());\n }\n this.userSockets.get(userId)!.add(client.id);\n\n // Store user ID in socket data for later use\n client.data.userId = userId;\n client.data.organizationId = payload.organizationId;\n\n // Join user-specific room\n client.join(`user:${userId}`);\n\n this.logger.log(`Client ${client.id} connected for user ${userId}`);\n\n // Send unread count on connection\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n client.emit('unread_count', { count: unreadCount });\n\n // Send recent notifications on connection", + "endLine": 82, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "evidence", + "startLine": 60 + }, + { + "code": " JwtModule.registerAsync({\n imports: [ConfigModule],\n useFactory: (configService: ConfigService) => ({\n secret: configService.get('JWT_SECRET'),\n signOptions: {\n expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'),\n },\n }),", + "endLine": 28, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/notifications/notifications.module.ts", + "role": "evidence", + "startLine": 21 + }, + { + "code": " const payload = await this.jwtService.verifyAsync(refreshToken, {\n secret: this.configService.get('JWT_SECRET'),\n });\n\n if (payload.type !== 'refresh') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n if (await this.isRefreshTokenRevoked(refreshToken)) {\n throw new UnauthorizedException('Refresh token has been revoked');\n }\n\n const user = await this.userRepository.findById(payload.sub);\n\n if (!user || !user.isActive) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n const rememberMe = payload.rememberMe === true;\n const tokens = await this.generateTokens(user, rememberMe);", + "endLine": 253, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 234 + }, + { + "code": " if (payload.type !== 'access') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n // Validate user exists and is active\n const user = await this.authService.validateUser(payload);\n\n if (!user) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n // This object will be attached to request.user\n return {", + "endLine": 72, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e4", + "label": "Source 5", + "path": "apps/backend/src/application/auth/jwt.strategy.ts", + "role": "evidence", + "startLine": 60 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_031c778b7b21254a01a74864", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:d75a150a0eb1c8cfe10e9bf7de156129e43baea0ca69a6053dfaa2a6af99ad52" + }, + "identity": { + "anchor": "les-websockets-acceptent-des-sessions-revoquees-ou-desactivees" + }, + "locations": [ + { + "endLine": 61, + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "root_control", + "startLine": 60 + }, + { + "endLine": 82, + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "evidence", + "startLine": 60 + }, + { + "endLine": 28, + "path": "apps/backend/src/application/notifications/notifications.module.ts", + "role": "evidence", + "startLine": 21 + }, + { + "endLine": 253, + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 234 + }, + { + "endLine": 72, + "path": "apps/backend/src/application/auth/jwt.strategy.ts", + "role": "evidence", + "startLine": 60 + } + ], + "occurrenceId": "occ_4fc1a504e8a941dcd377f6a2", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-0", + "originalCandidates": [ + { + "attacker": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout", + "confidence": "high", + "counterevidence": "JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover.", + "cwe": "CWE-287", + "evidence": [ + { + "lines": "60-84", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "source": "verifyAsync(token); userId=payload.sub; join user room; emit recent_notifications" + }, + { + "lines": "21-28", + "path": "apps/backend/src/application/notifications/notifications.module.ts", + "source": "JwtModule uses JWT_SECRET" + }, + { + "lines": "234-253,457-475", + "path": "apps/backend/src/application/auth/auth.service.ts", + "source": "Refresh tokens signed with same JwtService; HTTP refresh checks revoked token and active account" + }, + { + "lines": "60-72", + "path": "apps/backend/src/application/auth/jwt.strategy.ts", + "source": "HTTP strategy checks type===access and active user" + } + ], + "flow": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.", + "invariant": "Notifications must require a current active account and access-token authentication", + "remediation": "Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy.", + "severity": "medium", + "title": "WebSocket authentication accepts revoked refresh tokens and inactive users" + } + ], + "source": "local_plugin" + }, + "remediation": "Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "summary": "Notifications must require a current active account and access-token authentication Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired." + }, + "ruleId": "authentication.websocket-session-validation", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover." + }, + "summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-287" + ] + }, + "title": "Les WebSockets acceptent des sessions r\u00e9voqu\u00e9es ou d\u00e9sactiv\u00e9es", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. Contre-preuves : JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "sink": "apps/backend/src/application/gateways/notifications.gateway.ts", + "source": "Any authenticated WebSocket user", + "summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "reachability": { + "attacker": "Any authenticated WebSocket user", + "entrypoint": "apps/backend/src/application/gateways/notifications.gateway.ts", + "summary": "Any authenticated WebSocket user. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty." + }, + "summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)." + }, + "codeEvidence": [ + { + "code": " ) {\n try {\n const userId = client.data.userId;\n await this.notificationService.markAsRead(data.notificationId);\n\n // Send updated unread count\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n this.emitToUser(userId, 'unread_count', { count: unreadCount });", + "endLine": 124, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "root_control", + "startLine": 117 + }, + { + "code": " */\n @SubscribeMessage('mark_as_read')\n async handleMarkAsRead(\n @ConnectedSocket() client: Socket,\n @MessageBody() data: { notificationId: string }\n ) {\n try {\n const userId = client.data.userId;\n await this.notificationService.markAsRead(data.notificationId);\n\n // Send updated unread count\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n this.emitToUser(userId, 'unread_count', { count: unreadCount });", + "endLine": 124, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "evidence", + "startLine": 112 + }, + { + "code": " /**\n * Delete notification\n */", + "endLine": 127, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/services/notification.service.ts", + "role": "evidence", + "startLine": 125 + }, + { + "code": " async markAsRead(id: string): Promise {\n await this.ormRepository.update(id, {\n read: true,\n read_at: new Date(),\n });\n }", + "endLine": 158, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts", + "role": "evidence", + "startLine": 153 + }, + { + "code": " update(target, criteria, partialEntity) {\n // if user passed empty criteria or empty list of criterias, then throw an error\n if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) {\n return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`));\n }\n if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) {\n return this.createQueryBuilder()\n .update(target)\n .set(partialEntity)\n .whereInIds(criteria)\n .execute();\n }\n else {\n return this.createQueryBuilder()\n .update(target)\n .set(partialEntity)\n .where(criteria)\n .execute();\n }\n }", + "endLine": 365, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e4", + "label": "Source 5", + "path": "apps/backend/node_modules/typeorm/entity-manager/EntityManager.js", + "role": "evidence", + "startLine": 346 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_7f33ab35ec82433af164cb40", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:d713a1fce8cc4f3a1ec7860a727d88bddfe2b221baffe79742d5361c68c1324c" + }, + "identity": { + "anchor": "un-membre-peut-marquer-toutes-les-notifications-comme-lues" + }, + "locations": [ + { + "endLine": 124, + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "root_control", + "startLine": 117 + }, + { + "endLine": 124, + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "role": "evidence", + "startLine": 112 + }, + { + "endLine": 127, + "path": "apps/backend/src/application/services/notification.service.ts", + "role": "evidence", + "startLine": 125 + }, + { + "endLine": 158, + "path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts", + "role": "evidence", + "startLine": 153 + }, + { + "endLine": 365, + "path": "apps/backend/node_modules/typeorm/entity-manager/EntityManager.js", + "role": "evidence", + "startLine": 346 + } + ], + "occurrenceId": "occ_971e94ca8f3ed2e8bd6cf5ff", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-1", + "originalCandidates": [ + { + "attacker": "Any authenticated WebSocket user", + "confidence": "high", + "counterevidence": "REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty.", + "cwe": "CWE-639", + "evidence": [ + { + "lines": "112-124", + "path": "apps/backend/src/application/gateways/notifications.gateway.ts", + "source": "@MessageBody() data: { notificationId: string }; markAsRead(data.notificationId)" + }, + { + "lines": "125-127", + "path": "apps/backend/src/application/services/notification.service.ts", + "source": "notificationRepository.markAsRead(id)" + }, + { + "lines": "153-158", + "path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts", + "source": "ormRepository.update(id, {read:true,read_at:new Date()})" + } + ], + "flow": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).", + "invariant": "Only the notification recipient may mark their own notification as read", + "remediation": "Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods.", + "severity": "medium", + "title": "WebSocket notification update permits cross-tenant bulk marking as read" + } + ], + "source": "local_plugin" + }, + "remediation": "Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "summary": "Only the notification recipient may mark their own notification as read Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)." + }, + "ruleId": "authorization.notification-update", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty." + }, + "summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-639" + ] + }, + "title": "Un membre peut marquer toutes les notifications comme lues", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). Contre-preuves : REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "sink": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "Booking creator or another organization member reading organization/all", + "summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "reachability": { + "attacker": "Booking creator or another organization member reading organization/all", + "entrypoint": "apps/backend/src/application/services/csv-booking.service.ts", + "summary": "Booking creator or another organization member reading organization/all. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision." + }, + "summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier." + }, + "codeEvidence": [ + { + "code": " status: booking.status,\n documents: booking.documents.map(this.toDocumentDto),\n confirmationToken: booking.confirmationToken,\n requestedAt: booking.requestedAt,\n respondedAt: booking.respondedAt || null,", + "endLine": 1612, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "root_control", + "startLine": 1608 + }, + { + "code": " return this.toResponseDto(savedBooking);", + "endLine": 244, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 244 + }, + { + "code": " @Public()\n @Get('accept/:token')\n @ApiOperation({\n summary: 'Accept booking request (public)',\n description:\n 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',\n })\n @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })\n @ApiResponse({\n status: 200,\n description: 'Booking accepted successfully.',\n })\n @ApiResponse({ status: 404, description: 'Booking not found or invalid token' })\n @ApiResponse({\n status: 400,\n description: 'Booking cannot be accepted (invalid status or expired)',\n })\n async acceptBooking(@Param('token') token: string) {\n // Accept the booking\n const booking = await this.csvBookingService.acceptBooking(token);", + "endLine": 47, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts", + "role": "evidence", + "startLine": 28 + }, + { + "code": " async acceptBooking(token: string): Promise {\n this.logger.log(`Accepting booking with token: ${token}`);\n\n const booking = await this.csvBookingRepository.findByToken(token);\n\n if (!booking) {\n throw new NotFoundException('Booking not found');\n }\n\n // Get ORM entity for bookingNumber\n const ormBooking = await this.csvBookingRepository['repository'].findOne({\n where: { confirmationToken: token },\n });\n\n // Accept the booking (domain logic validates status)\n booking.accept();\n\n // Apply the flat per-booking service fee (forfait par booking) from the org's plan\n const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);\n booking.applyBookingFee(bookingFeeEur);\n this.logger.log(\n `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}\u20ac (flat)` : 'none (custom)'} on booking ${booking.id}`\n );", + "endLine": 908, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 886 + }, + { + "code": " * This is a simplified booking workflow for CSV-based rates where the user\n * selects a rate and sends a booking request to the carrier with documents.\n *\n * Business Rules:\n * - Booking can only be accepted/rejected when status is PENDING\n * - Once accepted/rejected, status cannot be changed\n * - Booking expires after 7 days if not responded to\n * - At least one document is required for booking creation\n * - Confirmation token is used for email accept/reject links\n * - Only carrier can accept/reject via email link\n * - User can cancel pending bookings", + "endLine": 65, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e4", + "label": "Source 5", + "path": "apps/backend/src/domain/entities/csv-booking.entity.ts", + "role": "evidence", + "startLine": 55 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_5b73c5668d032d8ea730de12", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:c620fcd5111eeae81ce76e54c934caa7d3950b6ea580def72df49a929ad154b5" + }, + "identity": { + "anchor": "le-client-recoit-le-jeton-de-reponse-du-transporteur" + }, + "locations": [ + { + "endLine": 1612, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "root_control", + "startLine": 1608 + }, + { + "endLine": 244, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 244 + }, + { + "endLine": 47, + "path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts", + "role": "evidence", + "startLine": 28 + }, + { + "endLine": 908, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 886 + }, + { + "endLine": 65, + "path": "apps/backend/src/domain/entities/csv-booking.entity.ts", + "role": "evidence", + "startLine": 55 + } + ], + "occurrenceId": "occ_5145afbcb4a011920eb68e70", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-2", + "originalCandidates": [ + { + "attacker": "Booking creator or another organization member reading organization/all", + "confidence": "high", + "counterevidence": "Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision.", + "cwe": "CWE-863", + "evidence": [ + { + "lines": "244,1610", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "createBooking returns toResponseDto; DTO includes confirmationToken" + }, + { + "lines": "28-47", + "path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts", + "source": "@Public() GET accept/:token forwards to acceptBooking" + }, + { + "lines": "886-914", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "findByToken(token); booking.accept(); repository.update(booking)" + }, + { + "lines": "55-65", + "path": "apps/backend/src/domain/entities/csv-booking.entity.ts", + "source": "Only carrier can accept/reject via email link" + } + ], + "flow": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.", + "invariant": "Only the carrier receiving the email credential may accept or reject a booking", + "remediation": "Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens.", + "severity": "high", + "title": "CSV booking responses expose the carrier accept/reject credential" + } + ], + "source": "local_plugin" + }, + "remediation": "Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "summary": "Only the carrier receiving the email credential may accept or reject a booking Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier." + }, + "ruleId": "authorization.carrier-token-disclosure", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision." + }, + "summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-863" + ] + }, + "title": "Le client re\u00e7oit le jeton de r\u00e9ponse du transporteur", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Contre-preuves : Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "Active VIEWER account including account downgraded from USER", + "summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Active VIEWER account including account downgraded from USER", + "entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "summary": "Active VIEWER account including account downgraded from USER. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source." + }, + "summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings." + }, + "codeEvidence": [ + { + "code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))", + "endLine": 88, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 86 + }, + { + "code": " VIEWER = 'VIEWER', // Read-only access", + "endLine": 19, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/domain/entities/user.entity.ts", + "role": "evidence", + "startLine": 19 + }, + { + "code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))", + "endLine": 88, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 86 + }, + { + "code": " this.logger.log(`Creating CSV booking for user ${userId}`);\n\n // Validate minimum document requirement\n if (!files || files.length === 0) {\n throw new BadRequestException('At least one document is required');\n }\n\n // Generate unique confirmation token and booking number\n const confirmationToken = uuidv4();\n const bookingId = uuidv4();\n const bookingNumber = this.generateBookingNumber();\n const documentPassword = this.deriveDocumentPassword(bookingId);\n\n // Hash the password for storage\n const passwordHash = await argon2.hash(documentPassword);\n\n // Upload documents to S3\n const documents = await this.uploadDocuments(files, bookingId);\n\n // Flat per-booking service fee (forfait par booking) based on the org's plan.\n // A fee <= 0 (e.g. Platinium \"sur mesure\") means no automatic charge: the\n // booking skips the payment gate and the carrier is notified immediately.\n const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);", + "endLine": 168, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 146 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_1876a06cabe3545c1be17f50", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:3f6bc3941b7e4e27d6e0a7e3db05eb4e8168bf61484402abc3b3eecbd5d2f4f4" + }, + "identity": { + "anchor": "viewer-peut-creer-et-modifier-des-reservations" + }, + "locations": [ + { + "endLine": 88, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 86 + }, + { + "endLine": 19, + "path": "apps/backend/src/domain/entities/user.entity.ts", + "role": "evidence", + "startLine": 19 + }, + { + "endLine": 88, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 86 + }, + { + "endLine": 168, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 146 + } + ], + "occurrenceId": "occ_6f0576490bbab107cd67cfe5", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-4", + "originalCandidates": [ + { + "attacker": "Active VIEWER account including account downgraded from USER", + "confidence": "high", + "counterevidence": "Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source.", + "cwe": "CWE-862", + "evidence": [ + { + "lines": "19,188-193", + "path": "apps/backend/src/domain/entities/user.entity.ts", + "source": "VIEWER read-only; canCreateBookings excludes VIEWER" + }, + { + "lines": "86-88,150-214", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "create route authenticates but never checks role" + }, + { + "lines": "146-244", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "createBooking saves supplied booking for caller" + } + ], + "flow": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.", + "invariant": "VIEWER role is read-only and cannot create bookings", + "remediation": "Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths.", + "severity": "medium", + "title": "Read-only VIEWER accounts can create and mutate CSV bookings" + } + ], + "source": "local_plugin" + }, + "remediation": "Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "VIEWER role is read-only and cannot create bookings VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings." + }, + "ruleId": "authorization.viewer-booking-mutations", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source." + }, + "summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-862" + ] + }, + "title": "VIEWER peut cr\u00e9er et modifier des r\u00e9servations", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Contre-preuves : Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/controllers/users.controller.ts", + "source": "Operator or attacker able to read application logs but not authorized to authenticate as users", + "summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Operator or attacker able to read application logs but not authorized to authenticate as users", + "entrypoint": "apps/backend/src/application/controllers/users.controller.ts", + "summary": "Operator or attacker able to read application logs but not authorized to authenticate as users. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented." + }, + "summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee." + }, + "codeEvidence": [ + { + "code": " // TODO: Send invitation email with temporary password\n this.logger.warn(\n `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`\n );", + "endLine": 166, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "root_control", + "startLine": 163 + }, + { + "code": " const tempPassword = dto.password || this.generateTemporaryPassword();\n\n // Hash password with Argon2id\n const passwordHash = await argon2.hash(tempPassword, {\n type: argon2.argon2id,\n memoryCost: 65536, // 64 MB\n timeCost: 3,\n parallelism: 4,\n });\n\n // Map DTO role to Domain role\n const domainRole = dto.role as unknown as DomainUserRole;\n\n // Create user entity\n const newUser = User.create({\n id: uuidv4(),\n organizationId: dto.organizationId,\n email: dto.email,\n passwordHash,\n firstName: dto.firstName,\n lastName: dto.lastName,\n role: domainRole,\n });", + "endLine": 156, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 134 + }, + { + "code": " const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;\n\n this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);\n this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);", + "endLine": 181, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/services/invitation.service.ts", + "role": "evidence", + "startLine": 178 + }, + { + "code": " level: isDev ? 'debug' : 'info',\n // Redact sensitive fields from logs\n redact: {\n paths: [\n 'req.headers.authorization',\n 'req.headers[\"x-api-key\"]',\n 'req.body.password',\n 'req.body.currentPassword',\n 'req.body.newPassword',\n ],\n censor: '[REDACTED]',\n },\n },", + "endLine": 135, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/app.module.ts", + "role": "evidence", + "startLine": 123 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_13367c121ce207647f4a6533", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:93fc9bffa9aee6f0eca0a51e8898c45597be65756c0307296df81134a8e7a466" + }, + "identity": { + "anchor": "les-logs-contiennent-mots-de-passe-et-invitations" + }, + "locations": [ + { + "endLine": 166, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "root_control", + "startLine": 163 + }, + { + "endLine": 156, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 134 + }, + { + "endLine": 181, + "path": "apps/backend/src/application/services/invitation.service.ts", + "role": "evidence", + "startLine": 178 + }, + { + "endLine": 135, + "path": "apps/backend/src/app.module.ts", + "role": "evidence", + "startLine": 123 + } + ], + "occurrenceId": "occ_5494e6769cd3425850eb7778", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-5", + "originalCandidates": [ + { + "attacker": "Operator or attacker able to read application logs but not authorized to authenticate as users", + "confidence": "high", + "counterevidence": "Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented.", + "cwe": "CWE-532", + "evidence": [ + { + "lines": "134-165", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "source": "tempPassword=dto.password || generated; logger.warn interpolates actual password" + }, + { + "lines": "178-181", + "path": "apps/backend/src/application/services/invitation.service.ts", + "source": "logger.log interpolates invitationLink containing active invitation token" + }, + { + "lines": "123-135", + "path": "apps/backend/src/app.module.ts", + "source": "Pino redact paths cover structured request password fields, not interpolated message secrets" + } + ], + "flow": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.", + "invariant": "Authentication secrets must not be exposed to log readers", + "remediation": "Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials.", + "severity": "high", + "title": "Passwords and invitation bearer credentials are written to application logs" + } + ], + "source": "local_plugin" + }, + "remediation": "Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "Authentication secrets must not be exposed to log readers Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee." + }, + "ruleId": "credential-exposure.application-logs", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented." + }, + "summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-532" + ] + }, + "title": "Les logs contiennent mots de passe et invitations", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Contre-preuves : Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/auth/auth.service.ts", + "source": "Attacker holding a victim refresh token before password recovery", + "summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Attacker holding a victim refresh token before password recovery", + "entrypoint": "apps/backend/src/application/auth/auth.service.ts", + "summary": "Attacker holding a victim refresh token before password recovery. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed." + }, + "summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access." + }, + "codeEvidence": [ + { + "code": " // Update password (mutates in place)\n user.updatePassword(passwordHash);\n await this.userRepository.save(user);\n\n // Mark token as used\n await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });\n", + "endLine": 392, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "root_control", + "startLine": 386 + }, + { + "code": " async resetPassword(token: string, newPassword: string): Promise {\n const resetToken = await this.passwordResetTokenRepository.findOne({\n where: { token: this.hashResetToken(token) },\n });\n\n if (!resetToken) {\n throw new BadRequestException('Token de r\u00e9initialisation invalide ou expir\u00e9');\n }\n\n if (resetToken.usedAt) {\n throw new BadRequestException('Ce lien de r\u00e9initialisation a d\u00e9j\u00e0 \u00e9t\u00e9 utilis\u00e9');\n }\n\n if (resetToken.expiresAt < new Date()) {\n throw new BadRequestException(\n 'Le lien de r\u00e9initialisation a expir\u00e9. Veuillez en demander un nouveau.'\n );\n }\n\n const user = await this.userRepository.findById(resetToken.userId);\n\n if (!user || !user.isActive) {\n throw new NotFoundException('Utilisateur introuvable');", + "endLine": 376, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 354 + }, + { + "code": " const payload = await this.jwtService.verifyAsync(refreshToken, {\n secret: this.configService.get('JWT_SECRET'),\n });\n\n if (payload.type !== 'refresh') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n if (await this.isRefreshTokenRevoked(refreshToken)) {\n throw new UnauthorizedException('Refresh token has been revoked');\n }\n\n const user = await this.userRepository.findById(payload.sub);\n\n if (!user || !user.isActive) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n const rememberMe = payload.rememberMe === true;\n const tokens = await this.generateTokens(user, rememberMe);", + "endLine": 253, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 234 + }, + { + "code": " updatePassword(newPasswordHash: string): void {\n this.props.passwordHash = newPasswordHash;\n this.props.updatedAt = new Date();\n }", + "endLine": 199, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/domain/entities/user.entity.ts", + "role": "evidence", + "startLine": 196 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_3ea856b16338984d28e2c344", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:dba4deafa83f815d5f54f3e7f2951e3ce9194ed24b09f77f801c05835bf5b693" + }, + "identity": { + "anchor": "le-changement-de-mot-de-passe-conserve-les-anciennes-sessions" + }, + "locations": [ + { + "endLine": 392, + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "root_control", + "startLine": 386 + }, + { + "endLine": 376, + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 354 + }, + { + "endLine": 253, + "path": "apps/backend/src/application/auth/auth.service.ts", + "role": "evidence", + "startLine": 234 + }, + { + "endLine": 199, + "path": "apps/backend/src/domain/entities/user.entity.ts", + "role": "evidence", + "startLine": 196 + } + ], + "occurrenceId": "occ_b3baf623592ccfdf73fc5ecb", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-6", + "originalCandidates": [ + { + "attacker": "Attacker holding a victim refresh token before password recovery", + "confidence": "high", + "counterevidence": "Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed.", + "cwe": "CWE-613", + "evidence": [ + { + "lines": "354-394", + "path": "apps/backend/src/application/auth/auth.service.ts", + "source": "resetPassword updates passwordHash then marks reset token used; no session invalidation" + }, + { + "lines": "234-253", + "path": "apps/backend/src/application/auth/auth.service.ts", + "source": "refresh only verifies signature/type, per-token logout blacklist, active user" + }, + { + "lines": "196-199", + "path": "apps/backend/src/domain/entities/user.entity.ts", + "source": "updatePassword only changes hash and updatedAt" + } + ], + "flow": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.", + "invariant": "Recovering a compromised account must invalidate pre-reset authentication sessions", + "remediation": "Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change.", + "severity": "medium", + "title": "Password recovery does not invalidate existing refresh sessions" + } + ], + "source": "local_plugin" + }, + "remediation": "Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "Recovering a compromised account must invalidate pre-reset authentication sessions Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access." + }, + "ruleId": "session-invalidation.password-reset", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed." + }, + "summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-613" + ] + }, + "title": "Le changement de mot de passe conserve les anciennes sessions", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Contre-preuves : Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5", + "e6" + ], + "sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "Any authenticated account, including newly registered free-plan user", + "summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5", + "e6" + ], + "reachability": { + "attacker": "Any authenticated account, including newly registered free-plan user", + "entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "summary": "Any authenticated account, including newly registered free-plan user. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test." + }, + "summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory." + }, + "codeEvidence": [ + { + "code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))", + "endLine": 88, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 86 + }, + { + "code": " @UseInterceptors(FilesInterceptor('documents', 10))", + "endLine": 88, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 88 + }, + { + "code": "@Module({\n imports: [\n TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),\n ConfigModule,\n NotificationsModule,\n EmailModule,\n StorageModule,\n SubscriptionsModule,\n StripeModule,\n ],", + "endLine": 37, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts", + "role": "evidence", + "startLine": 28 + }, + { + "code": " /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */\n session: 'xpeditis_session',\n} as const;\n\nexport function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {\n httpOnly: boolean;\n secure: boolean;\n sameSite: 'lax' | 'strict' | 'none';\n path: string;\n domain?: string;\n maxAge?: number;\n} {\n // SameSite must be 'none' when the frontend and the API live on different\n // sites (cross-origin), otherwise the browser drops the auth cookies set in\n // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).\n // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.", + "endLine": 194, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/infrastructure/security/security.config.ts", + "role": "evidence", + "startLine": 179 + }, + { + "code": "function Multer (options) {\n if (options.storage) {\n this.storage = options.storage\n } else if (options.dest) {\n this.storage = diskStorage({ destination: options.dest })\n } else {\n this.storage = memoryStorage()\n }\n\n this.limits = options.limits\n this.preservePath = options.preservePath\n this.fileFilter = options.fileFilter || allowAll\n}", + "endLine": 23, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e4", + "label": "Source 5", + "path": "apps/backend/node_modules/multer/index.js", + "role": "evidence", + "startLine": 11 + }, + { + "code": "function MemoryStorage (opts) {}\n\nMemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) {\n file.stream.pipe(concat({ encoding: 'buffer' }, function (data) {\n cb(null, {\n buffer: data,\n size: data.length\n })\n }))\n}", + "endLine": 12, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e5", + "label": "Source 6", + "path": "apps/backend/node_modules/multer/storage/memory.js", + "role": "evidence", + "startLine": 3 + }, + { + "code": " const limits = cfg.limits;\n const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'\n ? limits.fieldSize\n : 1 * 1024 * 1024);\n const fileSizeLimit = (limits && typeof limits.fileSize === 'number'\n ? limits.fileSize\n : Infinity);", + "endLine": 256, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e6", + "label": "Source 7", + "path": "apps/backend/node_modules/busboy/lib/types/multipart.js", + "role": "evidence", + "startLine": 250 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_bf9e4938067e4e0ec02624ca", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:4cbc2135cb631fb2ed5d2c3639da04f13558b2fd303e0876eaeac63f36e2b642" + }, + "identity": { + "anchor": "les-televersements-ne-bornent-pas-la-memoire-utilisee" + }, + "locations": [ + { + "endLine": 88, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 86 + }, + { + "endLine": 88, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 88 + }, + { + "endLine": 37, + "path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts", + "role": "evidence", + "startLine": 28 + }, + { + "endLine": 194, + "path": "apps/backend/src/infrastructure/security/security.config.ts", + "role": "evidence", + "startLine": 179 + }, + { + "endLine": 23, + "path": "apps/backend/node_modules/multer/index.js", + "role": "evidence", + "startLine": 11 + }, + { + "endLine": 12, + "path": "apps/backend/node_modules/multer/storage/memory.js", + "role": "evidence", + "startLine": 3 + }, + { + "endLine": 256, + "path": "apps/backend/node_modules/busboy/lib/types/multipart.js", + "role": "evidence", + "startLine": 250 + } + ], + "occurrenceId": "occ_0e8b50e3868ae0135d1f03b2", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-7", + "originalCandidates": [ + { + "attacker": "Any authenticated account, including newly registered free-plan user", + "confidence": "high", + "counterevidence": "Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test.", + "cwe": "CWE-400", + "evidence": [ + { + "lines": "88,690,746", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "FilesInterceptor has count only, no local limits" + }, + { + "lines": "28-37", + "path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts", + "source": "No MulterModule defaults supplied" + }, + { + "lines": "179-194", + "path": "apps/backend/src/infrastructure/security/security.config.ts", + "source": "fileUploadConfig declares maxFileSize but is not wired to these interceptors" + } + ], + "flow": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.", + "invariant": "Single upload requests must have bounded resource use before buffering", + "remediation": "Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage.", + "severity": "medium", + "title": "CSV document uploads buffer files without a size limit" + } + ], + "source": "local_plugin" + }, + "remediation": "Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5", + "e6" + ], + "summary": "Single upload requests must have bounded resource use before buffering POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory." + }, + "ruleId": "resource-exhaustion.multipart-memory", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test." + }, + "summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-400" + ] + }, + "title": "Les t\u00e9l\u00e9versements ne bornent pas la m\u00e9moire utilis\u00e9e", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3", + "e4", + "e5", + "e6" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Contre-preuves : Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0" + ], + "sink": "docker/docker-compose.full.yml", + "source": "Anyone who obtains repository/configuration content", + "summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment." + }, + "evidenceRefs": [ + "e0" + ], + "reachability": { + "attacker": "Anyone who obtains repository/configuration content", + "entrypoint": "docker/docker-compose.full.yml", + "summary": "Anyone who obtains repository/configuration content. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential." + }, + "summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment." + }, + "codeEvidence": [ + { + "code": " SMTP_PASS: [REDACTED]", + "endLine": 137, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "docker/docker-compose.full.yml", + "role": "root_control", + "startLine": 137 + } + ], + "confidence": { + "level": "medium", + "rationale": "Tra\u00e7age statique du code courant. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_190e783e3fd6c0427523f25a", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:f06b23146f25cb11d62eb9ce5cfef13eb6c2d9acc51c88fd01e89e2d53707d54" + }, + "identity": { + "anchor": "une-cle-smtp-figure-dans-un-fichier-suivi" + }, + "locations": [ + { + "endLine": 137, + "path": "docker/docker-compose.full.yml", + "role": "root_control", + "startLine": 137 + } + ], + "occurrenceId": "occ_d4860f2f9683cb292b0ca32d", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "front-1", + "originalCandidates": [ + { + "attacker": "Anyone who obtains repository/configuration content", + "confidence": "medium", + "control": "Credential is inline rather than secret reference.", + "counterevidence": "Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential.", + "cwe": "CWE-798", + "evidence": "SMTP_PASS: [REDACTED]. Parent prior auditor identified provider-shaped value; current review kept output redacted.", + "flow": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.", + "line": 137, + "path": "docker/docker-compose.full.yml", + "remediation": "Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret.", + "severity": "high", + "title": "Provider SMTP credential embedded in tracked development compose file" + } + ], + "source": "local_plugin" + }, + "remediation": "Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0" + ], + "summary": "Credential is inline rather than secret reference. Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment." + }, + "ruleId": "hardcoded-credential.smtp", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "Format fournisseur confirm\u00e9, mais validit\u00e9 non test\u00e9e. Un lecteur du d\u00e9p\u00f4t peut obtenir la cl\u00e9 ; usage abusif possible si elle est toujours active. Valeur masqu\u00e9e." + }, + "summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-798" + ] + }, + "title": "Une cl\u00e9 SMTP figure dans un fichier suivi", + "validation": { + "evidenceRefs": [ + "e0" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. Contre-preuves : Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/services/subscription.service.ts", + "source": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN", + "summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN", + "entrypoint": "apps/backend/src/application/services/subscription.service.ts", + "summary": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe." + }, + "summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200." + }, + "codeEvidence": [ + { + "code": " }\n\n // Downgrade to FREE plan - count only non-ADMIN licenses\n const canceledSubscription = subscription\n .updatePlan(\n SubscriptionPlan.bronze(),\n await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)\n )\n .updateStatus(SubscriptionStatus.canceled());\n\n await this.subscriptionRepository.save(canceledSubscription);\n", + "endLine": 619, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/services/subscription.service.ts", + "role": "root_control", + "startLine": 608 + }, + { + "code": " if (!newPlan.canAccommodateUsers(currentUserCount)) {\n throw new InvalidSubscriptionDowngradeException(\n this.props.plan.value,\n newPlan.value,\n currentUserCount,\n newPlan.maxLicenses\n );\n }", + "endLine": 269, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/domain/entities/subscription.entity.ts", + "role": "root_control", + "startLine": 262 + }, + { + "code": " maxLicenses: 1,\n monthlyPriceEur: 0,\n yearlyPriceEur: 0,\n maxShipmentsPerYear: 5,\n bookingFeeEur: 15,\n statusBadge: 'none',", + "endLine": 55, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/domain/value-objects/subscription-plan.vo.ts", + "role": "evidence", + "startLine": 50 + }, + { + "code": " this.logger.error('Webhook processing failed', error);\n return { received: false };\n }", + "endLine": 281, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/controllers/subscriptions.controller.ts", + "role": "root_control", + "startLine": 279 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_4450c37b1177da8cc92d9bbf", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:c957854b5764d83e3df0b07631a0185999a16797f862b0352f241896e37fa19e" + }, + "identity": { + "anchor": "la-resiliation-peut-conserver-les-avantages-payants" + }, + "locations": [ + { + "endLine": 619, + "path": "apps/backend/src/application/services/subscription.service.ts", + "role": "root_control", + "startLine": 608 + }, + { + "endLine": 269, + "path": "apps/backend/src/domain/entities/subscription.entity.ts", + "role": "evidence", + "startLine": 262 + }, + { + "endLine": 55, + "path": "apps/backend/src/domain/value-objects/subscription-plan.vo.ts", + "role": "evidence", + "startLine": 50 + }, + { + "endLine": 281, + "path": "apps/backend/src/application/controllers/subscriptions.controller.ts", + "role": "evidence", + "startLine": 279 + } + ], + "occurrenceId": "occ_660d06b4ca749441dfe7cc13", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "subscription-cancellation", + "originalCandidates": [ + { + "evidence": "handleSubscriptionDeleted calls updatePlan before saving canceled status, updatePlan throws when users exceed limit", + "title": "Cancellation cannot downgrade when active licenses exceed Bronze cap" + } + ], + "source": "local_plugin" + }, + "remediation": "Persister la r\u00e9siliation ind\u00e9pendamment des limites de licences, retirer les droits effectifs puis r\u00e9soudre le surnombre. Ne pas acquitter une erreur de traitement comme un succ\u00e8s.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "Une r\u00e9siliation doit retirer les droits m\u00eame si le compte d\u00e9passe la capacit\u00e9 gratuite. customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200." + }, + "ruleId": "business-logic.subscription-cancellation", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "medium", + "rationale": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe." + }, + "summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-841" + ] + }, + "title": "La r\u00e9siliation peut conserver les avantages payants", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200. Contre-preuves : \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "Authenticated USER or VIEWER in organization with other users bookings", + "summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "reachability": { + "attacker": "Authenticated USER or VIEWER in organization with other users bookings", + "entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "summary": "Authenticated USER or VIEWER in organization with other users bookings. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads." + }, + "summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately." + }, + "codeEvidence": [ + { + "code": " @Get('organization/all')\n @UseGuards(JwtAuthGuard)\n @ApiBearerAuth()\n @ApiOperation({\n summary: 'Get organization bookings',\n description:\n \"Retrieve all bookings for the user's organization with pagination. For managers/admins.\",\n })\n @ApiQuery({ name: 'page', required: false, type: Number, example: 1 })", + "endLine": 321, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 313 + }, + { + "code": " @Get('organization/all')\n @UseGuards(JwtAuthGuard)\n @ApiBearerAuth()\n @ApiOperation({\n summary: 'Get organization bookings',\n description:\n \"Retrieve all bookings for the user's organization with pagination. For managers/admins.\",\n })\n @ApiQuery({ name: 'page', required: false, type: Number, example: 1 })\n @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })\n @ApiResponse({\n status: 200,\n description: 'Organization bookings retrieved successfully',\n type: CsvBookingListResponseDto,\n })\n @ApiResponse({ status: 401, description: 'Unauthorized' })\n async getOrganizationBookings(\n @Request() req: any,\n @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,\n @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number\n ): Promise {\n const organizationId = req.user.organizationId;\n return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);", + "endLine": 335, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 313 + }, + { + "code": " page,\n limit,\n totalPages: Math.ceil(bookings.length / limit),\n };\n }\n\n /**\n * Get bookings for an organization (paginated)\n */\n async getOrganizationBookings(\n organizationId: string,\n page: number = 1,\n limit: number = 10\n ): Promise {\n const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);\n\n // Simple pagination (in-memory)\n const start = (page - 1) * limit;\n const end = start + limit;\n const paginatedBookings = bookings.slice(start, end);\n\n return {", + "endLine": 1221, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 1200 + }, + { + "code": " // Verify user owns this booking OR is the assigned carrier\n const isOwner = booking.userId === userId;\n const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;\n\n if (!isOwner && !isAssignedCarrier) {\n throw new NotFoundException(`Booking with ID ${id} not found`);\n }\n\n return this.toResponseDto(booking);\n }\n\n /**\n * Get booking by confirmation token (public endpoint)", + "endLine": 697, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e3", + "label": "Source 4", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 685 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_50c8900726aa1ddf77230d4f", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:3f0bb62a45cf42e61ae285fdbde413347a2f73bd14c8dd25600f6d05048cdef6" + }, + "identity": { + "anchor": "les-dossiers-des-collegues-sont-accessibles-sans-role-de-gestion" + }, + "locations": [ + { + "endLine": 321, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "root_control", + "startLine": 313 + }, + { + "endLine": 335, + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "role": "evidence", + "startLine": 313 + }, + { + "endLine": 1221, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 1200 + }, + { + "endLine": 697, + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "role": "evidence", + "startLine": 685 + } + ], + "occurrenceId": "occ_4868877e84480a9f3396770f", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-3", + "originalCandidates": [ + { + "attacker": "Authenticated USER or VIEWER in organization with other users bookings", + "confidence": "high", + "counterevidence": "Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads.", + "cwe": "CWE-862", + "evidence": [ + { + "lines": "313-338", + "path": "apps/backend/src/application/controllers/csv-bookings.controller.ts", + "source": "organization/all only @UseGuards(JwtAuthGuard), passes req.user.organizationId" + }, + { + "lines": "1200-1221", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "getOrganizationBookings returns all organization records through toResponseDto" + }, + { + "lines": "685-697", + "path": "apps/backend/src/application/services/csv-booking.service.ts", + "source": "Individual booking read rejects non-owner/non-carrier" + } + ], + "flow": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.", + "invariant": "Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins", + "remediation": "Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility.", + "severity": "medium", + "title": "Organization CSV booking listing lacks manager/admin authorization" + } + ], + "source": "local_plugin" + }, + "remediation": "Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "summary": "Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately." + }, + "ruleId": "authorization.organization-booking-list", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "low", + "rationale": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads." + }, + "summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-862" + ] + }, + "title": "Les dossiers des coll\u00e8gues sont accessibles sans r\u00f4le de gestion", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2", + "e3" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Contre-preuves : Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "sink": "apps/backend/src/application/controllers/users.controller.ts", + "source": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID", + "summary": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "reachability": { + "attacker": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID", + "entrypoint": "apps/backend/src/application/controllers/users.controller.ts", + "summary": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation." + }, + "summary": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration." + }, + "codeEvidence": [ + { + "code": " // Authorization: Only ADMIN can assign ADMIN role\n if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {\n throw new ForbiddenException('Only platform administrators can assign ADMIN role');\n }\n\n // Authorization: Managers can only update users in their own organization\n if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {\n throw new ForbiddenException('You can only update users in your own organization');\n }", + "endLine": 264, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "root_control", + "startLine": 256 + }, + { + "code": " if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {\n throw new ForbiddenException('Only platform administrators can assign ADMIN role');\n }\n\n // Authorization: Managers can only update users in their own organization\n if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {\n throw new ForbiddenException('You can only update users in your own organization');\n }\n\n // Update fields\n if (dto.firstName) {\n user.updateFirstName(dto.firstName);\n }\n\n if (dto.lastName) {\n user.updateLastName(dto.lastName);\n }\n\n if (dto.role) {\n const domainRole = dto.role as unknown as DomainUserRole;\n user.updateRole(domainRole);\n }\n", + "endLine": 279, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e1", + "label": "Source 2", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 257 + }, + { + "code": "\n // Fetch users from current user's organization\n this.logger.log(\n `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`\n );", + "endLine": 400, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 396 + } + ], + "confidence": { + "level": "high", + "rationale": "Tra\u00e7age statique du code courant. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_044db1631e9f89d1b75d672c", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:07810ecb4fc6a43ce1fbde341c16228d5c1744c8781ac75d5c76f0a63946c3de" + }, + "identity": { + "anchor": "un-manager-peut-retrograder-un-administrateur-de-son-organisation" + }, + "locations": [ + { + "endLine": 264, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "root_control", + "startLine": 256 + }, + { + "endLine": 279, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 257 + }, + { + "endLine": 400, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 396 + } + ], + "occurrenceId": "occ_b71f7a24ae63cf0ccd54604c", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "back-8", + "originalCandidates": [ + { + "attacker": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID", + "confidence": "high", + "counterevidence": "Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation.", + "cwe": "CWE-863", + "evidence": [ + { + "lines": "257-282", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "source": "Only blocks dto.role===ADMIN; same-organization manager otherwise allowed to update role and active status" + }, + { + "lines": "396-400", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "source": "List explicitly hides ADMIN users from non-admins" + } + ], + "flow": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.", + "invariant": "Managers must not alter platform administrator privileges/status", + "remediation": "Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes.", + "severity": "medium", + "title": "Organization managers can demote or deactivate platform administrators" + } + ], + "source": "local_plugin" + }, + "remediation": "Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "summary": "Managers must not alter platform administrator privileges/status Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration." + }, + "ruleId": "authorization.admin-target-hierarchy", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "low", + "rationale": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation." + }, + "summary": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-863" + ] + }, + "title": "Un manager peut r\u00e9trograder un administrateur de son organisation", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "Manager invokes PATCH /users/ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Contre-preuves : Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation." + } + }, + { + "attackPath": { + "dataflow": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "sink": "apps/frontend/src/components/ExportButton.tsx", + "source": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software", + "summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active." + }, + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "reachability": { + "attacker": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software", + "entrypoint": "apps/backend/src/application/controllers/users.controller.ts", + "summary": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced." + }, + "summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active." + }, + "codeEvidence": [ + { + "code": " // Update fields\n if (dto.firstName) {\n user.updateFirstName(dto.firstName);\n }\n\n if (dto.lastName) {\n user.updateLastName(dto.lastName);\n }", + "endLine": 273, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e0", + "label": "Source 1", + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 266 + }, + { + "code": " {\n const headers = columns.map(col => `\"${col.label.replace(/\"/g, '\"\"')}\"`).join(';');\n\n const rows = data.map(row => {\n return columns\n .map(col => {\n const value = getNestedValue(row, col.key as string);\n const formattedValue = col.format ? col.format(value, row) : formatValue(value);\n return `\"${formattedValue.replace(/\"/g, '\"\"')}\"`;\n })\n .join(';');\n });\n\n return [headers, ...rows].join('\\n');\n };\n", + "endLine": 80, + "explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.", + "id": "e2", + "label": "Source 3", + "path": "apps/frontend/src/components/ExportButton.tsx", + "role": "root_control", + "startLine": 65 + } + ], + "confidence": { + "level": "medium", + "rationale": "Tra\u00e7age statique du code courant. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced." + }, + "extensions": { + "investigator": "Source audit and independent parent validation" + }, + "findingId": "csf_0bb11fd71e25c3769cbe03e8", + "fingerprints": { + "algorithm": "codex-security/v1", + "primary": "codex-security/v1:sha256:0de3829c9e9af3e471a70b622b2eae3861b2a0de91d3e83514d30b3bd1d83b85" + }, + "identity": { + "anchor": "les-exports-csv-conservent-les-formules-injectees" + }, + "locations": [ + { + "endLine": 273, + "path": "apps/backend/src/application/controllers/users.controller.ts", + "role": "evidence", + "startLine": 266 + }, + { + "endLine": 347, + "path": "apps/frontend/app/[locale]/dashboard/settings/users/page.tsx", + "role": "evidence", + "startLine": 341 + }, + { + "endLine": 80, + "path": "apps/frontend/src/components/ExportButton.tsx", + "role": "root_control", + "startLine": 65 + } + ], + "occurrenceId": "occ_1a8dda002db49396d99ce0b5", + "preventiveControls": [ + "Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource." + ], + "provenance": { + "candidateId": "front-2", + "originalCandidates": [ + { + "attacker": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software", + "confidence": "medium", + "control": "CSV quote escaping is not formula neutralization.", + "counterevidence": "Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced.", + "cwe": "CWE-1236", + "evidence": "An accepted firstName =1+1 becomes CSV cell \"=1+1\"; strings containing formula expressions are retained.", + "flow": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.", + "line": 75, + "path": "apps/frontend/src/components/ExportButton.tsx", + "remediation": "Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests.", + "severity": "medium", + "title": "CSV export interprets user-controlled names as spreadsheet formulas" + } + ], + "source": "local_plugin" + }, + "remediation": "Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests.", + "remediationTests": [ + "Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es." + ], + "rootCause": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "summary": "CSV quote escaping is not formula neutralization. UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active." + }, + "ruleId": "formula-injection.csv-export", + "severity": { + "changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.", + "level": "low", + "rationale": "Attaque limit\u00e9e \u00e0 des coll\u00e8gues et n\u00e9cessitant une ouverture dans un tableur qui interpr\u00e8te les formules. Aucune ex\u00e9cution syst\u00e8me ni exfiltration automatique d\u00e9montr\u00e9e." + }, + "summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.", + "taxonomy": { + "category": "Authorization / security control", + "cwe": [ + "CWE-1236" + ] + }, + "title": "Les exports CSV conservent les formules inject\u00e9es", + "validation": { + "evidenceRefs": [ + "e0", + "e1", + "e2" + ], + "limitations": [ + "Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau." + ], + "method": "static source trace", + "summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. Contre-preuves : Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced." + } + } + ], + "scanId": "4c194468-0b5f-4f24-9005-5be211dc0e47", + "schemaVersion": "1.0" +} diff --git a/docs/security/check-secu/report.md b/docs/security/check-secu/report.md new file mode 100644 index 0000000..ad9d3d2 --- /dev/null +++ b/docs/security/check-secu/report.md @@ -0,0 +1,4098 @@ +# Security Review: xpeditis2.0 copy + +## Scope + +Audit statique transversal sur check_secu, révision 8446f879b676b303fdb2891388f88ff7e43f5fea. + +- Scan mode: repository +- Target kind: git_revision +- Target ID: target_sha256_ddfe0183466d4153b86e8f190318b958432df21c7d3bcaec8c57c2564c3f1208 +- Revision: 8446f879b676b303fdb2891388f88ff7e43f5fea +- Inventory strategy: repository +- Included paths: . +- Excluded paths: none +- Runtime or test status: Aucun test de pénétration ni exécution du produit ; vérification des bibliothèques installées par lecture. +- Artifacts reviewed: apps/backend/src/app.module.ts, apps/backend/src/application/api-keys/api-keys.service.ts, apps/backend/src/application/auth/auth.service.ts, apps/backend/src/application/auth/jwt.strategy.ts, apps/backend/src/application/controllers/audit.controller.ts, apps/backend/src/application/controllers/auth.controller.ts, apps/backend/src/application/controllers/bookings.controller.ts, apps/backend/src/application/controllers/csv-booking-actions.controller.ts, apps/backend/src/application/controllers/gdpr.controller.ts, apps/backend/src/application/controllers/invitations.controller.ts, apps/backend/src/application/controllers/notifications.controller.ts, apps/backend/src/application/controllers/organizations.controller.ts, apps/backend/src/application/controllers/subscriptions.controller.ts, apps/backend/src/application/controllers/users.controller.ts, apps/backend/src/application/controllers/webhooks.controller.ts, apps/backend/src/application/csv-bookings/csv-bookings.module.ts, apps/backend/src/application/dashboard/dashboard.controller.ts, apps/backend/src/application/dto/organization.dto.ts, apps/backend/src/application/dto/subscription.dto.ts, apps/backend/src/application/dto/user.dto.ts, apps/backend/src/application/gateways/notifications.gateway.ts, apps/backend/src/application/guards/api-key-or-jwt.guard.ts, apps/backend/src/application/guards/feature-flag.guard.ts, apps/backend/src/application/guards/jwt-auth.guard.ts, apps/backend/src/application/guards/roles.guard.ts, apps/backend/src/application/guards/throttle.guard.ts, apps/backend/src/application/logs/logs.controller.ts, apps/backend/src/application/mcp/capabilities/account.capabilities.ts, apps/backend/src/application/mcp/capabilities/admin.capabilities.ts, apps/backend/src/application/mcp/capabilities/bookings.capabilities.ts, apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts, apps/backend/src/application/mcp/capabilities/rates.capabilities.ts, apps/backend/src/application/mcp/capability.registry.ts, apps/backend/src/application/mcp/capability.ts, apps/backend/src/application/mcp/mcp.controller.ts, apps/backend/src/application/notifications/notifications.module.ts, apps/backend/src/application/services/analytics.service.ts, apps/backend/src/application/services/fuzzy-search.service.ts, apps/backend/src/application/services/gdpr.service.ts, apps/backend/src/application/services/invitation.service.ts, apps/backend/src/application/services/notification.service.ts, apps/backend/src/application/services/subscription.service.ts, apps/backend/src/application/services/webhook.service.ts, apps/backend/src/application/trade-assistant/trade-assistant.controller.ts, apps/backend/src/application/trade-assistant/trade-assistant.service.ts, apps/backend/src/domain/entities/subscription.entity.ts, apps/backend/src/domain/entities/user.entity.ts, apps/backend/src/domain/services/booking.service.ts, apps/backend/src/domain/services/capability-access.ts, apps/backend/src/domain/value-objects/subscription-plan.vo.ts, apps/backend/src/domain/value-objects/subscription-status.vo.ts, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts, apps/backend/src/infrastructure/pdf/pdf.adapter.ts, apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts, apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts, apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository.ts, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository.ts, apps/backend/src/infrastructure/security/security.config.ts, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts, apps/backend/src/infrastructure/stripe/stripe.adapter.ts, apps/backend/src/main.ts, apps/frontend/Dockerfile, apps/frontend/app/\[locale\]/layout.tsx, apps/frontend/app/\[locale\]/login/page.tsx, apps/frontend/app/api/health/route.ts, apps/frontend/i18n/navigation.ts, apps/frontend/i18n/request.ts, apps/frontend/i18n/routing.ts, apps/frontend/lib/api/client.ts, apps/frontend/middleware.ts, apps/frontend/next.config.js, apps/frontend/package.json, apps/frontend/src/components/ExportButton.tsx, apps/frontend/src/components/assistant/answer-text.tsx, apps/frontend/src/components/assistant/message-list.tsx, apps/frontend/src/components/notifications/notification-row.tsx, apps/frontend/src/components/providers.tsx, apps/frontend/src/hooks/use-url-state.ts, apps/frontend/src/lib/api/client.ts, apps/frontend/src/lib/context/auth-context.tsx, apps/frontend/src/utils/export.ts, apps/frontend/tsconfig.json, apps/log-exporter/Dockerfile, apps/log-exporter/package.json, apps/log-exporter/src/index.js, docker/docker-compose.full.yml, infra/logging/loki/loki-config.yml, infra/prod/k8s/base/06-log-exporter.yaml, infra/prod/k8s/base/08-traefik-middlewares.yaml, infra/prod/k8s/base/09-ingress.yaml, infra/prod/k8s/base/10-network-policies.yaml +- Scan context: Modèle de menace généré depuis le code et revu indépendamment ; aucun modèle utilisateur. + +Limitations and exclusions: +- Couverture source partielle ; aucune attestation d’absence de vulnérabilités. +- Fichiers .env/.env.\* interdits, non lus. +- Pas d’audit CVE en ligne, de déploiement réel, des secrets actifs, permissions cloud ou historique Git. +- Excluded \*\*/.env\*: Restriction explicite de lecture. +- Excluded \*\*/.env\*: User prohibits .env and .env.\* reads. + +### Scan Summary + +| Field | Value | +| --- | --- | +| Scan outcome | completed | +| Reportable findings | 14 | +| Severity mix | high: 2, medium: 9, low: 3 | +| Confidence mix | high: 12, medium: 2 | +| Coverage | partial | +| Validation mode | Static source trace | + +Canonical artifacts: `scan-manifest.json`, `findings.json`, and `coverage.json`. This report is a deterministic projection of those files. + +## Threat Model + +Xpeditis freight platform uses Nest API with relational storage, CSV shipping rates, booking documents, subscription payments, MCP and AI assistant. Global ApiKeyOrJwtGuard and throttling protect normal API routes; public carrier links and Stripe webhook have separate authority checks (apps/backend/src/app.module.ts:210; apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/controllers/subscriptions.controller.ts:262). Production manifests describe Kubernetes plus private PostgreSQL and external object storage; actual deployment state is not supplied. + +### Assets + +- User sessions, API-key authority, organization booking data and subscription entitlements; API-key/JWT authentication paths are distinct (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34). +- Booking documents, freight rate integrity, published blog assets, AI conversation history and tool access. + +### Trust Boundaries + +- Browser to API: JWT extraction accepts httpOnly accessToken cookie; auth endpoints set cookies and security config defaults SameSite=lax with production Secure (apps/backend/src/application/auth/jwt.strategy.ts:40; apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/infrastructure/security/security.config.ts:195). Helmet/CORS/validation are applied at startup (apps/backend/src/main.ts:33; apps/backend/src/main.ts:42; apps/backend/src/main.ts:54). +- External API key caller to application: key validation supplies user context; absent key falls back to JWT (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34). +- MCP tools/list visibility is separate from invocation enforcement: registry checks role and plan again and parses schema before handler execution, recording audit (apps/backend/src/application/mcp/capability.registry.ts:85; apps/backend/src/application/mcp/capability.registry.ts:100). +- AI invocation is bound to authenticated actor and uses the same capability registry; capability scope is not inherently read-only. Quota reserved before model request (apps/backend/src/application/trade-assistant/trade-assistant.service.ts:146; apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216). +- Carrier email token permits public accept/reject actions; document delivery independently requires accepted booking and password when hash exists (apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/services/csv-booking.service.ts:729; apps/backend/src/application/services/csv-booking.service.ts:848). +- Stripe webhook is public and passes raw request body/signature to service, with adapter constructEvent verification using configured webhook secret (apps/backend/src/application/controllers/subscriptions.controller.ts:262; apps/backend/src/infrastructure/stripe/stripe.adapter.ts:251). + +### Attacker Capabilities + +- Unauthenticated caller can request public endpoints and supply arbitrary ordinary request input, but is not assumed to possess carrier token, password, Stripe signing secret, administrative API key or deployment control. +- Authenticated organization user controls their requests and AI questions; crossing into another tenant, administrative capability or higher-plan entitlement would be a new authority gain. MCP visibility alone is not permission evidence; registry invocation enforces policy (apps/backend/src/application/mcp/capability.registry.ts:85). +- Privileged CSV configuration/import and release operators are separate conditional workflows; ordinary remote callers are not assumed to control local files, deployment variables, or migration scripts. + +### Security Objectives + +- Preserve organization and document ownership across API, MCP and AI handlers; evaluate handler-level scoping separately from global authentication. +- Keep public token capabilities scoped to intended booking and action; enforce additional document password/state controls at every document consumer (apps/backend/src/application/services/csv-booking.service.ts:848). +- Bind financial state changes to verified Stripe events; protect credentials and sensitive object contents with actual consumed storage/database configuration. +- Retain effective resource distinctions: CSV configured bucket, document/PDF/blog hardcoded buckets, and distinct database startup versus migration TLS behavior. + +### Assumptions + +- User origin: requested complete security audit on new check_secu branch from current branch; this independent review performs architecture mapping only. No supplied threat model or knowledge base. +- No first-party SECURITY.md found by resolver inventory; only vendored node_modules policies exist. No .env files read. +- ConfigMap DATABASE_SSL=true and hostssl comments do not mean runtime clients consume TLS: app.module options and startup script omit ssl; CLI data-source consumes true but disables certificate validation (apps/backend/src/app.module.ts:165; apps/backend/scripts/setup/startup.js:13; apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26; infra/prod/k8s/base/02-configmap-backend.yaml:47). +- ConfigMap AWS_S3_BUCKET=xpeditis-prod-documents affects CSV object loading; separate booking documents/PDF/blog consumers hardcode other buckets. Object-store policies and provisioned bucket existence remain external prerequisites (infra/prod/k8s/base/02-configmap-backend.yaml:71; apps/backend/src/application/services/csv-booking.service.ts:1269; apps/backend/src/application/services/booking-automation.service.ts:100; apps/backend/src/application/controllers/blog.controller.ts:23). +- Current code uses httpOnly auth cookies; repository overview claiming localStorage token architecture is not sufficient evidence of current implementation (apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/application/auth/jwt.strategy.ts:40). +- Coverage is architectural, not a completed vulnerability audit. External IAM, deployed networking, CI secrets, tenant enforcement of every handler, refresh lifecycle and carrier-token entropy/expiry are not fully established by this pass. +- Nest TypeORM / production ConfigMap: DATABASE_HOST/PORT/NAME from ConfigService; DATABASE_SSL declared but absent from TypeORM options =\> 10.10.1.20:5432/xpeditis_prod; no explicit TLS option. Contrôle: synchronize:false; server pg_hba controls admission. Runtime factory does not consume DATABASE_SSL; deployment success and ambient driver options remain unknown Sources: apps/backend/src/app.module.ts:165, infra/prod/k8s/base/02-configmap-backend.yaml:44 +- TypeORM migration CLI / production migration Job: Job calls compiled data-source; DATABASE_SSL=true from ConfigMap =\> 10.10.1.20:5432/xpeditis_prod with ssl.rejectUnauthorized=false. Contrôle: TLS encryption without certificate validation in data-source. Sources: infra/prod/k8s/base/07-migration-job.yaml:52, apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26 +- Startup pg client and migration DataSource / image startup script: DATABASE_\* directly consumed; no ssl option =\> configured PostgreSQL target, including prod target when prod ConfigMap injected. Contrôle: database credential and server admission. Different TLS behavior from migration Job; Job explicitly documents this at 07-migration-job.yaml:52 Sources: apps/backend/scripts/setup/startup.js:13, apps/backend/scripts/setup/startup.js:40 +- CSV object loader / production/object-storage configured: company config metadata.minioObjectKey; AWS_S3_BUCKET from ConfigMap; storage adapter AWS_S3_ENDPOINT =\> https://fsn1.your-objectstorage.com/xpeditis-prod-documents/{metadata.minioObjectKey}. Contrôle: S3 credential permissions; fallback to local file on error. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:149, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts:244, infra/prod/k8s/base/02-configmap-backend.yaml:70 +- CSV local loader / local and object-store fallback: absolute filePath unchanged; otherwise process.cwd()/src/infrastructure/storage/csv-storage/rates joined with filePath =\> {cwd}/src/infrastructure/storage/csv-storage/rates/{relative filePath}, or absolute filePath. Contrôle: host filesystem permissions and administrative configuration authority. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:125, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:164, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:287 +- Booking document upload/download / all S3 deployments including prod: hardcoded bucket; document key constructed in service; endpoint from adapter =\> xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{originalFilename}; prod endpoint https://fsn1.your-objectstorage.com. Contrôle: carrier token, ACCEPTED status, password hash when present, document belongs to token booking. AWS_S3_BUCKET=xpeditis-prod-documents does not select this bucket Sources: apps/backend/src/application/services/csv-booking.service.ts:1269, apps/backend/src/application/services/csv-booking.service.ts:848, apps/backend/src/application/services/csv-booking.service.ts:866 +- Booking PDF automation / all S3 deployments: hardcoded bucket and booking-derived key =\> xpeditis-bookings/bookings/{booking.id}/{booking.bookingNumber.value}.pdf. Contrôle: backend automation and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/services/booking-automation.service.ts:98 +- Blog image API / all S3 deployments: hardcoded bucket; public route constructs blog-images filename key =\> xpeditis-blog/blog-images/{filename}. Contrôle: public publication workflow and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/controllers/blog.controller.ts:23, apps/backend/src/application/controllers/blog.controller.ts:27, apps/backend/src/application/controllers/blog.controller.ts:76 +- Trade assistant AI / configured OPENAI_API_KEY: fixed Responses endpoint; OPENAI_MODEL defaults gpt-4.1-mini =\> https://api.openai.com/v1/responses; question/history/passages and invoked tool outcomes. Contrôle: actor-bound registry invocation; 4 tool rounds, 800 output tokens, store:false, 30 second timeout. Sources: apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:51, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:115, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:162, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216 + +## Findings + +| Finding | Severity | Confidence | Detailed write-up | +| --- | --- | --- | --- | +| [La redirection de connexion permet une XSS DOM](#finding-1) | high | high | inline below | +| [Un manager peut modifier une autre organisation](#finding-2) | high | high | inline below | +| [Les téléversements ne bornent pas la mémoire utilisée](#finding-3) | medium | high | inline below | +| [Les WebSockets acceptent des sessions révoquées ou désactivées](#finding-4) | medium | high | inline below | +| [Le client reçoit le jeton de réponse du transporteur](#finding-5) | medium | high | inline below | +| [Les logs contiennent mots de passe et invitations](#finding-6) | medium | high | inline below | +| [La résiliation peut conserver les avantages payants](#finding-7) | medium | high | inline below | +| [VIEWER peut créer et modifier des réservations](#finding-8) | medium | high | inline below | +| [Un membre peut marquer toutes les notifications comme lues](#finding-9) | medium | high | inline below | +| [Le changement de mot de passe conserve les anciennes sessions](#finding-10) | medium | high | inline below | +| [Une clé SMTP figure dans un fichier suivi](#finding-11) | medium | medium | inline below | +| [Les exports CSV conservent les formules injectées](#finding-12) | low | medium | inline below | +| [Les dossiers des collègues sont accessibles sans rôle de gestion](#finding-13) | low | high | inline below | +| [Un manager peut rétrograder un administrateur de son organisation](#finding-14) | low | high | inline below | + +### Confidence Scale + +| Label | Meaning | +| --- | --- | +| high | Direct evidence supports the finding with no material unresolved blocker. | +| medium | Evidence supports a plausible issue, but material runtime or reachability proof remains. | +| low | Evidence is incomplete and the item is retained only for explicit follow-up. | + + + +### [1] La redirection de connexion permet une XSS DOM + +| Field | Value | +| --- | --- | +| Severity | high | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically. | +| Category | Authorization / security control | +| CWE | CWE-79 | +| Affected lines | apps/frontend/app/\[locale\]/login/page.tsx:95-99, apps/frontend/app/\[locale\]/login/page.tsx:162-167, apps/frontend/src/lib/context/auth-context.tsx:105-110, apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175, apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105, apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403 | + +#### Summary + +login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. + +#### Root Cause + +No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation. login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. + +**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const { login } = useAuth(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get('redirect') || '/dashboard'; + const tLogin = useTranslations('auth.login'); + const tPanel = useTranslations('auth.sidePanel'); +``` + +**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167` + +Étape du parcours source décrit dans la cause et la validation. + +``` + setIsLoading(true); + + try { + await login(email, password, redirectTo, rememberMe); + } catch (err: any) { + const { message, field } = mapLoginError(err, tLogin); +``` + +**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110` + +Étape du parcours source décrit dans la cause et la validation. + +``` + try { + await apiLogin({ email, password, rememberMe }); + // Fetch complete user profile after login (session lives in httpOnly cookies) + const currentUser = await getCurrentUser(); + setUser(currentUser); + router.push(redirectTo); +``` + +**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function useNavigate(dispatch) { + return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{ + const url = new URL((0, _addbasepath.addBasePath)(href), location.href); + return dispatch({ + type: _routerreducertypes.ACTION_NAVIGATE, + url, + isExternalUrl: isExternalURL(url), + locationSearch: location.search, + shouldScroll: shouldScroll != null ? shouldScroll : true, +``` + +**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const pendingPush = navigateType === "push"; + // we want to prune the prefetch cache on every navigation to avoid it growing too large + (0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache); + mutable.preserveCustomHistoryState = false; + if (isExternalUrl) { + return handleExternalUrl(state, mutable, url.toString(), pendingPush); + } + const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({ +``` + +**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (pushRef.mpaNavigation) { + // if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL + if (globalMutable.pendingMpaPath !== canonicalUrl) { + const location1 = window.location; + if (pushRef.pendingPush) { + location1.assign(canonicalUrl); + } else { + location1.replace(canonicalUrl); +``` + +#### Validation + +login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. Contre-preuves : HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically. + +Validation method: static source trace + +**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const { login } = useAuth(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get('redirect') || '/dashboard'; + const tLogin = useTranslations('auth.login'); + const tPanel = useTranslations('auth.sidePanel'); +``` + +**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167` + +Étape du parcours source décrit dans la cause et la validation. + +``` + setIsLoading(true); + + try { + await login(email, password, redirectTo, rememberMe); + } catch (err: any) { + const { message, field } = mapLoginError(err, tLogin); +``` + +**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110` + +Étape du parcours source décrit dans la cause et la validation. + +``` + try { + await apiLogin({ email, password, rememberMe }); + // Fetch complete user profile after login (session lives in httpOnly cookies) + const currentUser = await getCurrentUser(); + setUser(currentUser); + router.push(redirectTo); +``` + +**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function useNavigate(dispatch) { + return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{ + const url = new URL((0, _addbasepath.addBasePath)(href), location.href); + return dispatch({ + type: _routerreducertypes.ACTION_NAVIGATE, + url, + isExternalUrl: isExternalURL(url), + locationSearch: location.search, + shouldScroll: shouldScroll != null ? shouldScroll : true, +``` + +**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const pendingPush = navigateType === "push"; + // we want to prune the prefetch cache on every navigation to avoid it growing too large + (0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache); + mutable.preserveCustomHistoryState = false; + if (isExternalUrl) { + return handleExternalUrl(state, mutable, url.toString(), pendingPush); + } + const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({ +``` + +**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (pushRef.mpaNavigation) { + // if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL + if (globalMutable.pendingMpaPath !== canonicalUrl) { + const location1 = window.location; + if (pushRef.pendingPush) { + location1.assign(canonicalUrl); + } else { + location1.replace(canonicalUrl); +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. + +- **Source:** Unauthenticated attacker who persuades victim to authenticate using crafted login URL + +- **Sink:** apps/frontend/src/lib/context/auth-context.tsx + +**Source 1** — `apps/frontend/app/\[locale\]/login/page.tsx:95-99` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const { login } = useAuth(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get('redirect') || '/dashboard'; + const tLogin = useTranslations('auth.login'); + const tPanel = useTranslations('auth.sidePanel'); +``` + +**Source 2** — `apps/frontend/app/\[locale\]/login/page.tsx:162-167` + +Étape du parcours source décrit dans la cause et la validation. + +``` + setIsLoading(true); + + try { + await login(email, password, redirectTo, rememberMe); + } catch (err: any) { + const { message, field } = mapLoginError(err, tLogin); +``` + +**Source 3** — `apps/frontend/src/lib/context/auth-context.tsx:105-110` + +Étape du parcours source décrit dans la cause et la validation. + +``` + try { + await apiLogin({ email, password, rememberMe }); + // Fetch complete user profile after login (session lives in httpOnly cookies) + const currentUser = await getCurrentUser(); + setUser(currentUser); + router.push(redirectTo); +``` + +**Source 4** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:167-175` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function useNavigate(dispatch) { + return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{ + const url = new URL((0, _addbasepath.addBasePath)(href), location.href); + return dispatch({ + type: _routerreducertypes.ACTION_NAVIGATE, + url, + isExternalUrl: isExternalURL(url), + locationSearch: location.search, + shouldScroll: shouldScroll != null ? shouldScroll : true, +``` + +**Source 5** — `apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js:98-105` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const pendingPush = navigateType === "push"; + // we want to prune the prefetch cache on every navigation to avoid it growing too large + (0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache); + mutable.preserveCustomHistoryState = false; + if (isExternalUrl) { + return handleExternalUrl(state, mutable, url.toString(), pendingPush); + } + const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({ +``` + +**Source 6** — `apps/frontend/node_modules/next/dist/client/components/app-router.js:396-403` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (pushRef.mpaNavigation) { + // if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL + if (globalMutable.pendingMpaPath !== canonicalUrl) { + const location1 = window.location; + if (pushRef.pendingPush) { + location1.assign(canonicalUrl); + } else { + location1.replace(canonicalUrl); +``` + +#### Reachability + +Unauthenticated attacker who persuades victim to authenticate using crafted login URL. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically. + +- **Attacker:** Unauthenticated attacker who persuades victim to authenticate using crafted login URL + +- **Entry point:** apps/frontend/app/\[locale\]/login/page.tsx + +#### Severity + +**High** — login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [2] Un manager peut modifier une autre organisation + +| Field | Value | +| --- | --- | +| Severity | high | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role. | +| Category | Authorization / security control | +| CWE | CWE-863 | +| Affected lines | apps/backend/src/application/auth/jwt.strategy.ts:75-81, apps/backend/src/application/guards/roles.guard.ts:43-50, apps/backend/src/application/controllers/organizations.controller.ts:241-256, apps/backend/src/application/controllers/organizations.controller.ts:291-304 | + +#### Summary + +PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. + +#### Root Cause + +Seul ADMIN peut modifier une autre organisation. PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. + +**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81` + +Étape du parcours source décrit dans la cause et la validation. + +``` + role: user.role, + organizationId: user.organizationId, + firstName: user.firstName, + lastName: user.lastName, + }; + } +} +``` + +**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Case-insensitive role comparison + const userRole = user.role.toLowerCase(); + const requiredRolesLower = requiredRoles.map(r => r.toLowerCase()); + + return requiredRolesLower.includes(userRole); + } +} +``` + +**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async updateOrganization( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Managers can only update their own organization + if (user.role === 'manager' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only update your own organization'); + } +``` + +**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.isActive !== undefined) { + if (dto.isActive) { + organization.activate(); + } else { + organization.deactivate(); + } + } + + // Save updated organization + const updatedOrg = await this.organizationRepository.save(organization); + + this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); + + return OrganizationMapper.toDto(updatedOrg); +``` + +#### Validation + +PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. Contre-preuves : UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81` + +Étape du parcours source décrit dans la cause et la validation. + +``` + role: user.role, + organizationId: user.organizationId, + firstName: user.firstName, + lastName: user.lastName, + }; + } +} +``` + +**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Case-insensitive role comparison + const userRole = user.role.toLowerCase(); + const requiredRolesLower = requiredRoles.map(r => r.toLowerCase()); + + return requiredRolesLower.includes(userRole); + } +} +``` + +**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async updateOrganization( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Managers can only update their own organization + if (user.role === 'manager' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only update your own organization'); + } +``` + +**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.isActive !== undefined) { + if (dto.isActive) { + organization.activate(); + } else { + organization.deactivate(); + } + } + + // Save updated organization + const updatedOrg = await this.organizationRepository.save(organization); + + this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); + + return OrganizationMapper.toDto(updatedOrg); +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. + +- **Source:** Manager authentifié connaissant l’UUID d’une organisation cible + +- **Sink:** apps/backend/src/application/controllers/organizations.controller.ts + +**Source 1** — `apps/backend/src/application/auth/jwt.strategy.ts:75-81` + +Étape du parcours source décrit dans la cause et la validation. + +``` + role: user.role, + organizationId: user.organizationId, + firstName: user.firstName, + lastName: user.lastName, + }; + } +} +``` + +**Source 2** — `apps/backend/src/application/guards/roles.guard.ts:43-50` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Case-insensitive role comparison + const userRole = user.role.toLowerCase(); + const requiredRolesLower = requiredRoles.map(r => r.toLowerCase()); + + return requiredRolesLower.includes(userRole); + } +} +``` + +**Source 3** — `apps/backend/src/application/controllers/organizations.controller.ts:241-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async updateOrganization( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateOrganizationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Updating organization: ${id}`); + + const organization = await this.organizationRepository.findById(id); + if (!organization) { + throw new NotFoundException(`Organization ${id} not found`); + } + + // Authorization: Managers can only update their own organization + if (user.role === 'manager' && organization.id !== user.organizationId) { + throw new ForbiddenException('You can only update your own organization'); + } +``` + +**Source 4** — `apps/backend/src/application/controllers/organizations.controller.ts:291-304` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.isActive !== undefined) { + if (dto.isActive) { + organization.activate(); + } else { + organization.deactivate(); + } + } + + // Save updated organization + const updatedOrg = await this.organizationRepository.save(organization); + + this.logger.log(`Organization updated successfully: ${updatedOrg.id}`); + + return OrganizationMapper.toDto(updatedOrg); +``` + +#### Reachability + +Manager authentifié connaissant l’UUID d’une organisation cible. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role. + +- **Attacker:** Manager authentifié connaissant l’UUID d’une organisation cible + +- **Entry point:** apps/backend/src/application/auth/jwt.strategy.ts + +#### Severity + +**High** — PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne vérifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonnées, identifiants et statut d’une autre organisation et recevoir sa fiche. UUID cible requis ; aucun accès anonyme. Le rôle reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Refuser tout appel non ADMIN dont la cible diffère de l’organisation de la session ; utiliser l’enum de rôle et appliquer le prédicat dans le service. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [3] Les téléversements ne bornent pas la mémoire utilisée + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test. | +| Category | Authorization / security control | +| CWE | CWE-400 | +| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/application/controllers/csv-bookings.controller.ts:88, apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37, apps/backend/src/infrastructure/security/security.config.ts:179-194, apps/backend/node_modules/multer/index.js:11-23, apps/backend/node_modules/multer/storage/memory.js:3-12, apps/backend/node_modules/busboy/lib/types/multipart.js:250-256 | + +#### Summary + +POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. + +#### Root Cause + +Single upload requests must have bounded resource use before buffering POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37` + +Étape du parcours source décrit dans la cause et la validation. + +``` +@Module({ + imports: [ + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), + ConfigModule, + NotificationsModule, + EmailModule, + StorageModule, + SubscriptionsModule, + StripeModule, + ], +``` + +**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */ + session: 'xpeditis_session', +} as const; + +export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): { + httpOnly: boolean; + secure: boolean; + sameSite: 'lax' | 'strict' | 'none'; + path: string; + domain?: string; + maxAge?: number; +} { + // SameSite must be 'none' when the frontend and the API live on different + // sites (cross-origin), otherwise the browser drops the auth cookies set in + // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS). + // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups. +``` + +**Source 5** — `apps/backend/node_modules/multer/index.js:11-23` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function Multer (options) { + if (options.storage) { + this.storage = options.storage + } else if (options.dest) { + this.storage = diskStorage({ destination: options.dest }) + } else { + this.storage = memoryStorage() + } + + this.limits = options.limits + this.preservePath = options.preservePath + this.fileFilter = options.fileFilter || allowAll +} +``` + +**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function MemoryStorage (opts) {} + +MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) { + file.stream.pipe(concat({ encoding: 'buffer' }, function (data) { + cb(null, { + buffer: data, + size: data.length + }) + })) +} +``` + +**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const limits = cfg.limits; + const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number' + ? limits.fieldSize + : 1 * 1024 * 1024); + const fileSizeLimit = (limits && typeof limits.fileSize === 'number' + ? limits.fileSize + : Infinity); +``` + +#### Validation + +POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Contre-preuves : Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37` + +Étape du parcours source décrit dans la cause et la validation. + +``` +@Module({ + imports: [ + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), + ConfigModule, + NotificationsModule, + EmailModule, + StorageModule, + SubscriptionsModule, + StripeModule, + ], +``` + +**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */ + session: 'xpeditis_session', +} as const; + +export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): { + httpOnly: boolean; + secure: boolean; + sameSite: 'lax' | 'strict' | 'none'; + path: string; + domain?: string; + maxAge?: number; +} { + // SameSite must be 'none' when the frontend and the API live on different + // sites (cross-origin), otherwise the browser drops the auth cookies set in + // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS). + // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups. +``` + +**Source 5** — `apps/backend/node_modules/multer/index.js:11-23` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function Multer (options) { + if (options.storage) { + this.storage = options.storage + } else if (options.dest) { + this.storage = diskStorage({ destination: options.dest }) + } else { + this.storage = memoryStorage() + } + + this.limits = options.limits + this.preservePath = options.preservePath + this.fileFilter = options.fileFilter || allowAll +} +``` + +**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function MemoryStorage (opts) {} + +MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) { + file.stream.pipe(concat({ encoding: 'buffer' }, function (data) { + cb(null, { + buffer: data, + size: data.length + }) + })) +} +``` + +**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const limits = cfg.limits; + const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number' + ? limits.fieldSize + : 1 * 1024 * 1024); + const fileSizeLimit = (limits && typeof limits.fileSize === 'number' + ? limits.fileSize + : Infinity); +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. + +- **Source:** Any authenticated account, including newly registered free-plan user + +- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 3** — `apps/backend/src/application/csv-bookings/csv-bookings.module.ts:28-37` + +Étape du parcours source décrit dans la cause et la validation. + +``` +@Module({ + imports: [ + TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]), + ConfigModule, + NotificationsModule, + EmailModule, + StorageModule, + SubscriptionsModule, + StripeModule, + ], +``` + +**Source 4** — `apps/backend/src/infrastructure/security/security.config.ts:179-194` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */ + session: 'xpeditis_session', +} as const; + +export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): { + httpOnly: boolean; + secure: boolean; + sameSite: 'lax' | 'strict' | 'none'; + path: string; + domain?: string; + maxAge?: number; +} { + // SameSite must be 'none' when the frontend and the API live on different + // sites (cross-origin), otherwise the browser drops the auth cookies set in + // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS). + // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups. +``` + +**Source 5** — `apps/backend/node_modules/multer/index.js:11-23` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function Multer (options) { + if (options.storage) { + this.storage = options.storage + } else if (options.dest) { + this.storage = diskStorage({ destination: options.dest }) + } else { + this.storage = memoryStorage() + } + + this.limits = options.limits + this.preservePath = options.preservePath + this.fileFilter = options.fileFilter || allowAll +} +``` + +**Source 6** — `apps/backend/node_modules/multer/storage/memory.js:3-12` + +Étape du parcours source décrit dans la cause et la validation. + +``` +function MemoryStorage (opts) {} + +MemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) { + file.stream.pipe(concat({ encoding: 'buffer' }, function (data) { + cb(null, { + buffer: data, + size: data.length + }) + })) +} +``` + +**Source 7** — `apps/backend/node_modules/busboy/lib/types/multipart.js:250-256` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const limits = cfg.limits; + const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number' + ? limits.fieldSize + : 1 * 1024 * 1024); + const fileSizeLimit = (limits && typeof limits.fileSize === 'number' + ? limits.fileSize + : Infinity); +``` + +#### Reachability + +Any authenticated account, including newly registered free-plan user. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test. + +- **Attacker:** Any authenticated account, including newly registered free-plan user + +- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +#### Severity + +**Medium** — POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [4] Les WebSockets acceptent des sessions révoquées ou désactivées + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover. | +| Category | Authorization / security control | +| CWE | CWE-287 | +| Affected lines | apps/backend/src/application/gateways/notifications.gateway.ts:60-61, apps/backend/src/application/gateways/notifications.gateway.ts:60-82, apps/backend/src/application/notifications/notifications.module.ts:21-28, apps/backend/src/application/auth/auth.service.ts:234-253, apps/backend/src/application/auth/jwt.strategy.ts:60-72 | + +#### Summary + +Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. + +#### Root Cause + +Notifications must require a current active account and access-token authentication Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; + + // Store socket connection for user + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)!.add(client.id); + + // Store user ID in socket data for later use + client.data.userId = userId; + client.data.organizationId = payload.organizationId; + + // Join user-specific room + client.join(`user:${userId}`); + + this.logger.log(`Client ${client.id} connected for user ${userId}`); + + // Send unread count on connection + const unreadCount = await this.notificationService.getUnreadCount(userId); + client.emit('unread_count', { count: unreadCount }); + + // Send recent notifications on connection +``` + +**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28` + +Étape du parcours source décrit dans la cause et la validation. + +``` + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'), + }, + }), +``` + +**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid token type'); + } + + // Validate user exists and is active + const user = await this.authService.validateUser(payload); + + if (!user) { + throw new UnauthorizedException('User not found or inactive'); + } + + // This object will be attached to request.user + return { +``` + +#### Validation + +Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. Contre-preuves : JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; + + // Store socket connection for user + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)!.add(client.id); + + // Store user ID in socket data for later use + client.data.userId = userId; + client.data.organizationId = payload.organizationId; + + // Join user-specific room + client.join(`user:${userId}`); + + this.logger.log(`Client ${client.id} connected for user ${userId}`); + + // Send unread count on connection + const unreadCount = await this.notificationService.getUnreadCount(userId); + client.emit('unread_count', { count: unreadCount }); + + // Send recent notifications on connection +``` + +**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28` + +Étape du parcours source décrit dans la cause et la validation. + +``` + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'), + }, + }), +``` + +**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid token type'); + } + + // Validate user exists and is active + const user = await this.authService.validateUser(payload); + + if (!user) { + throw new UnauthorizedException('User not found or inactive'); + } + + // This object will be attached to request.user + return { +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. + +- **Source:** Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout + +- **Sink:** apps/backend/src/application/gateways/notifications.gateway.ts + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-61` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:60-82` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(token); + const userId = payload.sub; + + // Store socket connection for user + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)!.add(client.id); + + // Store user ID in socket data for later use + client.data.userId = userId; + client.data.organizationId = payload.organizationId; + + // Join user-specific room + client.join(`user:${userId}`); + + this.logger.log(`Client ${client.id} connected for user ${userId}`); + + // Send unread count on connection + const unreadCount = await this.notificationService.getUnreadCount(userId); + client.emit('unread_count', { count: unreadCount }); + + // Send recent notifications on connection +``` + +**Source 3** — `apps/backend/src/application/notifications/notifications.module.ts:21-28` + +Étape du parcours source décrit dans la cause et la validation. + +``` + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'), + }, + }), +``` + +**Source 4** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 5** — `apps/backend/src/application/auth/jwt.strategy.ts:60-72` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid token type'); + } + + // Validate user exists and is active + const user = await this.authService.validateUser(payload); + + if (!user) { + throw new UnauthorizedException('User not found or inactive'); + } + + // This object will be attached to request.user + return { +``` + +#### Reachability + +Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover. + +- **Attacker:** Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout + +- **Entry point:** apps/backend/src/application/gateways/notifications.gateway.ts + +#### Severity + +**Medium** — Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [5] Le client reçoit le jeton de réponse du transporteur + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision. | +| Category | Authorization / security control | +| CWE | CWE-863 | +| Affected lines | apps/backend/src/application/services/csv-booking.service.ts:1608-1612, apps/backend/src/application/services/csv-booking.service.ts:244, apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47, apps/backend/src/application/services/csv-booking.service.ts:886-908, apps/backend/src/domain/entities/csv-booking.entity.ts:55-65 | + +#### Summary + +Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. + +#### Root Cause + +Only the carrier receiving the email credential may accept or reject a booking Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. + +**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612` + +Étape du parcours source décrit dans la cause et la validation. + +``` + status: booking.status, + documents: booking.documents.map(this.toDocumentDto), + confirmationToken: booking.confirmationToken, + requestedAt: booking.requestedAt, + respondedAt: booking.respondedAt || null, +``` + +**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244` + +Étape du parcours source décrit dans la cause et la validation. + +``` + return this.toResponseDto(savedBooking); +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Public() + @Get('accept/:token') + @ApiOperation({ + summary: 'Accept booking request (public)', + description: + 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking accepted successfully.', + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ + status: 400, + description: 'Booking cannot be accepted (invalid status or expired)', + }) + async acceptBooking(@Param('token') token: string) { + // Accept the booking + const booking = await this.csvBookingService.acceptBooking(token); +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async acceptBooking(token: string): Promise { + this.logger.log(`Accepting booking with token: ${token}`); + + const booking = await this.csvBookingRepository.findByToken(token); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + // Get ORM entity for bookingNumber + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + // Accept the booking (domain logic validates status) + booking.accept(); + + // Apply the flat per-booking service fee (forfait par booking) from the org's plan + const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); + booking.applyBookingFee(bookingFeeEur); + this.logger.log( + `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` + ); +``` + +**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65` + +Étape du parcours source décrit dans la cause et la validation. + +``` + * This is a simplified booking workflow for CSV-based rates where the user + * selects a rate and sends a booking request to the carrier with documents. + * + * Business Rules: + * - Booking can only be accepted/rejected when status is PENDING + * - Once accepted/rejected, status cannot be changed + * - Booking expires after 7 days if not responded to + * - At least one document is required for booking creation + * - Confirmation token is used for email accept/reject links + * - Only carrier can accept/reject via email link + * - User can cancel pending bookings +``` + +#### Validation + +Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Contre-preuves : Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612` + +Étape du parcours source décrit dans la cause et la validation. + +``` + status: booking.status, + documents: booking.documents.map(this.toDocumentDto), + confirmationToken: booking.confirmationToken, + requestedAt: booking.requestedAt, + respondedAt: booking.respondedAt || null, +``` + +**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244` + +Étape du parcours source décrit dans la cause et la validation. + +``` + return this.toResponseDto(savedBooking); +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Public() + @Get('accept/:token') + @ApiOperation({ + summary: 'Accept booking request (public)', + description: + 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking accepted successfully.', + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ + status: 400, + description: 'Booking cannot be accepted (invalid status or expired)', + }) + async acceptBooking(@Param('token') token: string) { + // Accept the booking + const booking = await this.csvBookingService.acceptBooking(token); +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async acceptBooking(token: string): Promise { + this.logger.log(`Accepting booking with token: ${token}`); + + const booking = await this.csvBookingRepository.findByToken(token); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + // Get ORM entity for bookingNumber + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + // Accept the booking (domain logic validates status) + booking.accept(); + + // Apply the flat per-booking service fee (forfait par booking) from the org's plan + const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); + booking.applyBookingFee(bookingFeeEur); + this.logger.log( + `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` + ); +``` + +**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65` + +Étape du parcours source décrit dans la cause et la validation. + +``` + * This is a simplified booking workflow for CSV-based rates where the user + * selects a rate and sends a booking request to the carrier with documents. + * + * Business Rules: + * - Booking can only be accepted/rejected when status is PENDING + * - Once accepted/rejected, status cannot be changed + * - Booking expires after 7 days if not responded to + * - At least one document is required for booking creation + * - Confirmation token is used for email accept/reject links + * - Only carrier can accept/reject via email link + * - User can cancel pending bookings +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. + +- **Source:** Booking creator or another organization member reading organization/all + +- **Sink:** apps/backend/src/application/services/csv-booking.service.ts + +**Source 1** — `apps/backend/src/application/services/csv-booking.service.ts:1608-1612` + +Étape du parcours source décrit dans la cause et la validation. + +``` + status: booking.status, + documents: booking.documents.map(this.toDocumentDto), + confirmationToken: booking.confirmationToken, + requestedAt: booking.requestedAt, + respondedAt: booking.respondedAt || null, +``` + +**Source 2** — `apps/backend/src/application/services/csv-booking.service.ts:244` + +Étape du parcours source décrit dans la cause et la validation. + +``` + return this.toResponseDto(savedBooking); +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28-47` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Public() + @Get('accept/:token') + @ApiOperation({ + summary: 'Accept booking request (public)', + description: + 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Booking accepted successfully.', + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ + status: 400, + description: 'Booking cannot be accepted (invalid status or expired)', + }) + async acceptBooking(@Param('token') token: string) { + // Accept the booking + const booking = await this.csvBookingService.acceptBooking(token); +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:886-908` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async acceptBooking(token: string): Promise { + this.logger.log(`Accepting booking with token: ${token}`); + + const booking = await this.csvBookingRepository.findByToken(token); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + // Get ORM entity for bookingNumber + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + // Accept the booking (domain logic validates status) + booking.accept(); + + // Apply the flat per-booking service fee (forfait par booking) from the org's plan + const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); + booking.applyBookingFee(bookingFeeEur); + this.logger.log( + `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` + ); +``` + +**Source 5** — `apps/backend/src/domain/entities/csv-booking.entity.ts:55-65` + +Étape du parcours source décrit dans la cause et la validation. + +``` + * This is a simplified booking workflow for CSV-based rates where the user + * selects a rate and sends a booking request to the carrier with documents. + * + * Business Rules: + * - Booking can only be accepted/rejected when status is PENDING + * - Once accepted/rejected, status cannot be changed + * - Booking expires after 7 days if not responded to + * - At least one document is required for booking creation + * - Confirmation token is used for email accept/reject links + * - Only carrier can accept/reject via email link + * - User can cancel pending bookings +``` + +#### Reachability + +Booking creator or another organization member reading organization/all. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision. + +- **Attacker:** Booking creator or another organization member reading organization/all + +- **Entry point:** apps/backend/src/application/services/csv-booking.service.ts + +#### Severity + +**Medium** — Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [6] Les logs contiennent mots de passe et invitations + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented. | +| Category | Authorization / security control | +| CWE | CWE-532 | +| Affected lines | apps/backend/src/application/controllers/users.controller.ts:163-166, apps/backend/src/application/controllers/users.controller.ts:134-156, apps/backend/src/application/services/invitation.service.ts:178-181, apps/backend/src/app.module.ts:123-135 | + +#### Summary + +Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. + +#### Root Cause + +Authentication secrets must not be exposed to log readers Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // TODO: Send invitation email with temporary password + this.logger.warn( + `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` + ); +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const tempPassword = dto.password || this.generateTemporaryPassword(); + + // Hash password with Argon2id + const passwordHash = await argon2.hash(tempPassword, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + + // Map DTO role to Domain role + const domainRole = dto.role as unknown as DomainUserRole; + + // Create user entity + const newUser = User.create({ + id: uuidv4(), + organizationId: dto.organizationId, + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + role: domainRole, + }); +``` + +**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const invitationLink = `${frontendUrl}/register?token=${invitation.token}`; + + this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`); + this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`); +``` + +**Source 4** — `apps/backend/src/app.module.ts:123-135` + +Étape du parcours source décrit dans la cause et la validation. + +``` + level: isDev ? 'debug' : 'info', + // Redact sensitive fields from logs + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.body.password', + 'req.body.currentPassword', + 'req.body.newPassword', + ], + censor: '[REDACTED]', + }, + }, +``` + +#### Validation + +Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Contre-preuves : Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // TODO: Send invitation email with temporary password + this.logger.warn( + `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` + ); +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const tempPassword = dto.password || this.generateTemporaryPassword(); + + // Hash password with Argon2id + const passwordHash = await argon2.hash(tempPassword, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + + // Map DTO role to Domain role + const domainRole = dto.role as unknown as DomainUserRole; + + // Create user entity + const newUser = User.create({ + id: uuidv4(), + organizationId: dto.organizationId, + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + role: domainRole, + }); +``` + +**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const invitationLink = `${frontendUrl}/register?token=${invitation.token}`; + + this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`); + this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`); +``` + +**Source 4** — `apps/backend/src/app.module.ts:123-135` + +Étape du parcours source décrit dans la cause et la validation. + +``` + level: isDev ? 'debug' : 'info', + // Redact sensitive fields from logs + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.body.password', + 'req.body.currentPassword', + 'req.body.newPassword', + ], + censor: '[REDACTED]', + }, + }, +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. + +- **Source:** Operator or attacker able to read application logs but not authorized to authenticate as users + +- **Sink:** apps/backend/src/application/controllers/users.controller.ts + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:163-166` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // TODO: Send invitation email with temporary password + this.logger.warn( + `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}` + ); +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:134-156` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const tempPassword = dto.password || this.generateTemporaryPassword(); + + // Hash password with Argon2id + const passwordHash = await argon2.hash(tempPassword, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + + // Map DTO role to Domain role + const domainRole = dto.role as unknown as DomainUserRole; + + // Create user entity + const newUser = User.create({ + id: uuidv4(), + organizationId: dto.organizationId, + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + role: domainRole, + }); +``` + +**Source 3** — `apps/backend/src/application/services/invitation.service.ts:178-181` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const invitationLink = `${frontendUrl}/register?token=${invitation.token}`; + + this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`); + this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`); +``` + +**Source 4** — `apps/backend/src/app.module.ts:123-135` + +Étape du parcours source décrit dans la cause et la validation. + +``` + level: isDev ? 'debug' : 'info', + // Redact sensitive fields from logs + redact: { + paths: [ + 'req.headers.authorization', + 'req.headers["x-api-key"]', + 'req.body.password', + 'req.body.currentPassword', + 'req.body.newPassword', + ], + censor: '[REDACTED]', + }, + }, +``` + +#### Reachability + +Operator or attacker able to read application logs but not authorized to authenticate as users. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented. + +- **Attacker:** Operator or attacker able to read application logs but not authorized to authenticate as users + +- **Entry point:** apps/backend/src/application/controllers/users.controller.ts + +#### Severity + +**Medium** — Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [7] La résiliation peut conserver les avantages payants + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe. | +| Category | Authorization / security control | +| CWE | CWE-841 | +| Affected lines | apps/backend/src/application/services/subscription.service.ts:608-619, apps/backend/src/domain/entities/subscription.entity.ts:262-269, apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55, apps/backend/src/application/controllers/subscriptions.controller.ts:279-281 | + +#### Summary + +customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. + +#### Root Cause + +Une résiliation doit retirer les droits même si le compte dépasse la capacité gratuite. customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. + +**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619` + +Étape du parcours source décrit dans la cause et la validation. + +``` + } + + // Downgrade to FREE plan - count only non-ADMIN licenses + const canceledSubscription = subscription + .updatePlan( + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) + ) + .updateStatus(SubscriptionStatus.canceled()); + + await this.subscriptionRepository.save(canceledSubscription); + +``` + +**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (!newPlan.canAccommodateUsers(currentUserCount)) { + throw new InvalidSubscriptionDowngradeException( + this.props.plan.value, + newPlan.value, + currentUserCount, + newPlan.maxLicenses + ); + } +``` + +**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55` + +Étape du parcours source décrit dans la cause et la validation. + +``` + maxLicenses: 1, + monthlyPriceEur: 0, + yearlyPriceEur: 0, + maxShipmentsPerYear: 5, + bookingFeeEur: 15, + statusBadge: 'none', +``` + +**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.error('Webhook processing failed', error); + return { received: false }; + } +``` + +#### Validation + +customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. Contre-preuves : Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619` + +Étape du parcours source décrit dans la cause et la validation. + +``` + } + + // Downgrade to FREE plan - count only non-ADMIN licenses + const canceledSubscription = subscription + .updatePlan( + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) + ) + .updateStatus(SubscriptionStatus.canceled()); + + await this.subscriptionRepository.save(canceledSubscription); + +``` + +**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (!newPlan.canAccommodateUsers(currentUserCount)) { + throw new InvalidSubscriptionDowngradeException( + this.props.plan.value, + newPlan.value, + currentUserCount, + newPlan.maxLicenses + ); + } +``` + +**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55` + +Étape du parcours source décrit dans la cause et la validation. + +``` + maxLicenses: 1, + monthlyPriceEur: 0, + yearlyPriceEur: 0, + maxShipmentsPerYear: 5, + bookingFeeEur: 15, + statusBadge: 'none', +``` + +**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.error('Webhook processing failed', error); + return { received: false }; + } +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. + +- **Source:** Manager d’une organisation payante ayant au moins deux licences actives non ADMIN + +- **Sink:** apps/backend/src/application/services/subscription.service.ts + +**Source 1** — `apps/backend/src/application/services/subscription.service.ts:608-619` + +Étape du parcours source décrit dans la cause et la validation. + +``` + } + + // Downgrade to FREE plan - count only non-ADMIN licenses + const canceledSubscription = subscription + .updatePlan( + SubscriptionPlan.bronze(), + await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id) + ) + .updateStatus(SubscriptionStatus.canceled()); + + await this.subscriptionRepository.save(canceledSubscription); + +``` + +**Source 2** — `apps/backend/src/domain/entities/subscription.entity.ts:262-269` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (!newPlan.canAccommodateUsers(currentUserCount)) { + throw new InvalidSubscriptionDowngradeException( + this.props.plan.value, + newPlan.value, + currentUserCount, + newPlan.maxLicenses + ); + } +``` + +**Source 3** — `apps/backend/src/domain/value-objects/subscription-plan.vo.ts:50-55` + +Étape du parcours source décrit dans la cause et la validation. + +``` + maxLicenses: 1, + monthlyPriceEur: 0, + yearlyPriceEur: 0, + maxShipmentsPerYear: 5, + bookingFeeEur: 15, + statusBadge: 'none', +``` + +**Source 4** — `apps/backend/src/application/controllers/subscriptions.controller.ts:279-281` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.error('Webhook processing failed', error); + return { received: false }; + } +``` + +#### Reachability + +Manager d’une organisation payante ayant au moins deux licences actives non ADMIN. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe. + +- **Attacker:** Manager d’une organisation payante ayant au moins deux licences actives non ADMIN + +- **Entry point:** apps/backend/src/application/services/subscription.service.ts + +#### Severity + +**Medium** — customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan lève une exception, donc aucun save ne retire l’offre. Le contrôleur acquitte néanmoins avec HTTP 200. Événement Stripe signé et abonnement payant préexistant nécessaires. La signature est vérifiée ; l’attaque exploite les transitions locales, pas une falsification Stripe. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Persister la résiliation indépendamment des limites de licences, retirer les droits effectifs puis résoudre le surnombre. Ne pas acquitter une erreur de traitement comme un succès. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [8] VIEWER peut créer et modifier des réservations + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source. | +| Category | Authorization / security control | +| CWE | CWE-862 | +| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/domain/entities/user.entity.ts:19, apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88, apps/backend/src/application/services/csv-booking.service.ts:146-168 | + +#### Summary + +VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. + +#### Root Cause + +VIEWER role is read-only and cannot create bookings VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19` + +Étape du parcours source décrit dans la cause et la validation. + +``` + VIEWER = 'VIEWER', // Read-only access +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.log(`Creating CSV booking for user ${userId}`); + + // Validate minimum document requirement + if (!files || files.length === 0) { + throw new BadRequestException('At least one document is required'); + } + + // Generate unique confirmation token and booking number + const confirmationToken = uuidv4(); + const bookingId = uuidv4(); + const bookingNumber = this.generateBookingNumber(); + const documentPassword = this.deriveDocumentPassword(bookingId); + + // Hash the password for storage + const passwordHash = await argon2.hash(documentPassword); + + // Upload documents to S3 + const documents = await this.uploadDocuments(files, bookingId); + + // Flat per-booking service fee (forfait par booking) based on the org's plan. + // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the + // booking skips the payment gate and the carrier is notified immediately. + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); +``` + +#### Validation + +VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Contre-preuves : Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19` + +Étape du parcours source décrit dans la cause et la validation. + +``` + VIEWER = 'VIEWER', // Read-only access +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.log(`Creating CSV booking for user ${userId}`); + + // Validate minimum document requirement + if (!files || files.length === 0) { + throw new BadRequestException('At least one document is required'); + } + + // Generate unique confirmation token and booking number + const confirmationToken = uuidv4(); + const bookingId = uuidv4(); + const bookingNumber = this.generateBookingNumber(); + const documentPassword = this.deriveDocumentPassword(bookingId); + + // Hash the password for storage + const passwordHash = await argon2.hash(documentPassword); + + // Upload documents to S3 + const documents = await this.uploadDocuments(files, bookingId); + + // Flat per-booking service fee (forfait par booking) based on the org's plan. + // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the + // booking skips the payment gate and the carrier is notified immediately. + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. + +- **Source:** Active VIEWER account including account downgraded from USER + +- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 2** — `apps/backend/src/domain/entities/user.entity.ts:19` + +Étape du parcours source décrit dans la cause et la validation. + +``` + VIEWER = 'VIEWER', // Read-only access +``` + +**Source 3** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:86-88` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Post() + @ApiBearerAuth() + @UseInterceptors(FilesInterceptor('documents', 10)) +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:146-168` + +Étape du parcours source décrit dans la cause et la validation. + +``` + this.logger.log(`Creating CSV booking for user ${userId}`); + + // Validate minimum document requirement + if (!files || files.length === 0) { + throw new BadRequestException('At least one document is required'); + } + + // Generate unique confirmation token and booking number + const confirmationToken = uuidv4(); + const bookingId = uuidv4(); + const bookingNumber = this.generateBookingNumber(); + const documentPassword = this.deriveDocumentPassword(bookingId); + + // Hash the password for storage + const passwordHash = await argon2.hash(documentPassword); + + // Upload documents to S3 + const documents = await this.uploadDocuments(files, bookingId); + + // Flat per-booking service fee (forfait par booking) based on the org's plan. + // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the + // booking skips the payment gate and the carrier is notified immediately. + const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); +``` + +#### Reachability + +Active VIEWER account including account downgraded from USER. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source. + +- **Attacker:** Active VIEWER account including account downgraded from USER + +- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +#### Severity + +**Medium** — VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [9] Un membre peut marquer toutes les notifications comme lues + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty. | +| Category | Authorization / security control | +| CWE | CWE-639 | +| Affected lines | apps/backend/src/application/gateways/notifications.gateway.ts:117-124, apps/backend/src/application/gateways/notifications.gateway.ts:112-124, apps/backend/src/application/services/notification.service.ts:125-127, apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158, apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365 | + +#### Summary + +Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). + +#### Root Cause + +Only the notification recipient may mark their own notification as read Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + */ + @SubscribeMessage('mark_as_read') + async handleMarkAsRead( + @ConnectedSocket() client: Socket, + @MessageBody() data: { notificationId: string } + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** + * Delete notification + */ +``` + +**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async markAsRead(id: string): Promise { + await this.ormRepository.update(id, { + read: true, + read_at: new Date(), + }); + } +``` + +**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365` + +Étape du parcours source décrit dans la cause et la validation. + +``` + update(target, criteria, partialEntity) { + // if user passed empty criteria or empty list of criterias, then throw an error + if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) { + return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`)); + } + if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .whereInIds(criteria) + .execute(); + } + else { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .where(criteria) + .execute(); + } + } +``` + +#### Validation + +Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). Contre-preuves : REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + */ + @SubscribeMessage('mark_as_read') + async handleMarkAsRead( + @ConnectedSocket() client: Socket, + @MessageBody() data: { notificationId: string } + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** + * Delete notification + */ +``` + +**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async markAsRead(id: string): Promise { + await this.ormRepository.update(id, { + read: true, + read_at: new Date(), + }); + } +``` + +**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365` + +Étape du parcours source décrit dans la cause et la validation. + +``` + update(target, criteria, partialEntity) { + // if user passed empty criteria or empty list of criterias, then throw an error + if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) { + return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`)); + } + if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .whereInIds(criteria) + .execute(); + } + else { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .where(criteria) + .execute(); + } + } +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). + +- **Source:** Any authenticated WebSocket user + +- **Sink:** apps/backend/src/application/gateways/notifications.gateway.ts + +**Source 1** — `apps/backend/src/application/gateways/notifications.gateway.ts:117-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 2** — `apps/backend/src/application/gateways/notifications.gateway.ts:112-124` + +Étape du parcours source décrit dans la cause et la validation. + +``` + */ + @SubscribeMessage('mark_as_read') + async handleMarkAsRead( + @ConnectedSocket() client: Socket, + @MessageBody() data: { notificationId: string } + ) { + try { + const userId = client.data.userId; + await this.notificationService.markAsRead(data.notificationId); + + // Send updated unread count + const unreadCount = await this.notificationService.getUnreadCount(userId); + this.emitToUser(userId, 'unread_count', { count: unreadCount }); +``` + +**Source 3** — `apps/backend/src/application/services/notification.service.ts:125-127` + +Étape du parcours source décrit dans la cause et la validation. + +``` + /** + * Delete notification + */ +``` + +**Source 4** — `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts:153-158` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async markAsRead(id: string): Promise { + await this.ormRepository.update(id, { + read: true, + read_at: new Date(), + }); + } +``` + +**Source 5** — `apps/backend/node_modules/typeorm/entity-manager/EntityManager.js:346-365` + +Étape du parcours source décrit dans la cause et la validation. + +``` + update(target, criteria, partialEntity) { + // if user passed empty criteria or empty list of criterias, then throw an error + if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) { + return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`)); + } + if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .whereInIds(criteria) + .execute(); + } + else { + return this.createQueryBuilder() + .update(target) + .set(partialEntity) + .where(criteria) + .execute(); + } + } +``` + +#### Reachability + +Any authenticated WebSocket user. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty. + +- **Attacker:** Any authenticated WebSocket user + +- **Entry point:** apps/backend/src/application/gateways/notifications.gateway.ts + +#### Severity + +**Medium** — Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [10] Le changement de mot de passe conserve les anciennes sessions + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed. | +| Category | Authorization / security control | +| CWE | CWE-613 | +| Affected lines | apps/backend/src/application/auth/auth.service.ts:386-392, apps/backend/src/application/auth/auth.service.ts:354-376, apps/backend/src/application/auth/auth.service.ts:234-253, apps/backend/src/domain/entities/user.entity.ts:196-199 | + +#### Summary + +Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. + +#### Root Cause + +Recovering a compromised account must invalidate pre-reset authentication sessions Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. + +**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); + +``` + +**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async resetPassword(token: string, newPassword: string): Promise { + const resetToken = await this.passwordResetTokenRepository.findOne({ + where: { token: this.hashResetToken(token) }, + }); + + if (!resetToken) { + throw new BadRequestException('Token de réinitialisation invalide ou expiré'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException( + 'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.' + ); + } + + const user = await this.userRepository.findById(resetToken.userId); + + if (!user || !user.isActive) { + throw new NotFoundException('Utilisateur introuvable'); +``` + +**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199` + +Étape du parcours source décrit dans la cause et la validation. + +``` + updatePassword(newPasswordHash: string): void { + this.props.passwordHash = newPasswordHash; + this.props.updatedAt = new Date(); + } +``` + +#### Validation + +Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Contre-preuves : Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); + +``` + +**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async resetPassword(token: string, newPassword: string): Promise { + const resetToken = await this.passwordResetTokenRepository.findOne({ + where: { token: this.hashResetToken(token) }, + }); + + if (!resetToken) { + throw new BadRequestException('Token de réinitialisation invalide ou expiré'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException( + 'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.' + ); + } + + const user = await this.userRepository.findById(resetToken.userId); + + if (!user || !user.isActive) { + throw new NotFoundException('Utilisateur introuvable'); +``` + +**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199` + +Étape du parcours source décrit dans la cause et la validation. + +``` + updatePassword(newPasswordHash: string): void { + this.props.passwordHash = newPasswordHash; + this.props.updatedAt = new Date(); + } +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. + +- **Source:** Attacker holding a victim refresh token before password recovery + +- **Sink:** apps/backend/src/application/auth/auth.service.ts + +**Source 1** — `apps/backend/src/application/auth/auth.service.ts:386-392` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update password (mutates in place) + user.updatePassword(passwordHash); + await this.userRepository.save(user); + + // Mark token as used + await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); + +``` + +**Source 2** — `apps/backend/src/application/auth/auth.service.ts:354-376` + +Étape du parcours source décrit dans la cause et la validation. + +``` + async resetPassword(token: string, newPassword: string): Promise { + const resetToken = await this.passwordResetTokenRepository.findOne({ + where: { token: this.hashResetToken(token) }, + }); + + if (!resetToken) { + throw new BadRequestException('Token de réinitialisation invalide ou expiré'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Ce lien de réinitialisation a déjà été utilisé'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException( + 'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.' + ); + } + + const user = await this.userRepository.findById(resetToken.userId); + + if (!user || !user.isActive) { + throw new NotFoundException('Utilisateur introuvable'); +``` + +**Source 3** — `apps/backend/src/application/auth/auth.service.ts:234-253` + +Étape du parcours source décrit dans la cause et la validation. + +``` + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_SECRET'), + }); + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + if (await this.isRefreshTokenRevoked(refreshToken)) { + throw new UnauthorizedException('Refresh token has been revoked'); + } + + const user = await this.userRepository.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const rememberMe = payload.rememberMe === true; + const tokens = await this.generateTokens(user, rememberMe); +``` + +**Source 4** — `apps/backend/src/domain/entities/user.entity.ts:196-199` + +Étape du parcours source décrit dans la cause et la validation. + +``` + updatePassword(newPasswordHash: string): void { + this.props.passwordHash = newPasswordHash; + this.props.updatedAt = new Date(); + } +``` + +#### Reachability + +Attacker holding a victim refresh token before password recovery. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed. + +- **Attacker:** Attacker holding a victim refresh token before password recovery + +- **Entry point:** apps/backend/src/application/auth/auth.service.ts + +#### Severity + +**Medium** — Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [11] Une clé SMTP figure dans un fichier suivi + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | medium | +| Confidence rationale | Traçage statique du code courant. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential. | +| Category | Authorization / security control | +| CWE | CWE-798 | +| Affected lines | docker/docker-compose.full.yml:137 | + +#### Summary + +Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. + +#### Root Cause + +Credential is inline rather than secret reference. Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. + +**Source 1** — `docker/docker-compose.full.yml:137` + +Étape du parcours source décrit dans la cause et la validation. + +``` + SMTP_PASS: [REDACTED] +``` + +#### Validation + +Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. Contre-preuves : Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential. + +Validation method: static source trace + +**Source 1** — `docker/docker-compose.full.yml:137` + +Étape du parcours source décrit dans la cause et la validation. + +``` + SMTP_PASS: [REDACTED] +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. + +- **Source:** Anyone who obtains repository/configuration content + +- **Sink:** docker/docker-compose.full.yml + +**Source 1** — `docker/docker-compose.full.yml:137` + +Étape du parcours source décrit dans la cause et la validation. + +``` + SMTP_PASS: [REDACTED] +``` + +#### Reachability + +Anyone who obtains repository/configuration content. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential. + +- **Attacker:** Anyone who obtains repository/configuration content + +- **Entry point:** docker/docker-compose.full.yml + +#### Severity + +**Medium** — Format fournisseur confirmé, mais validité non testée. Un lecteur du dépôt peut obtenir la clé ; usage abusif possible si elle est toujours active. Valeur masquée. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [12] Les exports CSV conservent les formules injectées + +| Field | Value | +| --- | --- | +| Severity | low | +| Confidence | medium | +| Confidence rationale | Traçage statique du code courant. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced. | +| Category | Authorization / security control | +| CWE | CWE-1236 | +| Affected lines | apps/backend/src/application/controllers/users.controller.ts:266-273, apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347, apps/frontend/src/components/ExportButton.tsx:65-80 | + +#### Summary + +UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. + +#### Root Cause + +CSV quote escaping is not formula neutralization. UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } +``` + +**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347` + +Étape du parcours source décrit dans la cause et la validation. + +``` + { + const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';'); + + const rows = data.map(row => { + return columns + .map(col => { + const value = getNestedValue(row, col.key as string); + const formattedValue = col.format ? col.format(value, row) : formatValue(value); + return `"${formattedValue.replace(/"/g, '""')}"`; + }) + .join(';'); + }); + + return [headers, ...rows].join('\n'); + }; + +``` + +#### Validation + +UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. Contre-preuves : Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } +``` + +**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347` + +Étape du parcours source décrit dans la cause et la validation. + +``` + { + const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';'); + + const rows = data.map(row => { + return columns + .map(col => { + const value = getNestedValue(row, col.key as string); + const formattedValue = col.format ? col.format(value, row) : formatValue(value); + return `"${formattedValue.replace(/"/g, '""')}"`; + }) + .join(';'); + }); + + return [headers, ...rows].join('\n'); + }; + +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. + +- **Source:** Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software + +- **Sink:** apps/frontend/src/components/ExportButton.tsx + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:266-273` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } +``` + +**Source 2** — `apps/frontend/app/\[locale\]/dashboard/settings/users/page.tsx:341-347` + +Étape du parcours source décrit dans la cause et la validation. + +``` + { + const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';'); + + const rows = data.map(row => { + return columns + .map(col => { + const value = getNestedValue(row, col.key as string); + const formattedValue = col.format ? col.format(value, row) : formatValue(value); + return `"${formattedValue.replace(/"/g, '""')}"`; + }) + .join(';'); + }); + + return [headers, ...rows].join('\n'); + }; + +``` + +#### Reachability + +Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced. + +- **Attacker:** Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software + +- **Entry point:** apps/backend/src/application/controllers/users.controller.ts + +#### Severity + +**Low** — Attaque limitée à des collègues et nécessitant une ouverture dans un tableur qui interprète les formules. Aucune exécution système ni exfiltration automatique démontrée. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [13] Les dossiers des collègues sont accessibles sans rôle de gestion + +| Field | Value | +| --- | --- | +| Severity | low | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads. | +| Category | Authorization / security control | +| CWE | CWE-862 | +| Affected lines | apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321, apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335, apps/backend/src/application/services/csv-booking.service.ts:1200-1221, apps/backend/src/application/services/csv-booking.service.ts:685-697 | + +#### Summary + +A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. + +#### Root Cause + +Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Organization bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); +``` + +**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221` + +Étape du parcours source décrit dans la cause et la validation. + +``` + page, + limit, + totalPages: Math.ceil(bookings.length / limit), + }; + } + + /** + * Get bookings for an organization (paginated) + */ + async getOrganizationBookings( + organizationId: string, + page: number = 1, + limit: number = 10 + ): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + + // Simple pagination (in-memory) + const start = (page - 1) * limit; + const end = start + limit; + const paginatedBookings = bookings.slice(start, end); + + return { +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Verify user owns this booking OR is the assigned carrier + const isOwner = booking.userId === userId; + const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId; + + if (!isOwner && !isAssignedCarrier) { + throw new NotFoundException(`Booking with ID ${id} not found`); + } + + return this.toResponseDto(booking); + } + + /** + * Get booking by confirmation token (public endpoint) +``` + +#### Validation + +A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Contre-preuves : Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Organization bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); +``` + +**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221` + +Étape du parcours source décrit dans la cause et la validation. + +``` + page, + limit, + totalPages: Math.ceil(bookings.length / limit), + }; + } + + /** + * Get bookings for an organization (paginated) + */ + async getOrganizationBookings( + organizationId: string, + page: number = 1, + limit: number = 10 + ): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + + // Simple pagination (in-memory) + const start = (page - 1) * limit; + const end = start + limit; + const paginatedBookings = bookings.slice(start, end); + + return { +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Verify user owns this booking OR is the assigned carrier + const isOwner = booking.userId === userId; + const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId; + + if (!isOwner && !isAssignedCarrier) { + throw new NotFoundException(`Booking with ID ${id} not found`); + } + + return this.toResponseDto(booking); + } + + /** + * Get booking by confirmation token (public endpoint) +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. + +- **Source:** Authenticated USER or VIEWER in organization with other users bookings + +- **Sink:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +**Source 1** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-321` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) +``` + +**Source 2** — `apps/backend/src/application/controllers/csv-bookings.controller.ts:313-335` + +Étape du parcours source décrit dans la cause et la validation. + +``` + @Get('organization/all') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get organization bookings', + description: + "Retrieve all bookings for the user's organization with pagination. For managers/admins.", + }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + @ApiResponse({ + status: 200, + description: 'Organization bookings retrieved successfully', + type: CsvBookingListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getOrganizationBookings( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise { + const organizationId = req.user.organizationId; + return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit); +``` + +**Source 3** — `apps/backend/src/application/services/csv-booking.service.ts:1200-1221` + +Étape du parcours source décrit dans la cause et la validation. + +``` + page, + limit, + totalPages: Math.ceil(bookings.length / limit), + }; + } + + /** + * Get bookings for an organization (paginated) + */ + async getOrganizationBookings( + organizationId: string, + page: number = 1, + limit: number = 10 + ): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + + // Simple pagination (in-memory) + const start = (page - 1) * limit; + const end = start + limit; + const paginatedBookings = bookings.slice(start, end); + + return { +``` + +**Source 4** — `apps/backend/src/application/services/csv-booking.service.ts:685-697` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Verify user owns this booking OR is the assigned carrier + const isOwner = booking.userId === userId; + const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId; + + if (!isOwner && !isAssignedCarrier) { + throw new NotFoundException(`Booking with ID ${id} not found`); + } + + return this.toResponseDto(booking); + } + + /** + * Get booking by confirmation token (public endpoint) +``` + +#### Reachability + +Authenticated USER or VIEWER in organization with other users bookings. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads. + +- **Attacker:** Authenticated USER or VIEWER in organization with other users bookings + +- **Entry point:** apps/backend/src/application/controllers/csv-bookings.controller.ts + +#### Severity + +**Low** — A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + + + +### [14] Un manager peut rétrograder un administrateur de son organisation + +| Field | Value | +| --- | --- | +| Severity | low | +| Confidence | high | +| Confidence rationale | Traçage statique du code courant. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation. | +| Category | Authorization / security control | +| CWE | CWE-863 | +| Affected lines | apps/backend/src/application/controllers/users.controller.ts:256-264, apps/backend/src/application/controllers/users.controller.ts:257-279, apps/backend/src/application/controllers/users.controller.ts:396-400 | + +#### Summary + +Manager invokes PATCH /users/\ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. + +#### Root Cause + +Managers must not alter platform administrator privileges/status Manager invokes PATCH /users/\ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } + + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } + + if (dto.role) { + const domainRole = dto.role as unknown as DomainUserRole; + user.updateRole(domainRole); + } + +``` + +**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Fetch users from current user's organization + this.logger.log( + `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}` + ); +``` + +#### Validation + +Manager invokes PATCH /users/\ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Contre-preuves : Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation. + +Validation method: static source trace + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } + + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } + + if (dto.role) { + const domainRole = dto.role as unknown as DomainUserRole; + user.updateRole(domainRole); + } + +``` + +**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Fetch users from current user's organization + this.logger.log( + `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}` + ); +``` + +Limitations: +- Pas d’exécution du produit, de test de charge ni d’exploitation réseau. + +#### Dataflow + +Manager invokes PATCH /users/\ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. + +- **Source:** MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID + +- **Sink:** apps/backend/src/application/controllers/users.controller.ts + +**Source 1** — `apps/backend/src/application/controllers/users.controller.ts:256-264` + +Étape du parcours source décrit dans la cause et la validation. + +``` + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } +``` + +**Source 2** — `apps/backend/src/application/controllers/users.controller.ts:257-279` + +Étape du parcours source décrit dans la cause et la validation. + +``` + if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + + // Authorization: Managers can only update users in their own organization + if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) { + throw new ForbiddenException('You can only update users in your own organization'); + } + + // Update fields + if (dto.firstName) { + user.updateFirstName(dto.firstName); + } + + if (dto.lastName) { + user.updateLastName(dto.lastName); + } + + if (dto.role) { + const domainRole = dto.role as unknown as DomainUserRole; + user.updateRole(domainRole); + } + +``` + +**Source 3** — `apps/backend/src/application/controllers/users.controller.ts:396-400` + +Étape du parcours source décrit dans la cause et la validation. + +``` + + // Fetch users from current user's organization + this.logger.log( + `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}` + ); +``` + +#### Reachability + +MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation. + +- **Attacker:** MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID + +- **Entry point:** apps/backend/src/application/controllers/users.controller.ts + +#### Severity + +**Low** — Manager invokes PATCH /users/\ with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation. + +Les prérequis et contrôles externes décrits peuvent réduire la sévérité ; aucun déploiement réel n’a été testé. + +#### Remediation + +Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes. + +Tests: +- Reproduire le parcours décrit avec des données de test et vérifier le rejet sans effet sur les ressources protégées. + +Preventive controls: +- Centraliser le contrôle et vérifier toutes les interfaces vers la même ressource. + +## Reviewed Surfaces + +| Surface | Risk Area | Outcome | Notes | +| --- | --- | --- | --- | +| Authentification, récupération et WebSockets | Sessions | Reported | JWT HTTP vérifie le type access et le compte actif ; inscription liée à invitation vérifiée. Bypass WebSocket, sessions après reset et secrets dans logs confirmés. | +| Organisations et rôles CSV | Isolation et permissions | Reported | Contrôle inter-organisations cassé par casse du rôle ; liste CSV sans rôle et mutations VIEWER. Les mutations individuelles CSV vérifient le propriétaire. | +| Liens transporteurs et documents | Autorité et stockage | Reported | Jeton divulgué au client. Téléchargements vérifient ACCEPTED, mot de passe si configuré et appartenance du document ; PDFKit rend du texte sans navigateur ni chargement HTML. | +| Souscriptions Stripe | Intégrité financière | Reported | Signatures vérifiées ; résiliation bloquée par licences. Sync ne compare pas metadata.organizationId mais UNIQUE stripe_subscription_id bloque la réassociation normale ; scénario de course non confirmé. | +| MCP et assistant IA | Outils et données | No issue found | Rôle/offre contrôlés à chaque invocation ; acteur lié à session, SQL des conversations paramétré avec user_id, quota atomique et tours IA bornés. | +| Frontend et exports | XSS et CSV | Reported | Redirection brute vérifiée dans Next installé. Formules CSV non neutralisées. Contexte actif avec cookies HttpOnly, distinct de l’ancien client localStorage. | +| Logs et déploiements | Secrets et réseau | Reported | Clé SMTP littérale masquée, validité inconnue. Logs de production internes avec NetworkPolicy ; Compose dev expose 3100/3200 sans authentification, sans preuve d’exposition Internet. | +| Persistance, GDPR et configuration | Injection et données | No issue found | Requêtes recherche/GDPR/conversations paramétrées ; export GDPR exclut hash mot de passe, TOTP et hash de clé. DATABASE_SSL ignoré par runtime/startup et validation de certificat désactivée dans CLI ; buckets distincts, état réel externe non testé. 95 fichiers suivis lus intégralement ; lectures ciblées supplémentaires non comptées. | +| Webhook SSRF à l’enregistrement | Requêtes sortantes | Rejected | WebhookService poste vers la destination enregistrée sans filtre IP, mais les DTO CreateWebhookDto/UpdateWebhookDto n’ont aucun décorateur de validation ; la validation globale whitelist + forbidNonWhitelisted de main.ts rejette leurs champs. Aucune voie actuelle de création par un attaquant n’a été établie. Corriger les DTO doit impérativement ajouter aussi une politique de destination. | + +## Open Questions And Follow Up + +- Compléter les fichiers non lus intégralement avant de qualifier la couverture d’exhaustive. +- Vérifier rotation SMTP et plafond multipart au proxy sans réutiliser le secret. +- Vérifier liaison Stripe session/organisation et droits des abonnements UNPAID/PAUSED : plusieurs consommateurs lisent seulement plan. +- Aligner DATABASE_SSL, validation TLS SMTP/SQL et buckets provisionnés/ACL. +- Agents interrompus par limites d’usage. Pages/composants frontend, migrations/scripts, adaptateurs transporteurs et portions CSV restent non lus intégralement ; couverture non exhaustive. + - Follow-up prompt: Review deferred unit remaining-source and close its stated proof gap. +- syncFromStripe ne lie pas metadata.organizationId ; UNIQUE stripe_subscription_id bloque le scénario normal. Course avant webhook ou ancien abonnement non lié non validés. + - Follow-up prompt: Review deferred unit subscription-sync-binding and close its stated proof gap. +- Recovering interrupted investigator result for validation + - Follow-up prompt: Review deferred unit login-redirect and close its stated proof gap. +- Recovering interrupted baseline result + - Follow-up prompt: Review deferred unit notification-owner and close its stated proof gap. +- Recovering interrupted baseline result + - Follow-up prompt: Review deferred unit carrier-token and close its stated proof gap. diff --git a/docs/security/check-secu/scan-manifest.json b/docs/security/check-secu/scan-manifest.json new file mode 100644 index 0000000..24866e5 --- /dev/null +++ b/docs/security/check-secu/scan-manifest.json @@ -0,0 +1,199 @@ +{ + "documentType": "codex-security.scan-manifest", + "scan": { + "artifacts": [ + { + "mediaType": "application/json", + "path": "findings.json", + "sha256": "3884e597638c69a493af2c8869cf772ceecdad651eb46e062da9a63ffa6b9314" + }, + { + "mediaType": "application/json", + "path": "coverage.json", + "sha256": "6a40f0a888d78716b31180cbad762167372a9f7c143d2233a206e5e09d3fc368" + } + ], + "completedAt": "2026-09-08T12:52:54.338810Z", + "coverageRef": "coverage.json", + "findingsRef": "findings.json", + "id": "4c194468-0b5f-4f24-9005-5be211dc0e47", + "preservedSources": { + "checkpoints/2554140402e8e806d0fc9066ab498f121c3adda4c2fa8a0cc4d9219f906cfb62.json": "9f224890355cb4ad64f6242d008cd5fd0c7fd6b31be7fcdea64be115b40b5f99", + "checkpoints/a0a575a67430a289893d5c590e52fae92e5c3e02dced613366160327d22204c0.json": "6b807e93783851cd3c101fc30c19a678461e5641b094b769b43577bf92286f4b", + "checkpoints/a465c6ca7aaac4bec30e86579d857186ed255cd095818d961fdd5afa54583c89.json": "54c03e1544bfc060476d9898825855d75766b95f5a06e2e8bb4d99f749d46d92", + "checkpoints/a5174fc1c150ed228f8d64feda7151de15b334da83d207370aca233f5758cdc9.json": "a5174fc1c150ed228f8d64feda7151de15b334da83d207370aca233f5758cdc9", + "checkpoints/c2b024a503781cf1fe58a44701b8346e1e33b4ebc93c157dd63b9128884149c3.json": "8933660b6786bc90effde1b588bbfa7e2950bc42ddf84bc93bf339bd19f070ab", + "checkpoints/c8dd318728ac16e8c75fd8bf79844283c7877274bc241603a3dddd767a39e476.json": "d3926dbe629975bd1fa807abec36f0a422f87f53b4f756af3ed213d689705c82", + "checkpoints/e87e912ce792fff133063edb5e07f8efc27f233f100e856cf46aa948380a16fe.json": "ca71c7837bd2465bb08cfc79a0bbf381273eee526a889e3b0dc8586f884dea66" + }, + "producer": { + "name": "codex-security-plugin", + "version": "0.1.23" + }, + "scope": { + "artifactsReviewed": [ + "apps/backend/src/app.module.ts", + "apps/backend/src/application/api-keys/api-keys.service.ts", + "apps/backend/src/application/auth/auth.service.ts", + "apps/backend/src/application/auth/jwt.strategy.ts", + "apps/backend/src/application/controllers/audit.controller.ts", + "apps/backend/src/application/controllers/auth.controller.ts", + "apps/backend/src/application/controllers/bookings.controller.ts", + "apps/backend/src/application/controllers/csv-booking-actions.controller.ts", + "apps/backend/src/application/controllers/gdpr.controller.ts", + "apps/backend/src/application/controllers/invitations.controller.ts", + "apps/backend/src/application/controllers/notifications.controller.ts", + "apps/backend/src/application/controllers/organizations.controller.ts", + "apps/backend/src/application/controllers/subscriptions.controller.ts", + "apps/backend/src/application/controllers/users.controller.ts", + "apps/backend/src/application/controllers/webhooks.controller.ts", + "apps/backend/src/application/csv-bookings/csv-bookings.module.ts", + "apps/backend/src/application/dashboard/dashboard.controller.ts", + "apps/backend/src/application/dto/organization.dto.ts", + "apps/backend/src/application/dto/subscription.dto.ts", + "apps/backend/src/application/dto/user.dto.ts", + "apps/backend/src/application/gateways/notifications.gateway.ts", + "apps/backend/src/application/guards/api-key-or-jwt.guard.ts", + "apps/backend/src/application/guards/feature-flag.guard.ts", + "apps/backend/src/application/guards/jwt-auth.guard.ts", + "apps/backend/src/application/guards/roles.guard.ts", + "apps/backend/src/application/guards/throttle.guard.ts", + "apps/backend/src/application/logs/logs.controller.ts", + "apps/backend/src/application/mcp/capabilities/account.capabilities.ts", + "apps/backend/src/application/mcp/capabilities/admin.capabilities.ts", + "apps/backend/src/application/mcp/capabilities/bookings.capabilities.ts", + "apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts", + "apps/backend/src/application/mcp/capabilities/rates.capabilities.ts", + "apps/backend/src/application/mcp/capability.registry.ts", + "apps/backend/src/application/mcp/capability.ts", + "apps/backend/src/application/mcp/mcp.controller.ts", + "apps/backend/src/application/notifications/notifications.module.ts", + "apps/backend/src/application/services/analytics.service.ts", + "apps/backend/src/application/services/fuzzy-search.service.ts", + "apps/backend/src/application/services/gdpr.service.ts", + "apps/backend/src/application/services/invitation.service.ts", + "apps/backend/src/application/services/notification.service.ts", + "apps/backend/src/application/services/subscription.service.ts", + "apps/backend/src/application/services/webhook.service.ts", + "apps/backend/src/application/trade-assistant/trade-assistant.controller.ts", + "apps/backend/src/application/trade-assistant/trade-assistant.service.ts", + "apps/backend/src/domain/entities/subscription.entity.ts", + "apps/backend/src/domain/entities/user.entity.ts", + "apps/backend/src/domain/services/booking.service.ts", + "apps/backend/src/domain/services/capability-access.ts", + "apps/backend/src/domain/value-objects/subscription-plan.vo.ts", + "apps/backend/src/domain/value-objects/subscription-status.vo.ts", + "apps/backend/src/infrastructure/ai/openai-trade.adapter.ts", + "apps/backend/src/infrastructure/pdf/pdf.adapter.ts", + "apps/backend/src/infrastructure/persistence/typeorm/entities/notification.orm-entity.ts", + "apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts", + "apps/backend/src/infrastructure/persistence/typeorm/mappers/csv-booking.mapper.ts", + "apps/backend/src/infrastructure/persistence/typeorm/repositories/csv-booking.repository.ts", + "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts", + "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts", + "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-conversation.repository.ts", + "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-trade-quota.repository.ts", + "apps/backend/src/infrastructure/security/security.config.ts", + "apps/backend/src/infrastructure/storage/s3-storage.adapter.ts", + "apps/backend/src/infrastructure/stripe/stripe.adapter.ts", + "apps/backend/src/main.ts", + "apps/frontend/Dockerfile", + "apps/frontend/app/[locale]/layout.tsx", + "apps/frontend/app/[locale]/login/page.tsx", + "apps/frontend/app/api/health/route.ts", + "apps/frontend/i18n/navigation.ts", + "apps/frontend/i18n/request.ts", + "apps/frontend/i18n/routing.ts", + "apps/frontend/lib/api/client.ts", + "apps/frontend/middleware.ts", + "apps/frontend/next.config.js", + "apps/frontend/package.json", + "apps/frontend/src/components/ExportButton.tsx", + "apps/frontend/src/components/assistant/answer-text.tsx", + "apps/frontend/src/components/assistant/message-list.tsx", + "apps/frontend/src/components/notifications/notification-row.tsx", + "apps/frontend/src/components/providers.tsx", + "apps/frontend/src/hooks/use-url-state.ts", + "apps/frontend/src/lib/api/client.ts", + "apps/frontend/src/lib/context/auth-context.tsx", + "apps/frontend/src/utils/export.ts", + "apps/frontend/tsconfig.json", + "apps/log-exporter/Dockerfile", + "apps/log-exporter/package.json", + "apps/log-exporter/src/index.js", + "docker/docker-compose.full.yml", + "infra/logging/loki/loki-config.yml", + "infra/prod/k8s/base/06-log-exporter.yaml", + "infra/prod/k8s/base/08-traefik-middlewares.yaml", + "infra/prod/k8s/base/09-ingress.yaml", + "infra/prod/k8s/base/10-network-policies.yaml" + ], + "context": "Mod\u00e8le de menace g\u00e9n\u00e9r\u00e9 depuis le code et revu ind\u00e9pendamment ; aucun mod\u00e8le utilisateur.", + "excludePaths": [], + "includePaths": [ + "." + ], + "limitations": [ + "Couverture source partielle ; aucune attestation d\u2019absence de vuln\u00e9rabilit\u00e9s.", + "Fichiers .env/.env.* interdits, non lus.", + "Pas d\u2019audit CVE en ligne, de d\u00e9ploiement r\u00e9el, des secrets actifs, permissions cloud ou historique Git." + ], + "runtimeStatus": "Aucun test de p\u00e9n\u00e9tration ni ex\u00e9cution du produit ; v\u00e9rification des biblioth\u00e8ques install\u00e9es par lecture.", + "summary": "Audit statique transversal sur check_secu, r\u00e9vision 8446f879b676b303fdb2891388f88ff7e43f5fea.", + "validationMode": "Static source trace" + }, + "sealedAt": "2026-09-08T12:52:54.338810Z", + "startedAt": "2026-09-07T21:29:14.337312Z", + "status": "completed", + "target": { + "displayName": "xpeditis2.0 copy", + "kind": "git_revision", + "revision": "8446f879b676b303fdb2891388f88ff7e43f5fea", + "targetId": "target_sha256_ddfe0183466d4153b86e8f190318b958432df21c7d3bcaec8c57c2564c3f1208" + }, + "threatModel": { + "assets": [ + "User sessions, API-key authority, organization booking data and subscription entitlements; API-key/JWT authentication paths are distinct (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).", + "Booking documents, freight rate integrity, published blog assets, AI conversation history and tool access." + ], + "assumptions": [ + "User origin: requested complete security audit on new check_secu branch from current branch; this independent review performs architecture mapping only. No supplied threat model or knowledge base.", + "No first-party SECURITY.md found by resolver inventory; only vendored node_modules policies exist. No .env files read.", + "ConfigMap DATABASE_SSL=true and hostssl comments do not mean runtime clients consume TLS: app.module options and startup script omit ssl; CLI data-source consumes true but disables certificate validation (apps/backend/src/app.module.ts:165; apps/backend/scripts/setup/startup.js:13; apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26; infra/prod/k8s/base/02-configmap-backend.yaml:47).", + "ConfigMap AWS_S3_BUCKET=xpeditis-prod-documents affects CSV object loading; separate booking documents/PDF/blog consumers hardcode other buckets. Object-store policies and provisioned bucket existence remain external prerequisites (infra/prod/k8s/base/02-configmap-backend.yaml:71; apps/backend/src/application/services/csv-booking.service.ts:1269; apps/backend/src/application/services/booking-automation.service.ts:100; apps/backend/src/application/controllers/blog.controller.ts:23).", + "Current code uses httpOnly auth cookies; repository overview claiming localStorage token architecture is not sufficient evidence of current implementation (apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/application/auth/jwt.strategy.ts:40).", + "Coverage is architectural, not a completed vulnerability audit. External IAM, deployed networking, CI secrets, tenant enforcement of every handler, refresh lifecycle and carrier-token entropy/expiry are not fully established by this pass.", + "Nest TypeORM / production ConfigMap: DATABASE_HOST/PORT/NAME from ConfigService; DATABASE_SSL declared but absent from TypeORM options => 10.10.1.20:5432/xpeditis_prod; no explicit TLS option. Contr\u00f4le: synchronize:false; server pg_hba controls admission. Runtime factory does not consume DATABASE_SSL; deployment success and ambient driver options remain unknown Sources: apps/backend/src/app.module.ts:165, infra/prod/k8s/base/02-configmap-backend.yaml:44", + "TypeORM migration CLI / production migration Job: Job calls compiled data-source; DATABASE_SSL=true from ConfigMap => 10.10.1.20:5432/xpeditis_prod with ssl.rejectUnauthorized=false. Contr\u00f4le: TLS encryption without certificate validation in data-source. Sources: infra/prod/k8s/base/07-migration-job.yaml:52, apps/backend/src/infrastructure/persistence/typeorm/data-source.ts:26", + "Startup pg client and migration DataSource / image startup script: DATABASE_* directly consumed; no ssl option => configured PostgreSQL target, including prod target when prod ConfigMap injected. Contr\u00f4le: database credential and server admission. Different TLS behavior from migration Job; Job explicitly documents this at 07-migration-job.yaml:52 Sources: apps/backend/scripts/setup/startup.js:13, apps/backend/scripts/setup/startup.js:40", + "CSV object loader / production/object-storage configured: company config metadata.minioObjectKey; AWS_S3_BUCKET from ConfigMap; storage adapter AWS_S3_ENDPOINT => https://fsn1.your-objectstorage.com/xpeditis-prod-documents/{metadata.minioObjectKey}. Contr\u00f4le: S3 credential permissions; fallback to local file on error. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:149, apps/backend/src/infrastructure/storage/s3-storage.adapter.ts:244, infra/prod/k8s/base/02-configmap-backend.yaml:70", + "CSV local loader / local and object-store fallback: absolute filePath unchanged; otherwise process.cwd()/src/infrastructure/storage/csv-storage/rates joined with filePath => {cwd}/src/infrastructure/storage/csv-storage/rates/{relative filePath}, or absolute filePath. Contr\u00f4le: host filesystem permissions and administrative configuration authority. Sources: apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:125, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:164, apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts:287", + "Booking document upload/download / all S3 deployments including prod: hardcoded bucket; document key constructed in service; endpoint from adapter => xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{originalFilename}; prod endpoint https://fsn1.your-objectstorage.com. Contr\u00f4le: carrier token, ACCEPTED status, password hash when present, document belongs to token booking. AWS_S3_BUCKET=xpeditis-prod-documents does not select this bucket Sources: apps/backend/src/application/services/csv-booking.service.ts:1269, apps/backend/src/application/services/csv-booking.service.ts:848, apps/backend/src/application/services/csv-booking.service.ts:866", + "Booking PDF automation / all S3 deployments: hardcoded bucket and booking-derived key => xpeditis-bookings/bookings/{booking.id}/{booking.bookingNumber.value}.pdf. Contr\u00f4le: backend automation and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/services/booking-automation.service.ts:98", + "Blog image API / all S3 deployments: hardcoded bucket; public route constructs blog-images filename key => xpeditis-blog/blog-images/{filename}. Contr\u00f4le: public publication workflow and S3 credentials. AWS_S3_BUCKET does not select this bucket Sources: apps/backend/src/application/controllers/blog.controller.ts:23, apps/backend/src/application/controllers/blog.controller.ts:27, apps/backend/src/application/controllers/blog.controller.ts:76", + "Trade assistant AI / configured OPENAI_API_KEY: fixed Responses endpoint; OPENAI_MODEL defaults gpt-4.1-mini => https://api.openai.com/v1/responses; question/history/passages and invoked tool outcomes. Contr\u00f4le: actor-bound registry invocation; 4 tool rounds, 800 output tokens, store:false, 30 second timeout. Sources: apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:51, apps/backend/src/infrastructure/ai/openai-trade.adapter.ts:115, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:162, apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216" + ], + "attackerCapabilities": [ + "Unauthenticated caller can request public endpoints and supply arbitrary ordinary request input, but is not assumed to possess carrier token, password, Stripe signing secret, administrative API key or deployment control.", + "Authenticated organization user controls their requests and AI questions; crossing into another tenant, administrative capability or higher-plan entitlement would be a new authority gain. MCP visibility alone is not permission evidence; registry invocation enforces policy (apps/backend/src/application/mcp/capability.registry.ts:85).", + "Privileged CSV configuration/import and release operators are separate conditional workflows; ordinary remote callers are not assumed to control local files, deployment variables, or migration scripts." + ], + "securityObjectives": [ + "Preserve organization and document ownership across API, MCP and AI handlers; evaluate handler-level scoping separately from global authentication.", + "Keep public token capabilities scoped to intended booking and action; enforce additional document password/state controls at every document consumer (apps/backend/src/application/services/csv-booking.service.ts:848).", + "Bind financial state changes to verified Stripe events; protect credentials and sensitive object contents with actual consumed storage/database configuration.", + "Retain effective resource distinctions: CSV configured bucket, document/PDF/blog hardcoded buckets, and distinct database startup versus migration TLS behavior." + ], + "summary": "Xpeditis freight platform uses Nest API with relational storage, CSV shipping rates, booking documents, subscription payments, MCP and AI assistant. Global ApiKeyOrJwtGuard and throttling protect normal API routes; public carrier links and Stripe webhook have separate authority checks (apps/backend/src/app.module.ts:210; apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/controllers/subscriptions.controller.ts:262). Production manifests describe Kubernetes plus private PostgreSQL and external object storage; actual deployment state is not supplied.", + "trustBoundaries": [ + "Browser to API: JWT extraction accepts httpOnly accessToken cookie; auth endpoints set cookies and security config defaults SameSite=lax with production Secure (apps/backend/src/application/auth/jwt.strategy.ts:40; apps/backend/src/application/controllers/auth.controller.ts:108; apps/backend/src/infrastructure/security/security.config.ts:195). Helmet/CORS/validation are applied at startup (apps/backend/src/main.ts:33; apps/backend/src/main.ts:42; apps/backend/src/main.ts:54).", + "External API key caller to application: key validation supplies user context; absent key falls back to JWT (apps/backend/src/application/guards/api-key-or-jwt.guard.ts:34).", + "MCP tools/list visibility is separate from invocation enforcement: registry checks role and plan again and parses schema before handler execution, recording audit (apps/backend/src/application/mcp/capability.registry.ts:85; apps/backend/src/application/mcp/capability.registry.ts:100).", + "AI invocation is bound to authenticated actor and uses the same capability registry; capability scope is not inherently read-only. Quota reserved before model request (apps/backend/src/application/trade-assistant/trade-assistant.service.ts:146; apps/backend/src/application/trade-assistant/trade-assistant.service.ts:216).", + "Carrier email token permits public accept/reject actions; document delivery independently requires accepted booking and password when hash exists (apps/backend/src/application/controllers/csv-booking-actions.controller.ts:28; apps/backend/src/application/services/csv-booking.service.ts:729; apps/backend/src/application/services/csv-booking.service.ts:848).", + "Stripe webhook is public and passes raw request body/signature to service, with adapter constructEvent verification using configured webhook secret (apps/backend/src/application/controllers/subscriptions.controller.ts:262; apps/backend/src/infrastructure/stripe/stripe.adapter.ts:251)." + ] + } + }, + "schemaVersion": "1.0" +} diff --git a/infra/prod/README.md b/infra/prod/README.md index 2db7daf..b53d0e5 100644 --- a/infra/prod/README.md +++ b/infra/prod/README.md @@ -113,6 +113,18 @@ make preflight ## Règles de sécurité non négociables +PostgreSQL : avant de déployer les correctifs TLS, renseigner `DATABASE_SSL_CA` +dans le Secret backend chiffré SOPS avec le contenu PEM du certificat **public** +`/var/lib/xpeditis/certs/server.crt` de db-01, récupéré par un canal d’administration +authentifié. Ne jamais copier `server.key`. Le gabarit de secrets contient le +champ à renseigner. Le backend et le Job de migration utilisent ce même Secret. +Conserver `DATABASE_SSL=true` et un `DATABASE_HOST` présent dans les SAN du +certificat (IP privée ou nom DNS). Les certificats non approuvés et les noms +incorrects sont désormais refusés ; le réseau privé ne remplace pas ce contrôle. +Lors d’un renouvellement, distribuer le nouveau certificat de confiance avant +la bascule serveur et redémarrer les clients concernés. Ne pas désactiver la +vérification TLS pour contourner une erreur de certificat. + 1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml` chiffrés avec age. `make secrets-check` refuse le contraire. 2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé, diff --git a/infra/prod/k8s/base/03-secrets.template.yaml b/infra/prod/k8s/base/03-secrets.template.yaml index 1935923..d601a03 100644 --- a/infra/prod/k8s/base/03-secrets.template.yaml +++ b/infra/prod/k8s/base/03-secrets.template.yaml @@ -32,6 +32,11 @@ stringData: # --- Base de donnees ------------------------------------------------------- DATABASE_USER: "xpeditis" DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01 + # Certificat PUBLIC PEM de db-01 (/var/lib/xpeditis/certs/server.crt), + # obtenu via un canal d'administration authentifie. Jamais server.key. + # Necessaire pour authentifier le certificat auto-signe de PostgreSQL. + DATABASE_SSL_CA: | + REMPLACER_PAR_LE_CERTIFICAT_PUBLIC_PEM_DE_DB_01 # --- Redis ----------------------------------------------------------------- REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01 diff --git a/infra/prod/k8s/base/07-migration-job.yaml b/infra/prod/k8s/base/07-migration-job.yaml index 0cb462d..ed582ed 100644 --- a/infra/prod/k8s/base/07-migration-job.yaml +++ b/infra/prod/k8s/base/07-migration-job.yaml @@ -49,8 +49,8 @@ spec: - name: migrate image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__ imagePullPolicy: IfNotPresent - # CLI TypeORM sur la source de donnees compilee. Elle honore - # DATABASE_SSL, contrairement au client de secours de startup.js. + # CLI TypeORM sur la source de donnees compilee. API, CLI et startup + # partagent DATABASE_SSL et DATABASE_SSL_CA avec verification TLS. command: - node - ./node_modules/typeorm/cli.js diff --git a/infra/prod/scripts/01-setup-data-node.sh b/infra/prod/scripts/01-setup-data-node.sh index 505dc3e..5287e85 100755 --- a/infra/prod/scripts/01-setup-data-node.sh +++ b/infra/prod/scripts/01-setup-data-node.sh @@ -87,11 +87,10 @@ systemctl enable --now docker systemctl restart docker # --- 3. Certificat TLS de PostgreSQL ---------------------------------------- -# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un -# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a -# CHIFFRER le transport, pas a prouver une identite publique. -# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce -# certificat : c'est coherent, et documente dans 04-noeud-donnees.md. +# Certificat auto-signe : distribuer server.crt (public) aux clients via +# DATABASE_SSL_CA, par un canal d'administration authentifie. Les clients +# verifient le certificat et son SAN, meme sur le reseau prive. +# Ne jamais distribuer server.key ni desactiver rejectUnauthorized. if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then log "Generation du certificat TLS PostgreSQL (10 ans)" openssl req -new -x509 -days 3650 -nodes \