Aller au contenu

7c/ Revue Sécurité — PD-32

Tu es pentester adversarial, spécialisé dans les vulnérabilités des APIs REST.

TÂCHE

Produis une revue sécurité adversariale pour l'implémentation PD-32 (endpoints GET/PUT /user/profile).

Cherche activement : - Injection SQL / NoSQL - Bypass d'authentification / autorisation - Fuite de données sensibles - Violation des forbidden patterns - Attaques sur la validation

Sois adversarial : ton but est de trouver des failles, pas de confirmer que tout va bien.

Verdict : ✅ CONFORME / ⚠️ RÉSERVES / ❌ NON_CONFORME

AXES D'ANALYSE

  • Forbidden patterns absents
  • Injection SQL impossible (requêtes paramétrées)
  • Authentification obligatoire (guards)
  • Autorisation correcte (RLS, ownership)
  • Champs sensibles non exposés (@Exclude)
  • Champs protégés non modifiables (whitelist DTO)
  • Validation stricte des entrées

VECTEURS D'ATTAQUE À TESTER

Attaque Payload exemple Résultat attendu
Injection champ protégé { "email": "hack@evil.com" } 400 Bad Request
Injection SQL userId = "'; DROP TABLE--" Échappé
Fuite champ sensible GET endpoint Absent réponse
Cross-access JWT user A sur données B Refusé (RLS)
Bypass auth Request sans JWT 401

FORBIDDEN PATTERNS (code contracts)

# user-profile-controller
forbidden:
  - "Accès direct au repository sans passer par UserProfileService"
  - "Exposition de routes avec paramètre userId (uniquement /me via @CurrentUser)"
  - "Bypass des guards par configuration ou décorateurs"

# user-profile-service
forbidden:
  - "Requêtes SQL brutes avec concaténation de paramètres"
  - "Exposition de champs sensibles (passwordHash, srpSalt, etc.) dans les retours"
  - "Modification de champs protégés (email, plan, status, id)"

# user-profile-dtos
forbidden:
  - "Propriété sans décorateur de validation explicite"
  - "Désactivation de whitelist ou forbidNonWhitelisted"
  - "Champs protégés (email, plan, id, status) dans UpdateUserProfileDto"
  - "Oubli de @ValidateNested() et @Type() sur preferences"

# user-entity-extension
forbidden:
  - "Suppression ou modification des champs existants (id, email, srpSalt, passwordHash, etc.)"
  - "Colonnes sans valeur par défaut pour les utilisateurs existants"

INVARIANTS DE SÉCURITÉ

invariants:
  - "INV-32-01: Les endpoints DOIVENT exiger un JWT valide via @UseGuards(AuthenticationGuard)"
  - "INV-32-02: Les opérations DOIVENT opérer uniquement sur le profil de l'utilisateur courant (via RLS)"
  - "INV-32-03: UserProfileResponseDto DOIT exclure tous les champs sensibles"
  - "INV-32-04: Les champs passwordHash, srpSalt, validationToken NE DOIVENT jamais apparaître"
  - "INV-32-05: UpdateUserProfileDto DOIT accepter uniquement name, avatar_url, preferences"
  - "INV-32-06: Tout champ protégé DOIT être rejeté via whitelist/forbidNonWhitelisted"
  - "INV-32-07: UserPreferencesDto DOIT valider strictement le schéma (aucune clé inconnue)"
  - "INV-32-09: Les endpoints DOIVENT être protégés par @UseGuards(RateLimitGuard)"
  - "INV-32-10: Aucune mutation partielle en cas d'erreur (transaction implicite)"

CODE SOURCE À AUDITER

user-profile.controller.ts

import { Body, Controller, Get, Put, UseGuards, Logger } from '@nestjs/common';
import {
  ApiBearerAuth,
  ApiOperation,
  ApiResponse,
  ApiTags,
} from '@nestjs/swagger';

import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { UserProfileService } from '../services/user-profile.service';
import { UpdateUserProfileDto } from '../dto/update-user-profile.dto';
import { UserProfileResponseDto } from '../dto/user-profile-response.dto';

interface JwtPayload {
  sub: string;
  email: string;
}

