First commit

This commit is contained in:
Johan
2025-12-18 14:35:15 +01:00
commit 460c962bb6
26 changed files with 796 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.idea

234
README.md Normal file
View File

@@ -0,0 +1,234 @@
# Démonstration: OWASP A01:2025 - Broken Access Control
Cette démonstration illustre la faille OWASP A01:2025 (Broken Access Control) en utilisant un projet Django moderne.
Nous allons simuler le cas le plus courant : **l'IDOR (Insecure Direct Object Reference)**.
## 1. Objectif
Démontrer comment un utilisateur peut voir les données d'un autre utilisateur en modifiant simplement un ID dans l'URL, et comment y remédier.
## 2. Configuration du projet
1. **Installer les dépendances :**
```bash
poetry install
```
2. **Créer les migrations :**
```bash
python manage.py makemigrations profiles
```
3. **Appliquer les migrations (créer la base de données) :**
```bash
python manage.py migrate
```
4. **Créer des utilisateurs de test :**
Nous avons besoin d'au moins deux utilisateurs pour tester.
* **Créez un superutilisateur (admin) :**
```bash
python manage.py createsuperuser
```
(Par exemple : `admin` / `password123`)
* **Créez un utilisateur normal (user1) :**
Lancez à nouveau :
```bash
python manage.py createsuperuser
```
(Par exemple : `user1` / `password123`)
*Note : Nous utilisons `createsuperuser` pour `user1` par simplicité, mais ne lui donnez pas le statut de "superuser".*
5. **Lancer le serveur de test :**
```bash
python manage.py runserver
```
## 3. Scénario de test (la faille)
1. **Connectez-vous en tant qu'admin** sur [http://127.0.0.1:8000/accounts/login/](http://127.0.0.1:8000/accounts/login/).
2. Allez sur l'interface d'admin : [http://127.0.0.1:8000/admin/](http://127.0.0.1:8000/admin/)
3. Allez dans "Profiles". Vous verrez deux profils, un pour `admin` (ID 1) et un pour `user1` (ID 2).
4. Cliquez sur le profil de `admin` (ID 1) et ajoutez une "Note secrète", par exemple : `Secret de l'admin`.
5. Cliquez sur le profil de `user1` (ID 2) et ajoutez une "Note secrète", par exemple : `Top secret de user1`.
6. **Déconnectez-vous** (http://127.0.0.1:8000/accounts/logout/).
---
### **L'attaque (IDOR)**
1. **Connectez-vous en tant que `user1`** sur [http://127.0.0.1:8000/accounts/login/](http://127.0.0.1:8000/accounts/login/).
2. Allez sur la page d'accueil (http://127.0.0.1:8000/) et cliquez sur le lien "Voir mon profil (VULNÉRABLE)".
3. Vous arrivez sur `http://127.0.0.1:8000/profile/vulnerable/2/`. Vous voyez bien votre secret : `Top secret de user1`.
4. **Maintenant, modifiez l'URL dans votre navigateur.** Changez l'ID `2` par l'ID `1` (le profil de l'admin) :
`http://127.0.0.1:8000/profile/vulnerable/1/`
5. **PROBLÈME :** Vous êtes connecté en tant que `user1`, mais vous pouvez voir le secret de `admin` (`Secret de l'admin`) !
C'est une faille "Broken Access Control" de type IDOR. La vue `VulnerableProfileView` vérifie bien *si* l'utilisateur est connecté, mais elle ne vérifie pas *si* l'utilisateur connecté a le droit de voir *cet objet spécifique*.
```mermaid
graph TD
subgraph "Scénario de l'attaque IDOR"
A["User1 (ID=2) se connecte"] --> B{"User1 demande /profile/vulnerable/2/"};
B --> C["Serveur vérifie: user connecté? Oui"];
C --> D["Serveur renvoie 200 OK <br> 'Secret de user1'"];
D --> E{"User1 modifie l'URL <br> demande /profile/vulnerable/1/"};
E --> F["Serveur vérifie: user connecté? Oui"];
F --> G["Serveur renvoie 200 OK <br> 'Secret de admin' (ID=1)"];
G --> H[("FAILLE DE SÉCURITÉ")];
end
```
## 4. La correction
1. En étant toujours connecté en tant que `user1`, retournez à l'accueil (http://127.0.0.1:8000/).
2. Cliquez sur le lien "Voir mon profil (SÉCURISÉ)".
3. Vous arrivez sur `http://127.0.0.1:8000/profile/secure/2/`. Tout fonctionne, vous voyez votre secret.
4. **Maintenant, tentez la même attaque.** Modifiez l'URL et remplacez `2` par `1` :
`http://127.0.0.1:8000/profile/secure/1/`
5. **SOLUTION :** Django vous renvoie une erreur **"403 Forbidden"**. L'accès est refusé.
<!-- end list -->
```mermaid
graph TD
subgraph "Flux de la vue sécurisée"
A{"User1 (ID=2) demande /profile/secure/1/"} --> B["Serveur vérifie: user connecté? Oui"];
B --> C{"Serveur exécute test_func: <br> request.user == profile.user?"};
C -- "Renvoie False" --> D["Serveur renvoie 403 Forbidden"];
D --> E[("ACCÈS REFUSÉ")];
end
```
## 5. Explication du code corrigé
Ouvrez le fichier `profiles/views.py`.
* **`VulnerableProfileView`** :
```python
class VulnerableProfileView(LoginRequiredMixin, DetailView):
model = Profile
template_name = 'profiles/profile_detail.html'
# VULNÉRABILITÉ: Ne vérifie pas QUI est l'utilisateur!
# Elle cherche juste l'objet Profile par son ID (pk).
```
Cette vue hérite de `LoginRequiredMixin` (l'utilisateur doit être connecté) et `DetailView` (charge un objet par son `pk`). Elle ne vérifie jamais si `request.user` est le propriétaire du `Profile` qu'elle affiche.
* **`SecureProfileView`** :
```python
class SecureProfileView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
model = Profile
template_name = 'profiles/profile_detail.html'
# CORRECTION: On utilise UserPassesTestMixin pour vérifier la permission
def test_func(self):
# Récupère l'objet profile que DetailView a trouvé
profile = self.get_object()
# Vérifie si l'utilisateur connecté est le propriétaire du profil
return self.request.user == profile.user
# Si test_func renvoie False, Django renvoie un 403 Forbidden.
```
Cette vue ajoute le `UserPassesTestMixin`. Ce mixin exécute la méthode `test_func()` avant d'afficher la page. Si `test_func()` renvoie `True`, la page s'affiche. Si elle renvoie `False`, l'utilisateur reçoit une erreur 403.
Ici, on vérifie simplement que l'utilisateur de la requête (`self.request.user`) est bien le même que l'utilisateur lié au profil (`profile.user`). C'est la façon "moderne" et "Django" de gérer le contrôle d'accès au niveau de l'objet.
## 6. Allez plus loin avec l'outil OWASP ZAP
Les failles comme l'IDOR (testée manuellement) ou les pages "oubliées" sont courantes. Des outils comme **OWASP ZAP** (Zed Attack Proxy) sont conçus pour les trouver. ZAP fonctionne comme un "proxy" : il s'intercale entre votre navigateur et le site web pour intercepter, analyser et attaquer le trafic.
Voici deux exemples d'utilisation de ZAP sur notre projet.
### Partie 1 : Scan automatisé et autres failles (alertes)
ZAP dispose d'un **"Automated Scan"** (Scan Automatisé) qui va "spider" (parcourir) tout le site puis lancer une série de tests d'attaque sur chaque page trouvée pour y déceler des failles de configuration, des vulnérabilités XSS, et bien plus.
**Comment lancer ce scan :**
1. Ouvrez OWASP ZAP.
2. Dans la fenêtre principale, assurez-vous d'être sur l'onglet **"Démarrage"**.
3. Entrez l'URL de votre application Django locale : `http://127.0.0.1:8000`
4. Cliquez sur le bouton **"Attaquer"** pour lancer le scan automatisé.
5. ZAP va s'occuper de tout. Attendez simplement que le scan se termine.
6. Une fois le scan terminé, cliquez sur l'onglet **"Alertes"** en bas pour voir les résultats.
<!-- end list -->
```mermaid
graph TD
subgraph "Processus du scan automatisé ZAP"
A["Entrer l'URL cible"] --> B["Cliquer 'Attaquer'"];
B --> C["ZAP explore le site 'Spider'"];
C --> D["ZAP lance des attaques 'Active Scan'"];
D --> E["Résultats affichés dans l'onglet 'Alertes'"];
E --> F["Exemple: 'Server Leaks Version'"];
end
```
**Résultat sur notre projet :**
Le screenshot ci-dessous montre l'onglet "Alertes" après le scan. ZAP a trouvé de nombreux problèmes qui ne sont pas liés à notre code, mais à la *configuration* du serveur (A02:2025 - Security Misconfiguration).
![OWASP ZAP AT](./img/OWASP_ZAP_AUTOMATED_SCAN_ATTACK_MODE.png)
* **Analyse d'une alerte : "Server Leaks Version Information..."**
* **Ce que ZAP a trouvé :** L'alerte en surbrillance nous dit que le serveur est trop "bavard". Dans l'en-tête (Header) de sa réponse, il inclut : `WSGIServer/0.2 CPython/3.13.5`.
* **Pourquoi est-ce un problème ?** Un attaquant voit maintenant que nous utilisons `CPython 3.13.5`. S'il existe une faille de sécurité *connue* (une CVE) pour cette version, l'attaquant peut l'exploiter directement.
* **Autres alertes visibles :** ZAP a également trouvé "Cookie No HttpOnly Flag" et "Content Security Policy (CSP) Header Not Set".
---
### Partie 2 : Découverte de pages cachées (Parcours Forcés)
La faille A01 (Broken Access Control) ne concerne pas seulement les objets (voir le profil d'un autre), mais aussi l'accès à des fonctionnalités entières. L'outil **"Parcours forcés"** de ZAP est conçu pour trouver les pages "cachées" en testant une liste de noms courants.
**Comment lancer ce scan :**
1. Dans ZAP, assurez-vous d'avoir visité votre site (`http://127.0.0.1:8000`) pour qu'il apparaisse dans "l'arborescence des Sites" à gauche.
2. Allez dans le menu `Vue`, puis `Afficher l'onglet`, puis `Onglet parcours Forcés`.
3. ZAP vous proposera une liste de dictionnaires. Choisissez-en un (ici: `directory-list-1.0.txt`) et lancez le scan.
4. Cliquez sur l'onglet **"Parcours forcés"** pour voir les résultats arriver en direct.
5. Au bout de 30000 requêtes environ (position du mot `protected` dans le fichier `directory-list-1.0.txt`), vous devriez voir la page `/protected/` apparaître avec un code **200 OK**.
6. Vous pouvez alors arrêter le scan.
<!-- end list -->
```mermaid
graph TD
subgraph "Processus du parcours forcé ZAP"
A["Lancer 'Parcours forcés' <br> avec un dictionnaire"] --> B{"ZAP teste des URL"};
B -- "/fake_page/" --> C["Serveur répond 404"];
B -- "/admin/" --> D["Serveur répond 302"];
B -- "..." --> B;
B -- "/protected/" --> E["Serveur répond 200 OK"];
E --> F[("PAGE CACHÉE TROUVÉE")];
end
```
**Résultat sur notre projet :**
En lançant l'outil, ZAP a testé des centaines de noms. Comme le mot `protected` est très courant dans ces listes, il a testé `http://127.0.0.1:8000/protected/` et a reçu un code **200 OK**. ZAP a donc prouvé l'existence d'une page que nous n'étions pas censés connaître.
![OWASP ZAP PF](./img/OWASP_ZAP_PF.png)
---
### Astuce : regardez votre console Django pendant le scan !
Pendant que vous lancez des outils comme "Parcours forcés" ou "Scan Actif" dans ZAP, gardez un œil sur le terminal où tourne votre serveur Django (`python manage.py runserver`).
Vous n'y verrez pas un flot lent de requêtes légitimes. À la place, vous verrez votre console défiler à toute vitesse, submergée par des centaines ou des milliers de requêtes en quelques secondes.
**Pourquoi c'est instructif ?**
* **Vous voyez l'attaque :** C'est la signature visuelle d'un scan automatisé. Vous verrez ZAP tester des URL qui n'existent pas (ce qui générera des `404 Not Found`) jusqu'à ce qu'il tombe sur une URL valide.
* **Comprendre les codes de statut :** Vous verrez le serveur répondre :
* `GET /admin/ 302 Found` (Redirection vers le login)
* `GET /fake_page/ 404 Not Found`
* `GET /backup/ 404 Not Found`
* `GET /protected/ 200 OK` (La requête gagnante !)
* **Détection :** C'est exactement ce que les administrateurs système et les outils de sécurité (comme un WAF ou un SIEM) recherchent. Un nombre massif de requêtes 404 provenant d'une seule adresse IP est un signe évident qu'un scan de découverte (reconnaissance) est en cours.

0
config/__init__.py Normal file
View File

16
config/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

91
config/settings.py Normal file
View File

@@ -0,0 +1,91 @@
"""
Paramètres Django pour le projet de démonstration.
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Clé secrète (NE PAS utiliser en production)
SECRET_KEY = 'django-insecure-@votre-cle-demo-ici'
# Mode Debug (NE PAS utiliser en production)
DEBUG = True
ALLOWED_HOSTS = []
# Définition des applications
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Notre application de démo
'profiles.apps.ProfilesConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], # Dossier central pour les templates
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Base de données (SQLite par simplicité)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Validation des mots de passe
AUTH_PASSWORD_VALIDATORS = [] # Simplifié pour la démo
# Internationalisation
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Fichiers statiques (CSS, JavaScript, Images)
STATIC_URL = 'static/'
# Type de champ auto pour la clé primaire
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Redirections après connexion/déconnexion
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'

22
config/urls.py Normal file
View File

@@ -0,0 +1,22 @@
"""
URLs principales du projet
"""
from django.contrib import admin
from django.urls import path, include
from django.views.generic import TemplateView
urlpatterns = [
path('admin/', admin.site.urls),
# Page d'accueil simple
path('', TemplateView.as_view(template_name='home.html'), name='home'),
# URLs d'authentification (login, logout, etc.)
path('accounts/', include('django.contrib.auth.urls')),
# URLs de notre application 'profiles'
path('profile/', include('profiles.urls', namespace='profiles')),
# Page protégée pour la démonstration OWASP ZAP
path('protected/', TemplateView.as_view(template_name='protected.html'), name='protected'),
]

16
config/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
img/OWASP_ZAP_PF.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

22
manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

71
poetry.lock generated Normal file
View File

@@ -0,0 +1,71 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "asgiref"
version = "3.10.0"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734"},
{file = "asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e"},
]
[package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "django"
version = "5.2.8"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f"},
{file = "django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f"},
]
[package.dependencies]
asgiref = ">=3.8.1"
sqlparse = ">=0.3.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "sqlparse"
version = "0.5.3"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"},
{file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"},
]
[package.extras]
dev = ["build", "hatch"]
doc = ["sphinx"]
[[package]]
name = "tzdata"
version = "2025.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
[metadata]
lock-version = "2.1"
python-versions = "^3.13"
content-hash = "da42791ad2f553edacb7bc8bf2c986644bf58702eba4adb5d9bb08dcb60aff76"

0
profiles/__init__.py Normal file
View File

10
profiles/admin.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import Profile
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
"""
Configuration de l'interface admin pour les profils.
"""
list_display = ('user', 'secret_note')
search_fields = ('user__username',)

10
profiles/apps.py Normal file
View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class ProfilesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'profiles'
def ready(self):
# Importer les signaux pour qu'ils soient enregistrés
import profiles.signals

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.8 on 2025-11-09 16:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret_note', models.CharField(blank=True, help_text='Un secret que seul cet utilisateur devrait voir.', max_length=255, verbose_name='Note secrète')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

22
profiles/models.py Normal file
View File

@@ -0,0 +1,22 @@
from django.db import models
from django.conf import settings
class Profile(models.Model):
"""
Modèle de profil simple lié à chaque utilisateur.
"""
# Relation 1-pour-1 avec le modèle User de Django
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
# Le champ "sensible" que nous voulons protéger
secret_note = models.CharField(
"Note secrète",
max_length=255,
blank=True,
help_text="Un secret que seul cet utilisateur devrait voir."
)
def __str__(self):
return f"Profil de {self.user.username}"

20
profiles/signals.py Normal file
View File

@@ -0,0 +1,20 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from .models import Profile
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_user_profile(sender, instance, created, **kwargs):
"""
Signal pour créer automatiquement un objet Profile
chaque fois qu'un nouvel utilisateur (User) est créé.
"""
if created:
Profile.objects.create(user=instance)
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def save_user_profile(sender, instance, **kwargs):
"""
Signal pour sauvegarder le profil lorsque l'utilisateur est sauvegardé.
"""
instance.profile.save()

20
profiles/urls.py Normal file
View File

@@ -0,0 +1,20 @@
from django.urls import path
from . import views
app_name = 'profiles'
urlpatterns = [
# L'URL VULNÉRABLE
path(
'vulnerable/<int:pk>/',
views.VulnerableProfileView.as_view(),
name='vulnerable_detail'
),
# L'URL SÉCURISÉE
path(
'secure/<int:pk>/',
views.SecureProfileView.as_view(),
name='secure_detail'
),
]

59
profiles/views.py Normal file
View File

@@ -0,0 +1,59 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.shortcuts import render
from django.views.generic import DetailView
from .models import Profile
#
# 1. LA VUE VULNÉRABLE (IDOR)
#
class VulnerableProfileView(LoginRequiredMixin, DetailView):
"""
Cette vue est VULNÉRABLE au Broken Access Control (IDOR).
- LoginRequiredMixin: Vérifie si l'utilisateur est connecté.
- DetailView: Récupère un objet par sa clé primaire (pk) passée dans l'URL.
PROBLÈME: Elle ne vérifie jamais si l'utilisateur connecté
est le *propriétaire* du profil qu'il demande.
"""
model = Profile
template_name = 'profiles/profile_detail.html'
context_object_name = 'profile' # Nom de l'objet dans le template
#
# 2. LA VUE SÉCURISÉE (CORRIGÉE)
#
class SecureProfileView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
"""
Cette vue est CORRIGÉE et protège contre l'IDOR.
- UserPassesTestMixin: Ajoute un test de permission personnalisé.
La vue n'est rendue que si la méthode `test_func` renvoie True.
Sinon, elle renvoie un "403 Forbidden".
"""
model = Profile
template_name = 'profiles/profile_detail.html'
context_object_name = 'profile'
def test_func(self):
"""
La fonction de test pour UserPassesTestMixin.
C'est le cœur de la correction.
"""
# 1. Récupère l'objet Profile que DetailView a trouvé (via self.get_object())
profile_to_view = self.get_object()
# 2. Vérifie si l'utilisateur de la requête (connecté)
# est le même que l'utilisateur lié à ce profil.
is_owner = (self.request.user == profile_to_view.user)
return is_owner
# Optionnel: Si vous préférez renvoyer un 404 (Not Found) au lieu d'un 403 (Forbidden)
# pour cacher l'existence de l'objet, vous pouvez surcharger handle_no_permission.
# def handle_no_permission(self):
# from django.http import Http404
# raise Http404("Profil non trouvé")

21
pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[project]
name = "django-broken-access-control"
version = "0.1.0"
description = ""
authors = [
{name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
requires-python = ">=3.13"
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = "^3.13"
django = "^5.2.8"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

43
templates/base.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Démo OWASP A01</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 20px auto; padding: 15px; line-height: 1.6; background-color: #f4f4f4; }
nav { background: #333; color: white; padding: 10px; border-radius: 8px; margin-bottom: 20px; }
nav a { color: white; text-decoration: none; padding: 5px 10px; }
nav ul { list-style-type: none; padding: 0; margin: 0; display: flex; }
nav ul li { margin-right: 15px; }
.user-info { margin-left: auto; }
.container { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
h1, h2 { color: #333; }
a { color: #007bff; }
.alert-fail { border: 1px solid #d9534f; background: #f2dede; color: #a94442; padding: 10px; border-radius: 5px; }
.alert-ok { border: 1px solid #5cb85c; background: #dff0d8; color: #3c763d; padding: 10px; border-radius: 5px; }
</style>
</head>
<body>
<nav>
<ul>
<li><a href="/">Accueil</a></li>
<li><a href="/admin/">Admin</a></li>
<li class="user-info">
{% if user.is_authenticated %}
Bonjour, {{ user.username }}!
<a href="{% url 'logout' %}?next=/">Se déconnecter</a>
{% else %}
<a href="{% url 'login' %}?next=/">Se connecter</a>
{% endif %}
</li>
</ul>
</nav>
<div class="container">
{% block content %}
{% endblock %}
</div>
</body>
</html>

42
templates/home.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block content %}
<h1>Démonstration OWASP A01: Broken Access Control (IDOR)</h1>
{% if user.is_authenticated %}
<p>Bienvenue, {{ user.username }}.</p>
{% if user.profile %}
<h2>Testez l'accès à votre profil</h2>
<p>Les deux liens ci-dessous pointent vers votre propre profil (ID: {{ user.profile.pk }}). Essayez ensuite de changer l'ID dans l'URL pour voir le profil de l'autre utilisateur.</p>
<div class="alert-fail">
<h3>Lien VULNÉRABLE</h3>
<p>Cette vue ne vérifie pas qui vous êtes. Elle vous laissera voir le profil d'un autre utilisateur si vous changez l'ID dans l'URL.</p>
<a href="{% url 'profiles:vulnerable_detail' pk=user.profile.pk %}">
Voir mon profil (VULNÉRABLE)
</a>
<br>
<small><code>/profile/vulnerable/{{ user.profile.pk }}/</code></small>
</div>
<br>
<div class="alert-ok">
<h3>Lien SÉCURISÉ</h3>
<p>Cette vue vérifie que vous êtes bien le propriétaire du profil. Elle bloquera (403 Forbidden) toute tentative de voir le profil d'un autre.</p>
<a href="{% url 'profiles:secure_detail' pk=user.profile.pk %}">
Voir mon profil (SÉCURISÉ)
</a>
<br>
<small><code>/profile/secure/{{ user.profile.pk }}/</code></small>
</div>
{% else %}
<p>Votre profil n'a pas encore été créé. (Cela ne devrait pas arriver, vérifiez les signaux)</p>
{% endif %}
{% else %}
<p>Veuillez vous connecter pour voir la démonstration.</p>
<a href="{% url 'login' %}?next=/">Se connecter</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<h1>Profil de {{ profile.user.username }}</h1>
<p><strong>Utilisateur :</strong> {{ profile.user.username }}</p>
<p><strong>ID Profil :</strong> {{ profile.pk }}</p>
<hr>
<h2>Note Secrète</h2>
<p style="font-size: 1.2em; font-weight: bold; color: #d9534f;">
{{ profile.secret_note|default:"[Aucune note secrète définie]" }}
</p>
{% endblock %}

27
templates/protected.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block content %}
<h1 style="color: #d9534f;">Zone (censée être) Protégée</h1>
<div class="alert-fail">
<h2>Accès NON-AUTORISÉ !</h2>
<p>
Vous n'auriez jamais dû trouver cette page !
</p>
<p>
Ceci est un (faux) panneau d'administration secret avec des informations sensibles.
Le fait que vous puissiez voir cette page sans être connecté
est une faille de sécurité (Security Misconfiguration / Broken Access Control).
</p>
<p>L'outil "Parcours forcés" de ZAP a probablement trouvé cette page parce que
l'URL <code>/protected/</code> était dans sa liste de mots.</p>
</div>
<h3>Informations "Sensibles"</h3>
<ul>
<li>Clé API du système : <code>fake_api_key_123456789ABCDEF</code></li>
<li>Endpoint de backup : <code>/system/run_backup_now</code></li>
<li>Mot de passe BDD (dev) : <code>admin/admin</code></li>
</ul>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block content %}
<h2>Se connecter</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Se connecter</button>
</form>
{% endblock %}