71 lines
5.5 KiB
Markdown
71 lines
5.5 KiB
Markdown
# 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.
|