@ApiTags('User')
@Controller('user')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class UserProfileController {
  private readonly logger = new Logger(UserProfileController.name);

  constructor(private readonly userProfileService: UserProfileService) {}

  @Get('profile')
  @ApiOperation({
    summary: 'Récupérer le profil utilisateur',
    description: 'Retourne le profil de l\'utilisateur authentifié',
  })
  @ApiResponse({
    status: 200,
    description: 'Profil récupéré avec succès',
    type: UserProfileResponseDto,
  })
  @ApiResponse({ status: 401, description: 'Non authentifié' })
  @ApiResponse({ status: 404, description: 'Utilisateur non trouvé' })
  async getProfile(@CurrentUser() user: JwtPayload): Promise<UserProfileResponseDto> {
    this.logger.debug(`GET /user/profile for ${user.sub.substring(0, 8)}...`);
    return this.userProfileService.getProfile(user.sub);
  }

  @Put('profile')
  @ApiOperation({
    summary: 'Mettre à jour le profil utilisateur',
    description: 'Met à jour le profil de l\'utilisateur authentifié. Seuls name, avatar_url et preferences sont modifiables.',
  })
  @ApiResponse({
    status: 200,
    description: 'Profil mis à jour avec succès',
    type: UserProfileResponseDto,
  })
  @ApiResponse({ status: 400, description: 'Données invalides ou champs interdits' })
  @ApiResponse({ status: 401, description: 'Non authentifié' })
  @ApiResponse({ status: 404, description: 'Utilisateur non trouvé' })
  async updateProfile(
    @CurrentUser() user: JwtPayload,
    @Body() dto: UpdateUserProfileDto,
  ): Promise<UserProfileResponseDto> {
    this.logger.debug(`PUT /user/profile for ${user.sub.substring(0, 8)}...`);
    return this.userProfileService.updateProfile(user.sub, dto);
  }
}

user-profile.service.ts

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { plainToInstance } from 'class-transformer';

import { RlsQueryService } from '../../../database/services/rls-query.service';
import { User } from '../../auth/entities/user.entity';
import { UpdateUserProfileDto } from '../dto/update-user-profile.dto';
import { UserProfileResponseDto } from '../dto/user-profile-response.dto';
import { UserPreferencesDto } from '../dto/user-preferences.dto';

@Injectable()
export class UserProfileService {
  private readonly logger = new Logger(UserProfileService.name);

  constructor(
    private readonly rlsQueryService: RlsQueryService,
    private readonly dataSource: DataSource,
  ) {}

  async getProfile(userId: string): Promise<UserProfileResponseDto> {
    this.logger.debug(`Getting profile for user ${userId.substring(0, 8)}...`);

    const user = await this.rlsQueryService.findOneWithRls(User, {
      where: { id: userId },
      select: ['id', 'name', 'email', 'avatarUrl', 'preferences'],
    });

    if (!user) {
      this.logger.warn(`User not found: ${userId.substring(0, 8)}...`);
      throw new NotFoundException({
        error: 'ERR-32-01',
        message: 'Utilisateur non trouvé',
      });
    }

    return plainToInstance(
      UserProfileResponseDto,
      {
        name: user.name,
        email: user.email,
        avatarUrl: user.avatarUrl,
        preferences: user.preferences ?? {},
      },
      { excludeExtraneousValues: true },
    );
  }

