fix(security): enforce live entitlements and protect credentials

This commit is contained in:
David 2026-09-18 12:57:39 +02:00
parent c09b8be9ae
commit a0ac0379eb
58 changed files with 2323 additions and 111 deletions

View File

@ -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 { TradeAssistantModule } from './application/trade-assistant/trade-assistant.module';
import { McpModule } from './application/mcp/mcp.module'; import { McpModule } from './application/mcp/mcp.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -113,6 +115,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
return { return {
pinoHttp: { pinoHttp: {
serializers: safeHttpSerializers,
transport: usePretty transport: usePretty
? { ? {
target: 'pino-pretty', target: 'pino-pretty',
@ -181,6 +184,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'], entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
synchronize: false, // ✅ Force false - use migrations instead synchronize: false, // ✅ Force false - use migrations instead
logging: configService.get('DATABASE_LOGGING', false), logging: configService.get('DATABASE_LOGGING', false),
logger: new SafeDatabaseLogger(configService.get('DATABASE_LOGGING', false)),
autoLoadEntities: true, // Auto-load entities from forFeature() autoLoadEntities: true, // Auto-load entities from forFeature()
}), }),
inject: [ConfigService], inject: [ConfigService],

View File

@ -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<SubscriptionStatusType>([
'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<SubscriptionStatusType>(['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,
});
}
);
});

View File

@ -154,8 +154,8 @@ export class ApiKeysService {
organizationId: user.organizationId, organizationId: user.organizationId,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
plan: subscription.plan.value, plan: subscription.accessPlan.value,
planFeatures: [...subscription.plan.planFeatures], planFeatures: [...subscription.accessPlan.planFeatures],
}; };
} }

View File

