First commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/.idea
|
||||||
234
README.md
Normal file
234
README.md
Normal 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).
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
0
config/__init__.py
Normal file
16
config/asgi.py
Normal file
16
config/asgi.py
Normal 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
91
config/settings.py
Normal 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
22
config/urls.py
Normal 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
16
config/wsgi.py
Normal 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()
|
||||||
BIN
img/OWASP_ZAP_AUTOMATED_SCAN_ATTACK_MODE.png
Normal file
BIN
img/OWASP_ZAP_AUTOMATED_SCAN_ATTACK_MODE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
img/OWASP_ZAP_PF.png
Normal file
BIN
img/OWASP_ZAP_PF.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
22
manage.py
Normal file
22
manage.py
Normal 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
71
poetry.lock
generated
Normal 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
0
profiles/__init__.py
Normal file
10
profiles/admin.py
Normal file
10
profiles/admin.py
Normal 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
10
profiles/apps.py
Normal 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
|
||||||
24
profiles/migrations/0001_initial.py
Normal file
24
profiles/migrations/0001_initial.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
profiles/migrations/__init__.py
Normal file
0
profiles/migrations/__init__.py
Normal file
22
profiles/models.py
Normal file
22
profiles/models.py
Normal 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
20
profiles/signals.py
Normal 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
20
profiles/urls.py
Normal 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
59
profiles/views.py
Normal 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
21
pyproject.toml
Normal 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
43
templates/base.html
Normal 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
42
templates/home.html
Normal 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 %}
|
||||||
15
templates/profiles/profile_detail.html
Normal file
15
templates/profiles/profile_detail.html
Normal 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
27
templates/protected.html
Normal 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 %}
|
||||||
10
templates/registration/login.html
Normal file
10
templates/registration/login.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user