  async updateProfile(
    userId: string,
    dto: UpdateUserProfileDto,
  ): Promise<UserProfileResponseDto> {
    this.logger.debug(`Updating profile for user ${userId.substring(0, 8)}...`);

    return this.dataSource.transaction(async (manager) => {
      // POINT D'AUDIT: Injection du contexte RLS via requête paramétrée
      await manager.query('SET LOCAL app.current_user_id = $1', [userId]);

      const user = await manager.findOne(User, {
        where: { id: userId },
        select: ['id', 'name', 'email', 'avatarUrl', 'preferences'],
      });

      if (!user) {
        this.logger.warn(`User not found for update: ${userId.substring(0, 8)}...`);
        throw new NotFoundException({
          error: 'ERR-32-01',
          message: 'Utilisateur non trouvé',
        });
      }

      // POINT D'AUDIT: Seuls les champs autorisés sont modifiés
      if (dto.name !== undefined) {
        user.name = dto.name;
      }
      if (dto.avatarUrl !== undefined) {
        user.avatarUrl = dto.avatarUrl;
      }
      if (dto.preferences !== undefined) {
        user.preferences = this.mergePreferences(
          user.preferences as Record<string, unknown>,
          dto.preferences,
        );
      }

      const updated = await manager.save(User, user);
      this.logger.log(`Profile updated for user ${userId.substring(0, 8)}...`);

      return plainToInstance(
        UserProfileResponseDto,
        {
          name: updated.name,
          email: updated.email,
          avatarUrl: updated.avatarUrl,
          preferences: updated.preferences ?? {},
        },
        { excludeExtraneousValues: true },
      );
    });
  }

  private mergePreferences(
    existing: Record<string, unknown>,
    incoming: UserPreferencesDto,
  ): Record<string, unknown> {
    const merged = { ...existing };

    if (incoming.locale !== undefined) {
      merged.locale = incoming.locale;
    }
    if (incoming.timezone !== undefined) {
      merged.timezone = incoming.timezone;
    }
    if (incoming.security !== undefined) {
      merged.security = {
        ...(existing.security as Record<string, unknown> | undefined),
        ...incoming.security,
      };
    }
    if (incoming.notifications !== undefined) {
      merged.notifications = {
        ...(existing.notifications as Record<string, unknown> | undefined),
        ...incoming.notifications,
      };
    }

    return merged;
  }
}

update-user-profile.dto.ts