@ -447,8 +447,8 @@ export class AuthService {
const subscription = await this.subscriptionService.getOrCreateSubscription( const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId user.organizationId
); );
plan = subscription.plan.value; plan = subscription.accessPlan.value;
planFeatures = [...subscription.plan.planFeatures]; planFeatures = [...subscription.accessPlan.planFeatures];
} catch (error) { } catch (error) {
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`); this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
} }

View File

@ -117,7 +117,7 @@ export class BookingsController {
const subscription = await this.subscriptionService.getOrCreateSubscription( const subscription = await this.subscriptionService.getOrCreateSubscription(
user.organizationId user.organizationId
); );
const maxShipments = subscription.plan.maxShipmentsPerYear; const maxShipments = subscription.maxShipmentsPerYear;
if (maxShipments !== -1) { if (maxShipments !== -1) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countShipmentsForOrganizationInYear( const count = await this.shipmentCounter.countShipmentsForOrganizationInYear(

View File

@ -178,7 +178,7 @@ export class CsvBookingsController {
if (req.user.role !== 'ADMIN') { if (req.user.role !== 'ADMIN') {
// Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year) // Check the paid-reservation limit (free/Bronze plan = 5 paid shipments/year)
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const maxShipments = subscription.plan.maxShipmentsPerYear; const maxShipments = subscription.maxShipmentsPerYear;
if (maxShipments !== -1) { if (maxShipments !== -1) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( const count = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(
@ -255,7 +255,7 @@ export class CsvBookingsController {
): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> { ): Promise<{ max: number; used: number; unlimited: boolean; limitReached: boolean }> {
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
const max = subscription.plan.maxShipmentsPerYear; const max = subscription.maxShipmentsPerYear;
const unlimited = max === -1; const unlimited = max === -1;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear( const used = await this.shipmentCounter.countPaidShipmentsForOrganizationInYear(

View File

@ -2,6 +2,11 @@ import { ExecutionContext, INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import request from 'supertest'; 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 { CsvBookingsController } from './csv-bookings.controller';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CsvBookingService } from '../services/csv-booking.service'; import { CsvBookingService } from '../services/csv-booking.service';
@ -11,6 +16,8 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
describe('CSV booking HTTP security', () => { describe('CSV booking HTTP security', () => {
let app: INestApplication; let app: INestApplication;
let subscription: Subscription;
const countPaidShipmentsForOrganizationInYear = jest.fn().mockResolvedValue(0);
const createBooking = jest.fn(async () => ({ id: 'booking' })); const createBooking = jest.fn(async () => ({ id: 'booking' }));
const getUserBookings = jest.fn(async () => ({ bookings: [] })); const getUserBookings = jest.fn(async () => ({ bookings: [] }));
beforeAll(async () => { beforeAll(async () => {
@ -21,11 +28,11 @@ describe('CSV booking HTTP security', () => {
{ {
provide: SubscriptionService, provide: SubscriptionService,
useValue: { useValue: {
getOrCreateSubscription: async () => ({ plan: { maxShipmentsPerYear: -1 } }), getOrCreateSubscription: async () => subscription,
}, },
}, },
{ provide: ConfigService, useValue: {} }, { provide: ConfigService, useValue: {} },
{ provide: SHIPMENT_COUNTER_PORT, useValue: {} }, { provide: SHIPMENT_COUNTER_PORT, useValue: { countPaidShipmentsForOrganizationInYear } },
{ provide: ORGANIZATION_REPOSITORY, useValue: {} }, { provide: ORGANIZATION_REPOSITORY, useValue: {} },
], ],
}) })
@ -49,7 +56,15 @@ describe('CSV booking HTTP security', () => {
afterAll(async () => { afterAll(async () => {
await app?.close(); await app?.close();
}); });
beforeEach(() => jest.clearAllMocks()); 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 () => { it('rejects VIEWER mutations before invoking the booking service', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
@ -76,6 +91,24 @@ describe('CSV booking HTTP security', () => {
.expect(413); .expect(413);
expect(createBooking).not.toHaveBeenCalled(); 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 () => { it('preserves permitted uploads', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/csv-bookings') .post('/csv-bookings')

View File

@ -119,7 +119,7 @@ export class InvitationsController {
description: 'Invitation expired or already used', description: 'Invitation expired or already used',
}) })
async verifyInvitation(@Param('token') token: string): Promise<InvitationResponseDto> { async verifyInvitation(@Param('token') token: string): Promise<InvitationResponseDto> {
this.logger.log(`Verifying invitation token: ${token}`); this.logger.log('Verifying invitation token');
const invitation = await this.invitationService.verifyInvitation(token); const invitation = await this.invitationService.verifyInvitation(token);

View File

@ -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 });
});
});

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
/** /**
* DomainExceptionFilter * DomainExceptionFilter
* *
@ -38,7 +39,7 @@ export class DomainExceptionFilter implements ExceptionFilter {
error: exception.name, error: exception.name,
message: typeof translated === 'string' ? translated : exception.message, message: typeof translated === 'string' ? translated : exception.message,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: safeRequestRoute(request),
}); });
} }
} }

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@ -60,8 +61,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
const reference = randomUUID().slice(0, 8); const reference = randomUUID().slice(0, 8);
this.logger.error( this.logger.error(
`[${reference}] ${request.method} ${request.url} — ${describe(exception)}`, `[${reference}] ${request.method} ${safeRequestRoute(request)} — ${unavailable ? 'dependency unavailable' : 'unexpected error'}`
exception instanceof Error ? exception.stack : undefined
); );
response.status(status).json({ response.status(status).json({
@ -71,7 +71,7 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
message: this.translate(key, lang), message: this.translate(key, lang),
reference, reference,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: safeRequestRoute(request),
}); });
} }
@ -81,9 +81,6 @@ export class UnhandledExceptionFilter implements ExceptionFilter {
} }
} }
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 * L'erreur vient-elle d'une dependance injoignable, plutot que d'une requete
* fautive ou d'un defaut du code ? * fautive ou d'un defaut du code ?

View File

@ -18,7 +18,7 @@ import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator'
* Feature Flag Guard * Feature Flag Guard
* *
* Checks if the user's subscription plan includes the required features. * 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: * Usage:
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard) * @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@ -58,19 +58,7 @@ export class FeatureFlagGuard implements CanActivate {
return true; return true;
} }
// Fast path: check plan features from JWT payload // Always resolve current rights, including after suspension or downgrade.
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
try { try {
const subscription = await this.subscriptionRepository.findByOrganizationId( const subscription = await this.subscriptionRepository.findByOrganizationId(
user.organizationId user.organizationId
@ -81,8 +69,9 @@ export class FeatureFlagGuard implements CanActivate {
this.throwFeatureRequired(requiredFeatures); this.throwFeatureRequired(requiredFeatures);
} }
const plan = subscription!.plan; const missingFeatures = requiredFeatures.filter(
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature)); feature => !subscription!.hasFeature(feature)
);
if (missingFeatures.length > 0) { if (missingFeatures.length > 0) {
this.throwFeatureRequired(requiredFeatures); this.throwFeatureRequired(requiredFeatures);

View File

@ -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);
});
});

View File

@ -1,3 +1,4 @@
import { safeRequestRoute } from '../logging/safe-http-log';
/** /**
* Performance Monitoring Interceptor * Performance Monitoring Interceptor
* *
@ -15,7 +16,8 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { method, url, user } = request; const { method, user } = request;
const url = safeRequestRoute(request);
const startTime = Date.now(); const startTime = Date.now();
return next.handle().pipe( return next.handle().pipe(
@ -39,10 +41,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
// Log error // Log error
this.logger.error( this.logger.error(`Request error: ${method} ${url} (${duration}ms)`);
`Request error: ${method} ${url} (${duration}ms) - ${error.message}`,
error.stack
);
// Capture exception in Sentry // Capture exception in Sentry
Sentry.withScope(scope => { Sentry.withScope(scope => {
@ -52,7 +51,7 @@ export class PerformanceMonitoringInterceptor implements NestInterceptor {
userId: user?.sub, userId: user?.sub,
duration, duration,
}); });
Sentry.captureException(error); Sentry.captureException(new Error('Request failed; sensitive error details omitted'));
}); });
throw error; throw error;

View File

@ -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 } });
});
});

View File

@ -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' };
},
};

View File

@ -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');
});
});

View File

@ -155,28 +155,17 @@ export class McpController {
/** /**
* Identite de l'appelant, completee de son offre. * Identite de l'appelant, completee de son offre.
* *
* Une cle API porte deja l'offre ; un jeton JWT ne la porte pas, elle est * L'offre est relue a chaque appel pour appliquer les suspensions et les
* alors lue sur l'abonnement. Sans cette resolution, un utilisateur connecte * changements de droits, meme si un jeton porte encore une ancienne offre.
* a l'application serait traite comme un compte Bronze.
*/ */
private async actorOf(user: UserPayload & { plan?: string }): Promise<CapabilityActor> { private async actorOf(user: UserPayload & { plan?: string }): Promise<CapabilityActor> {
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); const subscription = await this.subscriptions.getOrCreateSubscription(user.organizationId);
return { return {
id: user.id, id: user.id,
organizationId: user.organizationId, organizationId: user.organizationId,
role: user.role, role: user.role,
email: user.email, email: user.email,
plan: subscription.plan.value, plan: subscription.accessPlan.value,
}; };
} }
} }

View File

@ -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<unknown[]> }, '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, 'PENDING_PAYMENT'],
[-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<void> },
'sendCarrierBookingRequest'
)
.mockResolvedValue(undefined);
jest
.spyOn(
service as unknown as { notifyBookingRequestSent: () => Promise<void> },
'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
);
});
});

View File

@ -5,6 +5,7 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
UnauthorizedException, UnauthorizedException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
@ -150,6 +151,9 @@ export class CsvBookingService {
throw new BadRequestException('At least one document is required'); 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 // Generate unique confirmation token and booking number
const confirmationToken = uuidv4(); const confirmationToken = uuidv4();
const bookingId = uuidv4(); const bookingId = uuidv4();
@ -165,7 +169,6 @@ export class CsvBookingService {
// Flat per-booking service fee (forfait par booking) based on the org's plan. // 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 // A fee <= 0 (e.g. Platinium "sur mesure") means no automatic charge: the
// booking skips the payment gate and the carrier is notified immediately. // booking skips the payment gate and the carrier is notified immediately.
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
const requiresPayment = bookingFeeEur > 0; const requiresPayment = bookingFeeEur > 0;
const initialStatus = requiresPayment const initialStatus = requiresPayment
? CsvBookingStatus.PENDING_PAYMENT ? CsvBookingStatus.PENDING_PAYMENT
@ -247,17 +250,20 @@ export class CsvBookingService {
/** /**
* Resolve the flat per-booking fee (forfait par booking) for an organization * 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 * 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 * plan has a custom fee (-1, e.g. Platinium "sur mesure").
* bookings are not auto-charged. * An unknown fee must never be interpreted as a free booking.
*/ */
private async resolveBookingFeeEur(organizationId: string): Promise<number> { private async resolveBookingFeeEur(organizationId: string): Promise<number> {
try { try {
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId); 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; return fee > 0 ? fee : 0;
} catch (error: any) { } catch {
this.logger.error(`Failed to resolve booking fee: ${error?.message}`); this.logger.error('Failed to resolve booking fee');
return 0; throw new ServiceUnavailableException('Booking fee unavailable. Please retry later.');
} }
} }
@ -410,7 +416,7 @@ export class CsvBookingService {
}); });
this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`); this.logger.log(`Email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } 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');
} }
} }
@ -517,7 +523,7 @@ export class CsvBookingService {
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`); this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
} }
} catch (error: any) { } 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 // In-app notification for the user
@ -639,7 +645,7 @@ export class CsvBookingService {
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
); );
} catch (error: any) { } 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');
} }
// In-app notification for the user // In-app notification for the user
@ -700,7 +706,7 @@ export class CsvBookingService {
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
if (!booking) { if (!booking) {
throw new NotFoundException(`Booking with token ${token} not found`); throw new NotFoundException('Booking not found');
} }
return this.toResponseDto(booking); return this.toResponseDto(booking);
@ -714,7 +720,7 @@ export class CsvBookingService {
token: string, token: string,
password?: string password?: string
): Promise<CarrierDocumentsResponseDto> { ): Promise<CarrierDocumentsResponseDto> {
this.logger.log(`Getting documents for carrier with token: ${token}`); this.logger.log('Getting documents for carrier');
// Get ORM entity to access passwordHash // Get ORM entity to access passwordHash
const ormBooking = await this.csvBookingRepository['repository'].findOne({ const ormBooking = await this.csvBookingRepository['repository'].findOne({
@ -884,7 +890,7 @@ export class CsvBookingService {
* Accept a booking request * Accept a booking request
*/ */
async acceptBooking(token: string): Promise<CsvBookingResponseDto> { async acceptBooking(token: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Accepting booking with token: ${token}`); this.logger.log('Accepting booking');
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
@ -897,11 +903,9 @@ export class CsvBookingService {
where: { confirmationToken: token }, where: { confirmationToken: token },
}); });
// Accept the booking (domain logic validates status) // Resolve pricing before mutating the booking.
booking.accept();
// Apply the flat per-booking service fee (forfait par booking) from the org's plan
const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId); const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);
booking.accept();
booking.applyBookingFee(bookingFeeEur); booking.applyBookingFee(bookingFeeEur);
this.logger.log( this.logger.log(
`Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}` `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}€ (flat)` : 'none (custom)'} on booking ${booking.id}`
@ -931,7 +935,7 @@ export class CsvBookingService {
}); });
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`); this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } 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 // Create notification for user
@ -958,7 +962,7 @@ export class CsvBookingService {
* Reject a booking request * Reject a booking request
*/ */
async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> { async rejectBooking(token: string, reason?: string): Promise<CsvBookingResponseDto> {
this.logger.log(`Rejecting booking with token: ${token}`); this.logger.log('Rejecting booking');
const booking = await this.csvBookingRepository.findByToken(token); const booking = await this.csvBookingRepository.findByToken(token);
@ -1400,10 +1404,7 @@ export class CsvBookingService {
}); });
this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`); this.logger.log(`New documents notification sent to carrier: ${booking.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error( this.logger.error('Failed to send new documents notification');
`Failed to send new documents notification: ${error?.message}`,
error?.stack
);
} }
} }

View File

@ -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);
});
});

View File

@ -4,7 +4,14 @@
* Business logic for subscription and license management. * 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 { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
@ -90,7 +97,7 @@ export class SubscriptionService {
// ADMIN users always have PLATINIUM plan with no expiration. // ADMIN users always have PLATINIUM plan with no expiration.
// La regle vit dans le domaine : l'assistant la lit au meme endroit. // La regle vit dans le domaine : l'assistant la lit au meme endroit.
const isAdmin = userRole === PLATFORM_ADMIN_ROLE; const isAdmin = userRole === PLATFORM_ADMIN_ROLE;
const effectivePlan = resolveEffectivePlan(userRole, subscription.plan); const effectivePlan = resolveEffectivePlan(userRole, subscription.accessPlan);
const maxLicenses = effectivePlan.maxLicenses; const maxLicenses = effectivePlan.maxLicenses;
const availableLicenses = effectivePlan.isUnlimited() const availableLicenses = effectivePlan.isUnlimited()
? -1 ? -1
@ -280,6 +287,9 @@ export class SubscriptionService {
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId); const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) { if (checkoutSession) {
if (checkoutSession.metadata?.organizationId !== organizationId) {
throw new ForbiddenException('Checkout session does not belong to this organization');
}
this.logger.log( this.logger.log(
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}` `Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`
); );

View File

@ -62,35 +62,34 @@ export class Subscription {
}); });
} }
/** /** Current entitlements; keep the persisted plan intact for billing and recovery. */
* Reconstitute from persistence get accessPlan(): SubscriptionPlan {
*/ return this.isActive() ? this.props.plan : SubscriptionPlan.bronze();
/** }
* Check if a specific plan feature is available
*/
hasFeature(feature: import('../value-objects/plan-feature.vo').PlanFeature): boolean { 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 the maximum shipments per year allowed
*/ */
get maxShipmentsPerYear(): number { get maxShipmentsPerYear(): number {
return this.props.plan.maxShipmentsPerYear; return this.accessPlan.maxShipmentsPerYear;
} }
/** /**
* Get the per-booking fee for this subscription's plan * Get the per-booking fee for this subscription's plan
*/ */
get bookingFeeEur(): number { get bookingFeeEur(): number {
return this.props.plan.bookingFeeEur; return this.accessPlan.bookingFeeEur;
} }
/** /**
* Get the status badge for this subscription's plan * Get the status badge for this subscription's plan
*/ */
get statusBadge(): string { get statusBadge(): string {
return this.props.plan.statusBadge; return this.accessPlan.statusBadge;
} }
/** /**

View File

@ -55,10 +55,10 @@ describe('SMTP transport security', () => {
}); });
it('propagates secure delivery failures', async () => { it('propagates secure delivery failures', async () => {
const { adapter } = options('production'); const { adapter } = options('production');
sendMail.mockRejectedValue(new Error('certificate verification failed')); sendMail.mockRejectedValue(new Error('certificate verification failed: secret-fixture'));
await expect( await expect(
adapter.send({ to: 'test@example.org', subject: 'Test', text: 'Test' }) adapter.send({ to: 'test@example.org', subject: 'Test', text: 'Test' })
).rejects.toThrow('certificate verification failed'); ).rejects.toThrow('Email delivery failed');
}); });
}); });

View File

@ -172,8 +172,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)}` `✅ 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) { } catch (error) {
this.logger.error(`Failed to send email to ${options.to}`, error); this.logger.error('Email delivery failed');
throw error; throw new Error('Email delivery failed');
} }
} }
@ -313,23 +313,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); this.logger.log(`Invitation email sent to ${email} for ${organizationName}`);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('Invitation email delivery failed');
const errorCode = (error as any).code;
const errorResponse = (error as any).response;
const errorResponseCode = (error as any).responseCode;
const errorCommand = (error as any).command;
this.logger.error(`[sendInvitationWithToken] ERROR MESSAGE: ${errorMessage}`); throw new Error('Invitation email delivery failed');
this.logger.error(`[sendInvitationWithToken] ERROR CODE: ${errorCode}`);
this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE: ${errorResponse}`);
this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE CODE: ${errorResponseCode}`);
this.logger.error(`[sendInvitationWithToken] ERROR COMMAND: ${errorCommand}`);
if (error instanceof Error && error.stack) {
this.logger.error(`[sendInvitationWithToken] STACK: ${error.stack.substring(0, 500)}`);
}
throw error;
} }
} }

View File

@ -4,6 +4,7 @@
* Used for migrations and CLI commands * Used for migrations and CLI commands
*/ */
import { SafeDatabaseLogger } from './safe-database-logger';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { join } from 'path'; import { join } from 'path';
@ -24,6 +25,7 @@ export const AppDataSource = new DataSource({
subscribers: [], subscribers: [],
synchronize: false, // Never use in production synchronize: false, // Never use in production
logging: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development',
logger: new SafeDatabaseLogger(process.env.NODE_ENV === 'development'),
ssl: databaseTlsOptions( ssl: databaseTlsOptions(
process.env.DATABASE_SSL, process.env.DATABASE_SSL,
process.env.DATABASE_SSL_CA, process.env.DATABASE_SSL_CA,

View File

@ -46,14 +46,14 @@ export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort {
} }
async findByToken(token: string): Promise<CsvBooking | null> { async findByToken(token: string): Promise<CsvBooking | null> {
this.logger.log(`Finding CSV booking by token: ${token}`); this.logger.log('Finding CSV booking by token');
const ormEntity = await this.repository.findOne({ const ormEntity = await this.repository.findOne({
where: { confirmationToken: token }, where: { confirmationToken: token },
}); });
if (!ormEntity) { if (!ormEntity) {
this.logger.log(`CSV booking not found for token: ${token}`); this.logger.log('CSV booking not found for token');
return null; return null;
} }

View File

@ -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();
});
});

View File

@ -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);
}
}

View File

@ -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.

View File

@ -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.

26
audit_security/JOURNAL.md Normal file
View File

@ -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.

View File

@ -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.

71
audit_security/README.md Normal file
View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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<OrganizationResponseDto> {
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.

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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).

View File

@ -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).

View File

@ -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.

View File

@ -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).

View File

@ -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.

View File

@ -214,6 +214,7 @@ services:
STRIPE_PRO_YEARLY_PRICE_ID: "price_1SrItG4atifoBlu1CiSKold0" STRIPE_PRO_YEARLY_PRICE_ID: "price_1SrItG4atifoBlu1CiSKold0"
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: "price_1SrNj94atifoBlu1F6axOXrR" STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: "price_1SrNj94atifoBlu1F6axOXrR"
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: "price_1SrNiA4atifoBlu11RJD0ocG" STRIPE_ENTERPRISE_YEARLY_PRICE_ID: "price_1SrNiA4atifoBlu11RJD0ocG"
OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY must be supplied at deployment}
networks: networks:
- xpeditis_internal - xpeditis_internal

View File

@ -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é.