Основи Безпеки Вебзастосунків — Security Foundations¶
Для кого: Студенти без попередніх знань з кібербезпеки.
Мета: Побудувати фундамент — розуміти ЯК думає атакер, щоб будувати захист правильно.
Підхід: Кожна тема починається з аналогії з реального світу, потім — теорія і код.
1. Три кити безпеки: CIA Triad¶
Аналогія — банківський сейф¶
Уяви банківський сейф з документами: - Конфіденційність (C): документи бачить лише власник. Сторонній — ні. - Цілісність (I): ніхто не підмінив документи поки сейф був зачинений. - Доступність (A): сейф відкривається коли власник приходить. Не "закрито на ремонт".
Порушити хоча б один з трьох — вже атака.
CIA Triad у вебзастосунку¶
flowchart TB
CIA["🛡️ CIA Triad"]
CIA --> C["Конфіденційність"]
CIA --> I["Цілісність"]
CIA --> A["Доступність"]
C --> C1["Авторизація"]
C --> C2["Перевірка прав доступу"]
I --> I1["CSRF захист"]
I --> I2["Хешування паролів"]
A --> A1["Rate limiting"]
A --> A2["Захист від brute force"]
Django Security¶
flowchart LR
User["👤 Користувач"]
Auth["Authentication
Хто ти?"]
Perm["Authorization
Що тобі дозволено?"]
Resource["Дані системи"]
User --> Auth
Auth --> Perm
Perm --> Resource
ARCHITECT INSIGHT¶
Confidentiality:
хто бачить дані
Integrity:
хто може змінювати дані
Availability:
чи доступна система легітимним користувачам
Приклади порушень і наслідки¶
| Порушення | Приклад атаки | Реальний наслідок |
|---|---|---|
| Конфіденційність | Аліса читає нотатки Боба через IDOR | Витік особистих даних → штрафи GDPR |
| Цілісність | Атакер змінює ціну з $99 на $1 | Фінансові втрати компанії |
| Доступність | DDoS-атака 10 Gbps трафіку | Сайт недоступний → втрата клієнтів |
Важливо для початківця: Більшість студентів думають про безпеку лише як про "щоб не вкрали пароль" (Confidentiality). Але підміна даних (Integrity) і недоступність (Availability) — теж атаки!
2. Межа довіри (Trust Boundary)¶
Аналогія — митний контроль в аеропорту¶
Коли ти летиш з іншої країни, митниця перевіряє КОЖЕН твій чемодан. Вона не каже "цей пасажир виглядає добре, пропустимо без огляду". Кожен проходить однакову перевірку.
Trust Boundary в Django — це те, де Django перевіряє кожен вхідний запит, незалежно від того хто його надіслав.
Шлях запиту: від браузера до БД¶
%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 80}}}%%
flowchart TD
subgraph RED["🔴 Ненадійна зона"]
Browser["🌐 Browser"]
Attacker["👾 Attacker"]
end
subgraph YELLOW["🟡 Security Layer"]
SpacerY[" "]
Nginx["Nginx"]
Middleware["Middleware"]
Auth["Authentication"]
Validation["Validation"]
Permissions["Permissions"]
end
subgraph GREEN["🟢 Trusted Zone"]
SpacerX[" "]
View["Django View"]
ORM["Django ORM"]
DB["PostgreSQL"]
end
Browser --> SpacerY
Attacker --> SpacerY
SpacerY --> Nginx
Nginx --> Middleware
Middleware --> Auth
Auth --> Validation
Validation --> Permissions
Permissions --> SpacerX
SpacerX --> View
View --> ORM
ORM --> DB
style RED fill:#4a2b2b,stroke:#ff5c5c,color:#ffffff
style YELLOW fill:#4a432b,stroke:#f0c542,color:#ffffff
style GREEN fill:#2b4a35,stroke:#4caf50,color:#ffffff
style SpacerY fill:transparent,stroke:transparent,color:transparent
style SpacerX fill:transparent,stroke:transparent,color:transparent
Червона зона (ненадійна): Будь-який запит з Інтернету. Будь-хто може підробити:
- HTTP заголовки (X-Forwarded-For, Content-Type)
- Тіло POST-запиту (priority=999, is_admin=true)
- Cookie (sessionid=чужий_токен)
- URL параметри (?pk=42 — чужа нотатка)
Зелена зона (довірена): Всередині Django, після перевірок.
Що Django перевіряє при перетині межі¶
# Кожен вхідний запит проходить через цей ланцюг:
# 1. SecurityMiddleware — базові HTTP перевірки
# 2. SessionMiddleware — хто надсилає запит?
# 3. AuthenticationMiddleware — request.user встановлюється тут
# 4. CsrfViewMiddleware — чи є валідний CSRF токен?
# 5. Твій @login_required декоратор — залогінений?
# 6. forms.is_valid() — чи коректні дані форми?
# 7. get_object_or_404(..., user=request.user) — чи є право на цей об'єкт?
# Тільки після ВСІХ цих перевірок → довірена логіка
Правило: Все що прийшло з Інтернету — вважай підробленим до перевірки.
request.POST,request.GET,request.FILES,request.COOKIES— всі ненадійні.
3. Поверхня атаки (Attack Surface)¶
Аналогія — кількість дверей у будинку¶
Замок з 10 дверима складніше охороняти ніж замок з 2 дверима. Кожна додаткова двері — ще одна точка, яку треба захищати і яку може знайти атакер.
Attack Surface — загальна кількість "дверей" у твоєму застосунку.
Всі точки входу у типовий Django-застосунок¶
%%{init: {'flowchart': {'nodeSpacing': 50, 'rankSpacing': 70}}}%%
flowchart LR
A["👾 Атакер"]
subgraph SURFACE["🔴 Поверхня атаки вебзастосунку"]
U["URL endpoints<br/>/notes/, /admin/, /api/"]
F["HTML форми<br/><input>, <textarea>"]
C["Cookies<br/>sessionid, csrftoken"]
H["HTTP Headers<br/>Content-Type, User-Agent"]
FI["File uploads<br/>.jpg, .pdf, .exe"]
Q["Query params<br/>?search=..., ?pk=..."]
D["Dependencies<br/>Django, Pillow, requests"]
end
A --> U
A --> F
A --> C
A --> H
A --> FI
A --> Q
A --> D
classDef attacker fill:#ffcdd2,stroke:#c62828,color:#000000;
classDef surface fill:#263238,stroke:#90a4ae,color:#ffffff;
class A attacker;
class U,F,C,H,FI,Q,D surface;
style SURFACE fill:#3b2f2f,stroke:#ef5350,color:#ffffff
Як зменшити Attack Surface в Django¶
# settings.py — закрий все що не потрібно:
DEBUG = False # Прибирає debug-endpoints та детальні помилки
ALLOWED_HOSTS = ['mysite.com'] # Тільки твій домен, не '*' (відкриває всі)
# urls.py — можна прибрати /admin/ якщо не потрібен:
# path('admin/', admin.site.urls), # ← закоментуй або зміни URL
# INSTALLED_APPS — прибери що не використовуєш:
INSTALLED_APPS = [
# 'django.contrib.staticfiles', # якщо статику сервить Nginx
# 'debug_toolbar', # НІКОЛИ в production!
]
Чек-ліст зменшення Attack Surface:
- ✓ DEBUG = False — прибирає /500-debug-page/ та stack traces
- ✓ Специфічний ALLOWED_HOSTS — не ['*']
- ✓ Прибрати django-debug-toolbar з INSTALLED_APPS в production
- ✓ Закрити тестові endpoints (/dev/, /seed-data/)
- ✓ Rate limiting на /accounts/login/ і /accounts/password_reset/
4. Принцип Defense-in-Depth (Ешелонована Оборона)¶
Аналогія — середньовічний замок¶
🏰 Середньовічний замок ←→ Вебзастосунок
🌊 Рів з водою ←→ Firewall / DDoS захист (Cloudflare)
🧱 Зовнішні стіни ←→ Nginx: rate limiting, HTTPS
⚔️ Охоронці при воротах ←→ Django Middleware (CSRF, Sessions)
🚪 Внутрішні двері із замками ←→ @login_required + object-level checks
🔐 Сховище зі скарбами ←→ PostgreSQL + ORM (параметризовані запити)
📹 Відеоспостереження ←→ Logging + SIEM моніторинг
Головна ідея: Якщо атакер перейде рів (обійде Firewall) — його зупинять стіни (Nginx). Якщо обійде стіни — зупинять охоронці (Middleware). І так далі.
Схема 6 рівнів захисту¶
%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 90}}}%%
flowchart TD
Attack["🦹 Атакер"]
subgraph DEFENSE["🛡️ Defense-in-Depth: 6 рівнів захисту"]
L1["🌐 Рівень 1: Мережа<br/>Firewall<br/>DDoS protection<br/>HTTPS<br/>Cloudflare / AWS Shield"]
L2["🧱 Рівень 2: Web Server<br/>Nginx<br/>Rate limiting<br/>HTTP Security Headers"]
L3["⚙️ Рівень 3: Django Middleware<br/>SecurityMiddleware<br/>CSRF Middleware<br/>SessionMiddleware"]
L4["🔐 Рівень 4: Application Layer<br/>@login_required<br/>Permissions<br/>get_object_or_404(..., user=request.user)"]
L5["🗄️ Рівень 5: База даних<br/>Django ORM<br/>Параметризовані запити<br/>DB-user без DROP прав"]
L6["📡 Рівень 6: Моніторинг<br/>Logging<br/>Sentry<br/>Grafana<br/>SIEM / Alerts"]
end
Data["✅ Дані захищені"]
Attack --> L1
L1 --> L2
L2 --> L3
L3 --> L4
L4 --> L5
L5 --> L6
L6 --> Data
style Attack fill:#ffcdd2,stroke:#c62828,color:#000000
style DEFENSE fill:#263238,stroke:#90a4ae,color:#ffffff
style L1 fill:#fce4ec,stroke:#c62828,color:#000000
style L2 fill:#fff3e0,stroke:#e65100,color:#000000
style L3 fill:#fff9c4,stroke:#f9a825,color:#000000
style L4 fill:#e8f5e9,stroke:#2e7d32,color:#000000
style L5 fill:#e3f2fd,stroke:#1565c0,color:#000000
style L6 fill:#f3e5f5,stroke:#6a1b9a,color:#000000
style Data fill:#c8e6c9,stroke:#2e7d32,color:#000000
Що відбувається якщо один рівень пробито¶
%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 80}}}%%
flowchart TD
Attack["🦹 Атакер"]
Bypass["⚠️ Рівень 2 пробито<br/>Nginx rate limiting обійдено<br/>через VPN-ротацію"]
subgraph BAD["❌ Без Defense-in-Depth"]
Bad1["Nginx пропустив"]
Bad2["Brute-force триває"]
Bad3["💀 GAME OVER<br/>пароль підібрано"]
end
subgraph GOOD["✅ З Defense-in-Depth"]
Good1["Nginx пропустив"]
Good2["Django Middleware перевіряє запит"]
Good3["CSRF / Session / Auth перевірки"]
Good4["🛡️ Запит заблоковано"]
end
Attack --> Bypass
Bypass --> Bad1
Bad1 --> Bad2
Bad2 --> Bad3
Bypass --> Good1
Good1 --> Good2
Good2 --> Good3
Good3 --> Good4
style Attack fill:#ffcdd2,stroke:#c62828,color:#000000
style Bypass fill:#ffecb3,stroke:#ff6f00,color:#000000
style BAD fill:#3b1f1f,stroke:#c62828,color:#ffffff
style GOOD fill:#1b3b2a,stroke:#2e7d32,color:#ffffff
style Bad1 fill:#ffcdd2,stroke:#c62828,color:#000000
style Bad2 fill:#ef9a9a,stroke:#c62828,color:#000000
style Bad3 fill:#b71c1c,stroke:#ffffff,color:#ffffff
style Good1 fill:#fff9c4,stroke:#f9a825,color:#000000
style Good2 fill:#e8f5e9,stroke:#2e7d32,color:#000000
style Good3 fill:#c8e6c9,stroke:#2e7d32,color:#000000
style Good4 fill:#a5d6a7,stroke:#1b5e20,color:#000000
Сценарій: Атакер обійшов Nginx rate limiting через VPN-ротацію.
Без Defense-in-Depth:
Nginx → пропущено → GAME OVER (брут-форс паролів успішний)
З Defense-in-Depth:
Nginx → пропущено
→ Django Middleware: CSRF перевірка не пройшла → заблоковано ✓
Або інший сценарій: атакер вкрав sessionid cookie.
Без Defense-in-Depth:
Він входить і бачить ВСІ дані → GAME OVER
З Defense-in-Depth:
Він входить → бачить ТІЛЬКИ дані власника сесії
→ get_object_or_404(Note, pk=pk, user=request.user) → чужі нотатки недоступні ✓
5. Аутентифікація vs Авторизація¶
Аналогія — паспорт і квиток на концерт¶
- Паспорт = Аутентифікація (AuthN): "Хто ти?" — підтверджує особу
- Квиток = Авторизація (AuthZ): "Куди тобі можна?" — визначає права
У аеропорту: охорона перевіряє паспорт (AuthN), потім посадковий талон (AuthZ). Паспорт без квитка — не пустять. Квиток без паспорта — теж.
Повний flow аутентифікації в Django¶
sequenceDiagram
participant Б as Браузер
participant V as Django View
participant DB as База даних
Б->>V: POST /accounts/login/ {username, password}
V->>DB: authenticate(username, password)
Note over DB: Знаходить юзера<br/>Порівнює PBKDF2 хеші
DB-->>V: User object (або None)
alt Хеш збігається
V->>DB: login() — створює session запис
DB-->>V: session_key = "xK9mPq..."
V-->>Б: 302 Redirect + Set-Cookie: sessionid=xK9mPq...
else Не збігається
V-->>Б: 200 + форма з помилкою "Невірний логін"
end
Дерево рішень авторизації¶
%%{init: {'flowchart': {'nodeSpacing': 70, 'rankSpacing': 90}}}%%
flowchart TD
R["🌐 Запит<br/>/notes/42/edit/"]
Q1{"request.user<br/>аутентифікований?"}
R1["🔁 302 Redirect<br/>/accounts/login/"]
Q2{"note.user<br/>== request.user?"}
A1["✅ Доступ надано<br/>показати форму редагування"]
Q3{"user є членом<br/>note.group?"}
A2["👁️ Груповий доступ<br/>дозволити перегляд<br/>редагування заборонено"]
A3["⛔ 403 Forbidden<br/>PermissionDenied"]
R --> Q1
Q1 -->|НІ| R1
Q1 -->|ТАК| Q2
Q2 -->|ТАК| A1
Q2 -->|НІ| Q3
Q3 -->|ТАК| A2
Q3 -->|НІ| A3
style R fill:#e3f2fd,stroke:#1565c0,color:#000000
style Q1 fill:#fff9c4,stroke:#f9a825,color:#000000
style Q2 fill:#fff9c4,stroke:#f9a825,color:#000000
style Q3 fill:#fff9c4,stroke:#f9a825,color:#000000
style R1 fill:#fff3e0,stroke:#e65100,color:#000000
style A1 fill:#c8e6c9,stroke:#2e7d32,color:#000000
style A2 fill:#e3f2fd,stroke:#1565c0,color:#000000
style A3 fill:#ffcdd2,stroke:#c62828,color:#000000
Django реалізація¶
# Аутентифікація (хто ти?):
from django.contrib.auth import authenticate, login
user = authenticate(request, username='alice', password='secret')
if user is not None:
login(request, user) # ← створює сесію в БД
# тепер request.user = alice у всіх наступних запитах
# Авторизація (що тобі можна?):
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
@login_required # ← AuthN: залогінений?
def note_edit(request, pk):
note = get_object_or_404(Note, pk=pk)
if note.user != request.user: # ← AuthZ: власник?
raise PermissionDenied # 403 Forbidden
# або коротше:
note = get_object_or_404(Note, pk=pk, user=request.user) # AuthN + AuthZ разом
Часта помилка:
@login_required— це ТІЛЬКИ AuthN. Він не перевіряє чи цей конкретний об'єкт belongs to юзеру. Потрібні обидві перевірки!
6. Хешування паролів — чому MD5 небезпечний¶
Аналогія — м'ясорубка (одностороння функція)¶
М'ясо → м'ясорубка → фарш. Повернути фарш назад у цілий шматок м'яса — неможливо. Хеш-функція працює так само: пароль → хеш-функція → хеш. Відновити пароль з хешу — математично нездійсненно (для гарного алгоритму).
Чому MD5 — неправильний вибір¶
MD5 розроблявся у 1991 році як контрольна сума (не для паролів!). Він надзвичайно швидкий.
Порівняння швидкості (на сучасній GPU RTX 4090):
MD5: 60,000,000,000 хешів/секунду (60 мільярдів!)
bcrypt: 20,000 хешів/секунду
PBKDF2: 10,000 хешів/секунду
Brute-force паролю "Password1" (8 символів, A-z + 0-9):
MD5: < 1 секунди ← НЕБЕЗПЕЧНО
bcrypt: ~3 доби ← прийнятно
PBKDF2: ~7 діб ← ДОБРЕ
Ключовий принцип: Для паролів "повільно = безпечно". Алгоритм навмисно повільний, щоб brute-force атака коштувала роки часу навіть на суперкомп'ютері.
Таблиця порівняння алгоритмів¶
| Алгоритм | Швидкість | Salt | Безпечний? | Де використовують |
|---|---|---|---|---|
| MD5 | ~60 Ghash/s | ✗ | ❌ НІ | Контрольні суми файлів |
| SHA-256 | ~20 Ghash/s | ✗ | ❌ НІ для паролів | Підписи (не паролі!) |
| bcrypt | ~20k/s | ✓ | ✅ ТАК | PHP, Ruby проєкти |
| PBKDF2 | ~10k/s | ✓ | ✅ ТАК | Django за замовчуванням |
| Argon2 | ~1k/s | ✓ | ✅ ТАК | Найсучасніший (Django 3+) |
Що зберігається в БД Django¶
Поле User.password у базі даних:
pbkdf2_sha256$720000$randomsalt$VQaFwT...longhash...==
│ │ │ │
│ │ │ └─ власне хеш (base64)
│ │ └─ сіль (random, 22 символи)
│ └─ кількість ітерацій (720000 у Django 5.x)
└─ назва алгоритму
# Django автоматично хешує при set_password():
user = User.objects.create_user(username='alice', password='secret123')
print(user.password)
# → 'pbkdf2_sha256$720000$xK9mPq3rT8aL2v...=$VQaFwT+abc...=='
# Django автоматично перевіряє при authenticate():
user = authenticate(request, username='alice', password='secret123')
# → Django: PBKDF2('secret123', salt='xK9mPq3rT8aL2v...', iterations=720000)
# → Порівнює з хешем у БД
# → Повертає User якщо збігається, None якщо ні
Чому salt важливий¶
БЕЗ SALT — Rainbow Table атака:
Атакер завчасно обчислює таблицю:
"password" → "5f4dcc3b5aa765d61d8327deb882cf99"
"123456" → "e10adc3949ba59abbe56e057f20f883e"
"qwerty" → "d8578edf8458ce06fbc5bb76a58c5ca4"
...мільярди записів...
Отримав хеш з БД: "5f4dcc3b5aa765d61d8327deb882cf99"
→ Знаходить у таблиці за 0.001 секунди → пароль "password" ✓
З SALT — Rainbow Table марна:
"password" + "xK9mPq" (у Аліси) → "АБСОЛЮТНО УНІКАЛЬНИЙ хеш"
"password" + "mR7pLw" (у Боба) → "ІНШИЙ УНІКАЛЬНИЙ хеш"
Навіть якщо Аліса і Боб мають ОДНАКОВИЙ пароль — хеші різні.
Попередньо обчислена таблиця марна: доведеться brute-force кожен акаунт окремо.
7. CSRF — Cross-Site Request Forgery¶
Аналогія — підроблений лист від твого імені¶
Уяви: банк приймає перекази за листами з твоїм підписом. Атакер підробляє твій підпис і надсилає лист "перекажіть $1000 на мій рахунок". Банк не знає що лист підроблений.
CSRF — це те саме, але в браузері: атакер змушує твій браузер надіслати запит від твого імені (з твоїми cookies), без твого відома.
CSRF атака: покроково¶
sequenceDiagram
participant Б as Браузер Аліси
participant E as evil.com
participant D as bank.com
Note over Б,D: Аліса залогінена на bank.com (sessionid cookie є)
Б->>E: Аліса відкриває evil.com
E-->>Б: HTML з прихованою формою:
Note over E,Б: <form action="bank.com/transfer"><br/> <input value="hacker_account"><br/> <input value="1000"><br/></form><br/><script>form.submit()</script>
Б->>D: POST /transfer amount=1000 to=hacker
Note over Б,D: Cookie sessionid автоматично додається браузером!
D-->>Б: Переказ виконано (bank.com думав що це Аліса)
CSRF захист: з токеном¶
sequenceDiagram
participant Б as Браузер Аліси
participant E as evil.com
participant D as bank.com (з CSRF захистом)
Б->>D: GET /transfer (перша відкриття сторінки)
D-->>Б: HTML з CSRF токеном: csrfmiddlewaretoken=abc123xyz
Note over Б: Токен прив'язаний до сесії Аліси
Б->>E: Аліса відкриває evil.com
E-->>Б: Форма без CSRF токена (evil.com не знає abc123xyz!)
Б->>D: POST без csrfmiddlewaretoken
D-->>Б: 403 Forbidden — CSRF перевірка не пройшла ✓
Django реалізація¶
<!-- Кожна POST-форма ОБОВ'ЯЗКОВО має {% csrf_token %}: -->
<form method="POST" action="{% url 'note_create' %}">
{% csrf_token %}
<!-- Django генерує: -->
<!-- <input type="hidden" name="csrfmiddlewaretoken" value="abc123...xyz"> -->
{{ form.as_p }}
<button type="submit">Зберегти</button>
</form>
# settings.py — налаштування CSRF та Cookies:
SESSION_COOKIE_SAMESITE = "Lax"
# ↑ "Lax": cookie надсилається при навігації всередині сайту,
# але НЕ надсилається при крос-сайтових POST запитах.
# Тобто evil.com → bank.com не отримає sessionid автоматично.
# "Strict": cookie взагалі не надсилається при будь-якому крос-сайтовому запиті.
# Жорсткіше, але ламає деякі OAuth flows.
# "None": завжди надсилається (НЕБЕЗПЕЧНО, потрібен Secure flag).
SameSite: порівняння режимів¶
| SameSite | Навігація (a → b) | Cross-site POST | OAuth редиректи | Рекомендація |
|---|---|---|---|---|
Strict |
✗ не надсилається | ✗ | ✗ | Максимальна безпека |
Lax |
✓ надсилається | ✗ | ✓ | Рекомендовано для Django |
None |
✓ | ✓ | ✓ | Тільки з Secure прапором |
8. Безпека файлових завантажень¶
Аналогія — митниця і рентген¶
Прикордонник може перевірити паспорт (розширення файлу .jpg) — але назву можна підробити.
Рентген перевіряє ВМІСТ валізи (magic bytes файлу) — це підробити набагато складніше.
Правило: Ніколи не довіряй розширенню файлу. Завжди перевіряй справжній тип через вміст.
Небезпечні розширення та що з ними роблять атакери¶
| Розширення | Небезпека | Атака |
|---|---|---|
.php, .py, .sh |
Виконання коду на сервері (RCE) | Завантажити webshell, перейменувавши в .jpg |
.html, .svg |
XSS | <script> у SVG або HTML файлі |
.exe, .bat |
Зараження клієнта | Соціальна інженерія через download |
.zip (Zip Bomb) |
DoS | 1 КБ архів → 1 ТБ після розпакування |
Покроковий захист¶
# 1. Перевірка розширення (перший бар'єр):
from django.core.validators import FileExtensionValidator
class AvatarUploadForm(forms.Form):
avatar = forms.FileField(
validators=[FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'webp'])]
)
# ↑ Відхиляє .php, .exe — але атакер може перейменувати. Потрібен крок 2!
# 2. Перевірка magic bytes (справжній тип):
import magic # pip install python-magic-bin (Windows) або python-magic (Linux)
def validate_image_content(file):
file_content = file.read(2048)
file.seek(0) # повернути курсор — Django читатиме файл далі
mime_type = magic.from_buffer(file_content, mime=True)
allowed_types = {'image/jpeg', 'image/png', 'image/webp'}
if mime_type not in allowed_types:
raise forms.ValidationError(
f"Недійсний тип файлу: {mime_type}. Дозволено: JPEG, PNG, WebP."
)
# 3. Безпечне ім'я файлу (UUID замість оригінального імені):
import uuid
from pathlib import Path
def upload_to(instance, filename):
ext = Path(filename).suffix.lower() # .jpg
new_name = f"{uuid.uuid4()}{ext}" # 550e8400-e29b-41d4-a716-446655440000.jpg
return f"avatars/{new_name}"
# Атакер не може передбачити URL завантаженого файлу
# 4. Зберігання ПОЗА web root:
MEDIA_ROOT = '/var/uploads/private/' # НЕ /var/www/uploads/ !
# Django view для роздачі файлів:
@login_required
def serve_avatar(request, filename):
note = get_object_or_404(Note, avatar_filename=filename, user=request.user)
file_path = Path(settings.MEDIA_ROOT) / filename
return FileResponse(open(file_path, 'rb'), content_type='image/jpeg')
# ↑ Файл доступний тільки через Django view, а не напряму через Nginx
9. Принцип найменших привілеїв (Least Privilege)¶
Аналогія — система ключів в офісі¶
Охоронець має ключ від головного входу (може відчинити офіс). Але у нього НЕМАЄ ключа від сейфу директора, від серверної кімнати, від бухгалтерії. Він має рівно стільки доступу, скільки потрібно для його роботи — і не більше.
Least Privilege = кожен компонент системи має мінімально необхідні права.
Django компоненти та їх мінімальні права¶
| Компонент | Мінімальні права | ЩО НЕ треба давати |
|---|---|---|
| Django DB user | SELECT, INSERT, UPDATE, DELETE | CREATE DB, DROP TABLE, GRANT |
| Celery worker | підключення до Redis/RabbitMQ | адмін права БД |
| Email service | SMTP відправка | IMAP/POP3 читання |
| S3 bucket policy | s3:PutObject, s3:GetObject |
s3:DeleteBucket, s3:* |
| Nginx процес | читання /var/www/ |
запис у /etc/ |
Налаштування окремого DB-юзера (PostgreSQL)¶
-- Підключитись як postgres superuser:
-- psql -U postgres
-- 1. Створити окремого юзера для Django:
CREATE USER django_user WITH PASSWORD 'strongpassword';
-- 2. Дати права ТІЛЬКИ на конкретну БД:
GRANT CONNECT ON DATABASE mydb TO django_user;
-- 3. Дати права на схему:
GRANT USAGE ON SCHEMA public TO django_user;
-- 4. Дати права на операції (без DROP, CREATE, GRANT!):
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO django_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO django_user;
-- Якщо атакер зламає Django → він може лише читати/писати дані
-- але НЕ може: DROP TABLE users; або CREATE USER hacker SUPERUSER;
# settings.py:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'USER': 'django_user', # ← окремий юзер, не 'postgres'!
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': 'localhost',
'PORT': '5432',
}
}
10. Секрети та Environment Variables¶
Аналогія — пароль на стікері vs пароль у голові¶
Пароль написаний на стікері, приклеєному до монітора — бачить кожен хто проходить мимо. Те саме відбувається з секретами у коді: git commit → GitHub → PUBLIC репозиторій → всі бачать.
Проблема: git зберігає ВСЮ ІСТОРІЮ¶
# Навіть якщо ти видалив SECRET_KEY з settings.py і зробив commit —
# він ЗАЛИШАЄТЬСЯ в git history і його можна знайти:
git log --all --full-history -p -- "*.py" | grep "SECRET_KEY"
# → Покаже всі коміти де SECRET_KEY змінювався
# Атакери сканують GitHub в пошуку таких витоків:
# GitGuardian, TruffleHog — автоматичні сканери secrets у git
Правильне рішення: .env файл + python-decouple¶
# Крок 1: .env файл (НІКОЛИ не комітити в git!)
# Файл: crispy_notes_chat_app/.env
SECRET_KEY=django-very-random-key-50-chars-here
DATABASE_URL=postgresql://django_user:strongpassword@localhost:5432/mydb
EMAIL_HOST_PASSWORD=smtp_password_here
DEBUG=False
# Крок 2: pip install python-decouple
# settings.py:
from decouple import config, Csv
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost', cast=Csv())
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='mydb'),
'USER': config('DB_USER', default='django_user'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
}
}
# Крок 3: .gitignore (ОБОВ'ЯЗКОВО):
echo ".env" >> .gitignore
echo "*.env" >> .gitignore
# Перевірити що .env не потрапляє в git:
git status # .env не має з'являтися у списку
git check-ignore -v .env # → .gitignore:1:.env .env (ігнорується)
# Крок 4: .env.example — шаблон для нових розробників (БЕЗ реальних значень):
# .env.example (цей файл МОЖНА комітити):
SECRET_KEY=your-secret-key-here
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
EMAIL_HOST_PASSWORD=your-smtp-password
DEBUG=True
11. Безпечні HTTP Заголовки¶
Як це працює: браузер читає заголовки і застосовує правила¶
sequenceDiagram
participant Б as Браузер
participant D as Django (SecurityMiddleware)
Б->>D: GET /notes/
D-->>Б: HTTP/1.1 200 OK
Note over D,Б: X-Frame-Options: DENY<br/>X-Content-Type-Options: nosniff<br/>Strict-Transport-Security: max-age=31536000<br/>Referrer-Policy: same-origin
Note over Б: Браузер застосовує правила:<br/>— Не завантажуватиме цю сторінку у iframe<br/>— Не вгадуватиме MIME-тип файлів<br/>— Завжди використовуватиме HTTPS<br/>— Не надсилатиме Referer на зовнішні сайти
Детальний розбір кожного заголовку¶
X-Frame-Options: DENY — захист від Clickjacking
Атака Clickjacking:
1. Атакер розміщує твій сайт у прихованому <iframe> на evil.com
2. Поверх iframe — підроблені кнопки ("Натисни тут щоб отримати приз!")
3. Юзер "натискає на приз" — насправді натискає на кнопку "Видалити акаунт" твого сайту
Захист X-Frame-Options: DENY → браузер відмовляється завантажувати сторінку в <iframe>
# settings.py:
X_FRAME_OPTIONS = 'DENY' # Забороняє будь-який iframe
# або:
X_FRAME_OPTIONS = 'SAMEORIGIN' # Дозволяє iframe тільки з того ж домену
X-Content-Type-Options: nosniff — захист від MIME sniffing
Атака:
1. Атакер завантажує файл "photo.jpg" який насправді є JavaScript
2. Браузер "нюхає" вміст і каже: "Схоже на JS, запущу як скрипт!"
3. XSS атака через завантажений файл
Захист nosniff → браузер завжди використовує Content-Type з заголовку, ніколи не "вгадує"
Strict-Transport-Security: max-age=31536000 — примусовий HTTPS
Атака SSL Stripping:
1. Юзер вводить "mybank.com" (без https://)
2. Атакер "в середині" перехоплює і встановлює HTTP з'єднання з юзером
3. Паролі передаються відкритим текстом
Захист HSTS → після першого HTTPS відвідування браузер ЗАВЖДИ використовує HTTPS
і не приймає HTTP відповідь навіть якщо сервер її надсилає
Content-Security-Policy — захист від XSS
Без CSP:
Атакер вставляє <script src="evil.com/steal.js"> → браузер завантажує і виконує
З CSP: default-src 'self':
Браузер завантажує скрипти ТІЛЬКИ з поточного домену → evil.com/steal.js заблоковано
# settings.py — всі заголовки безпеки:
SECURE_CONTENT_TYPE_NOSNIFF = True # nosniff
X_FRAME_OPTIONS = 'DENY' # clickjacking
SECURE_REFERRER_POLICY = 'same-origin' # не витікає URL у Referer заголовку
# Production HTTPS (розкоментуй коли є SSL):
# SECURE_SSL_REDIRECT = True # HTTP → HTTPS редирект
# SECURE_HSTS_SECONDS = 31536000 # HSTS на 1 рік
# SECURE_HSTS_INCLUDE_SUBDOMAINS = True # поширюється на піддомени
# SESSION_COOKIE_SECURE = True # sessionid тільки по HTTPS
# CSRF_COOKIE_SECURE = True # csrftoken тільки по HTTPS
Перевірка через Django check¶
python manage.py check --deploy
# Приклад output:
# WARNINGS:
# ?: (security.W004) You have not set SESSION_COOKIE_SECURE to True.
# ?: (security.W008) Your SECRET_KEY has less than 50 characters.
# ?: (security.W012) SESSION_COOKIE_HTTPONLY is not set to True.
# ?: (security.W018) You should not have DEBUG set to True in deployment.
#
# Кожне попередження — конкретна вразливість!
12. Чек-ліст перед деплоєм¶
Категорія 1: Основні налаштування¶
# settings.py:
DEBUG = False # ❌ Ніколи True в production
SECRET_KEY = os.environ['SECRET_KEY'] # ❌ Не hardcoded рядок
# Як згенерувати новий SECRET_KEY:
# python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
ALLOWED_HOSTS = ['mysite.com', 'www.mysite.com'] # ❌ Не ['*']
Категорія 2: HTTPS¶
# Розкоментуй після встановлення SSL-сертифіката:
SECURE_SSL_REDIRECT = True # HTTP → HTTPS
SESSION_COOKIE_SECURE = True # Cookie тільки по HTTPS
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # Браузер пам'ятає HTTPS 1 рік
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
Категорія 3: Database¶
# Окремий DB-юзер з мінімальними правами (не postgres!):
# CREATE USER django_user WITH PASSWORD 'strongpassword';
# GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO django_user;
Категорія 4: Секрети¶
# .env файл — НЕ в git:
git status # .env не з'являється у списку
cat .gitignore | grep env # → .env (присутній!)
# Перевірити що secrets не в git history:
git log --all --full-history -p -- settings.py | grep "SECRET_KEY ="
Категорія 5: Залежності¶
# Перевірка вразливостей:
pip-audit # pip install pip-audit
# Перевірка застарілих пакетів:
pip list --outdated
# Фіксовані версії у requirements.txt (не >=, а ==):
# Django==5.2.14
# django-crispy-forms==2.3.3
Категорія 6: Django Security Check¶
python manage.py check --deploy
# Має показати: System check identified no issues (0 silenced).
# Якщо є WARNINGS — це реальні вразливості!
Категорія 7: Логування¶
# settings.py — базове логування для production:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'file': {
'class': 'logging.FileHandler',
'filename': '/var/log/django/security.log',
},
},
'loggers': {
'django.security': {
'handlers': ['file'],
'level': 'WARNING',
'propagate': False,
},
'django.request': {
'handlers': ['file'],
'level': 'ERROR',
'propagate': False,
},
},
}
Підсумок: Безпека — це не один
@login_required. Це шари: налаштування, хешування, CSRF, заголовки, мінімальні права, моніторинг. Кожен шар важливий. Пропустиш один — атакер знайде.