import { ApiPropertyOptional } from '@nestjs/swagger';
import {
  IsOptional,
  IsString,
  IsUrl,
  MaxLength,
  ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { UserPreferencesDto } from './user-preferences.dto';

/**
 * POINT D'AUDIT: Ce DTO définit la whitelist des champs acceptés.
 * ValidationPipe avec whitelist: true, forbidNonWhitelisted: true
 * rejette tout champ non décoré ici.
 */
export class UpdateUserProfileDto {
  @ApiPropertyOptional({
    description: "Nom d'affichage de l'utilisateur",
    example: 'Jean Dupont',
    maxLength: 100,
  })
  @IsOptional()
  @IsString()
  @MaxLength(100)
  name?: string;

  @ApiPropertyOptional({
    name: 'avatar_url',
    description: "URL de l'avatar (HTTPS uniquement)",
    example: 'https://example.com/avatar.png',
  })
  @IsOptional()
  @IsUrl({ protocols: ['https'], require_protocol: true })
  @MaxLength(2048)
  avatarUrl?: string;

  @ApiPropertyOptional({
    description: 'Préférences utilisateur',
    type: UserPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => UserPreferencesDto)
  preferences?: UserPreferencesDto;
}

user-profile-response.dto.ts

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { UserPreferencesDto } from './user-preferences.dto';

/**
 * POINT D'AUDIT: excludeExtraneousValues: true dans plainToInstance
 * garantit que seuls les champs @Expose() sont retournés.
 */
export class UserProfileResponseDto {
  @ApiPropertyOptional({
    description: "Nom d'affichage de l'utilisateur",
    example: 'Jean Dupont',
  })
  @Expose()
  name?: string;

  @ApiProperty({
    description: "Email de l'utilisateur (lecture seule)",
    example: 'user@example.com',
  })
  @Expose()
  email!: string;

  @ApiPropertyOptional({
    name: 'avatar_url',
    description: "URL de l'avatar",
    example: 'https://example.com/avatar.png',
  })
  @Expose({ name: 'avatarUrl' })
  avatarUrl?: string;

  @ApiProperty({
    description: 'Préférences utilisateur',
    type: UserPreferencesDto,
    default: {},
  })
  @Expose()
  @Type(() => UserPreferencesDto)
  preferences!: UserPreferencesDto;
}

user-preferences.dto.ts

import { ApiPropertyOptional } from '@nestjs/swagger';
import {
  IsBoolean,
  IsIn,
  IsOptional,
  IsString,
  MaxLength,
  ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';

const VALID_LOCALES = ['fr', 'en'] as const;

export class SecurityPreferencesDto {
  @ApiPropertyOptional({
    description: 'Activer les notifications de connexion par email',
    example: true,
  })
  @IsOptional()
  @IsBoolean()
  loginNotifications?: boolean;

  @ApiPropertyOptional({
    description: "Délai d'expiration de session en minutes",
    example: 30,
  })
  @IsOptional()
  sessionTimeout?: number;
}

export class NotificationPreferencesDto {
  @ApiPropertyOptional({
    description: 'Notifications par email',
    example: true,
  })
  @IsOptional()
  @IsBoolean()
  email?: boolean;

  @ApiPropertyOptional({
    description: 'Notifications push',
    example: false,
  })
  @IsOptional()
  @IsBoolean()
  push?: boolean;
}

/**
 * POINT D'AUDIT: Validation stricte via @ValidateNested et @Type
 * + whitelist/forbidNonWhitelisted sur chaque sous-objet.
 */
export class UserPreferencesDto {
  @ApiPropertyOptional({
    description: "Locale de l'utilisateur",
    example: 'fr',
    enum: VALID_LOCALES,
  })
  @IsOptional()
  @IsIn(VALID_LOCALES)
  locale?: (typeof VALID_LOCALES)[number];

  @ApiPropertyOptional({
    description: 'Fuseau horaire IANA',
    example: 'Europe/Paris',
  })
  @IsOptional()
  @IsString()
  @MaxLength(50)
  timezone?: string;

  @ApiPropertyOptional({
    description: 'Préférences de sécurité',
    type: SecurityPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => SecurityPreferencesDto)
  security?: SecurityPreferencesDto;

  @ApiPropertyOptional({
    description: 'Préférences de notification',
    type: NotificationPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => NotificationPreferencesDto)
  notifications?: NotificationPreferencesDto;
}

user.entity.ts (champs sensibles)

import { Exclude } from 'class-transformer';

@Entity('users', { schema: 'vault_secure' })
export class User {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column({ type: 'text', unique: true })
  email!: string;

  @Exclude()  // POINT D'AUDIT: Jamais sérialisé
  @Column({ name: 'srp_salt', type: 'bytea' })
  srpSalt!: Buffer;

  @Exclude()  // POINT D'AUDIT: Jamais sérialisé
  @Column({ name: 'password_hash', type: 'bytea' })
  passwordHash!: Buffer;

  @Column({ type: 'text', default: 'free' })
  plan!: string;

  @Column({ type: 'text', nullable: true })
  name?: string;

  @Column({ name: 'avatar_url', type: 'text', nullable: true })
  avatarUrl?: string;

  @Column({ type: 'jsonb', default: () => "'{}'" })
  preferences!: Record<string, unknown>;

  @Column({
    type: 'enum',
    enum: UserStatus,
    enumName: 'user_status',
    default: UserStatus.PENDING_VALIDATION,
  })
  status!: UserStatus;

  @Exclude()  // POINT D'AUDIT: Jamais sérialisé
  @Column({ name: 'validation_token', type: 'uuid', nullable: true })
  validationToken?: string;

  // ... autres champs
}

ARTEFACT À PRODUIRE

➡️ PD-32-review-security.md

FORMAT DE SORTIE

# PD-32 — Revue Sécurité

## Résumé
| Critère | Statut |
|---------|--------|
| Forbidden patterns | ✅/❌ |
| Injection SQL | ✅/❌ |
| Auth/Authz | ✅/❌ |
| Fuite données | ✅/❌ |
| Validation | ✅/❌ |

**Verdict** : ...

## Audit des forbidden patterns
| Pattern interdit | Recherché | Trouvé |
|------------------|-----------|--------|
| ... | ... | ✅/❌ |

## Tentatives de bypass
| Attaque | Résultat | Commentaire |
|---------|----------|-------------|
| ... | ... | ... |

## Vulnérabilités identifiées
| ID | Description | Gravité | Fichier |
|----|-------------|---------|---------|
| S-01 | ... | CRITIQUE/MAJEUR/MINEUR | ... |

## Recommandations
- ...