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