Перейти до змісту

Object Permissions — Захист від IDOR

Найпоширеніша вразливість у вебзастосунках (OWASP A01): Broken Access Control — юзер отримує доступ до чужих даних.


Що таке IDOR (Insecure Direct Object Reference)

Аліса зареєстрована. Вона бачить URL своєї нотатки: /notes/42/edit/
Вона думає: "А що буде якщо змінити 42 на 43?"
Аліса переходить на: /notes/43/edit/
Якщо view не перевіряє власника → Аліса редагує нотатку БОБА!

Це атака IDOR — Insecure Direct Object Reference.
OWASP A01 (Broken Access Control) — найчастіша причина витоків даних.

AuthN vs AuthZ різниця (з прикладом SQL)

# ─── ПРОБЛЕМА: @login_required — тільки AuthN, не AuthZ! ────────────────────
@login_required
def note_edit(request, pk):
    note = get_object_or_404(Note, pk=pk)   # ← IDOR вразливість!
    # Аліса залогінена → @login_required пропускає ✓
    # Але note(pk=43) належить Бобу → Аліса редагує чужі дані! ✗

# SQL генерований:
# SELECT * FROM note WHERE pk = 43
# → повертає нотатку Боба! Жодної перевірки user_id!


# ─── РІШЕННЯ: додай user= до get_object_or_404 ──────────────────────────────
@login_required
def note_edit(request, pk):
    note = get_object_or_404(Note, pk=pk, user=request.user)   # ← AuthZ ✓
    # pk=43 + user=Alice
    # SQL: SELECT * FROM note WHERE pk=43 AND user_id=42
    # Боб має note(pk=43, user_id=99) → user_id=42 не збігається → 404

Золоте правило:

@login_required               = "ти залогінений?"        (AuthN)
get_object_or_404(            = "цей об'єкт — твій?"     (AuthZ)
    Note, pk=pk,
    user=request.user
)

Чому 404, а не 403?

403 Forbidden → повідомляємо: "Цей ресурс існує, але ти не маєш доступу"
                Хакер знає що id=43 існує → може спробувати інші атаки

404 Not Found  → повідомляємо: "Такого ресурсу не існує"
                Хакер не знає існує чи ні → менше інформації для атаки

Принцип: не розкривати факт існування чужих об'єктів.


Патерн для власних об'єктів

# Застосовується до: note_edit, note_delete, notebook_edit, notebook_delete,
# todolist_edit, shopping_edit, тощо

@login_required
def note_delete(request, pk):
    note = get_object_or_404(Note, pk=pk, user=request.user)
    #      ↑ Якщо note.user != request.user → 404 (не 403!)
    if request.method == 'POST':
        services.delete_note(note)
        messages.warning(request, 'Нотатку видалено.')
        return redirect('notes_app:note_list')
    return render(request, 'notes_app/note_confirm_delete.html', {'note': note})


@login_required
def notebook_edit(request, pk):
    # Те саме — Notebook з user=request.user
    notebook = get_object_or_404(Notebook, pk=pk, user=request.user)
    # ...

Патерн для спільних об'єктів (власник АБО член групи)

# Нотатки можуть бути груповими → перевірка складніша:
from django.db.models import Q

@login_required
def note_detail(request, pk):
    user_groups = request.user.groups.all()
    note = get_object_or_404(
        Note.objects.filter(
            Q(user=request.user) | Q(group__in=user_groups)
        ),
        pk=pk
    )
    # Аліса бачить нотатку якщо:
    #   note.user == Alice (особиста нотатка)
    #   АБО note.group in Alice.groups.all() (групова нотатка)
    #
    # SQL:
    # SELECT * FROM note
    # WHERE pk = 43
    #   AND (user_id = 42 OR group_id IN (1, 3))
    # → Якщо ні те, ні інше → 404
    return render(request, 'notes_app/note_detail.html', {'note': note})

Повна реалізація note_edit з усіма перевірками

# notes_app/views.py — повна реалізація note_edit:

from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages
from django.db.models import Q
from .models import Note
from .forms import NoteForm
from . import services, selectors


@login_required
def note_edit(request, pk):
    """
    Редагування нотатки — два рівні перевірки:
    1. @login_required          → AuthN: залогінений?
    2. Q(user) | Q(group)       → read access: власна або групова нотатка?
    3. note.user == request.user → AuthZ write: редагувати може лише власник

    Чому Q-filter, а не get_object_or_404(..., user=request.user)?
      get_object_or_404(Note, pk=pk, user=request.user) блокував би
      членів групи від перегляду нотатки через URL /notes/<pk>/edit/.
      Реальна логіка: член групи може ЧИТАТИ нотатку (бачить її),
      але РЕДАГУВАТИ — лише власник.
    """
    user_groups = request.user.groups.all()
    note = get_object_or_404(
        Note.objects.filter(Q(user=request.user) | Q(group__in=user_groups)),
        pk=pk,
    )
    # ↑ SQL: SELECT * FROM note WHERE pk=<pk> AND (user_id=<uid> OR group_id IN (...))
    # → 404 якщо ані власник, ані член групи

    if note.user != request.user:
        # Член групи може ПЕРЕГЛЯДАТИ нотатку, але не редагувати чужу
        messages.error(request, 'Ти не можеш редагувати нотатку іншого користувача.')
        return redirect('notes_app:note_detail', pk=pk)

    if request.method == 'POST':
        form = NoteForm(request.POST, instance=note, user=request.user)
        if form.is_valid():
            # tags — M2M поле; NoteForm.cleaned_data['tags'] → QuerySet об'єктів Tag
            # tag_ids потрібні сервісу окремо (не передаємо dict напряму!)
            tags = form.cleaned_data.get('tags')
            tag_ids = [t.id for t in tags] if tags else []
            services.update_note(
                note,
                title=form.cleaned_data['title'],
                content=form.cleaned_data.get('content', ''),
                priority=form.cleaned_data.get('priority', note.priority),
                notebook=form.cleaned_data.get('notebook'),
                is_pinned=form.cleaned_data.get('is_pinned', note.is_pinned),
                group=form.cleaned_data.get('group'),
                tag_ids=tag_ids,
            )
            # update_note(note, *, title, content, ...) — keyword-only args
            # Не можна викликати як update_note(note, form.cleaned_data) → TypeError
            messages.success(request, f'✅ Нотатку "{note.title}" оновлено!')
            return redirect('notes_app:note_detail', pk=note.pk)
    else:
        form = NoteForm(instance=note, user=request.user)

    return render(request, 'notes_app/note_form.html', {
        'form': form,
        'title': f'Редагувати: {note.title}',
        'note': note,
        'action': 'Зберегти зміни',
    })


@login_required
def note_delete(request, pk):
    # Аналогічно: Q-filter для доступу + owner check перед видаленням
    user_groups = request.user.groups.all()
    note = get_object_or_404(
        Note.objects.filter(Q(user=request.user) | Q(group__in=user_groups)),
        pk=pk,
    )
    if note.user != request.user:
        messages.error(request, 'Ти не можеш видалити нотатку іншого користувача.')
        return redirect('notes_app:note_list')
    if request.method == 'POST':
        title = note.title
        services.delete_note(note)
        messages.warning(request, f'🗑️ Нотатку "{title}" видалено.')
        return redirect('notes_app:note_list')
    return render(request, 'notes_app/note_confirm_delete.html', {'note': note})

До vs Після:

# ✗ ДО (IDOR вразливість):
note = get_object_or_404(Note, pk=pk)
# SQL: SELECT * FROM note WHERE pk=43
# Повертає нотатку Боба якщо pk=43 — жодної перевірки власника!

# ✓ ПІСЛЯ для особистих об'єктів (Notebook, TodoList, тощо):
notebook = get_object_or_404(Notebook, pk=pk, user=request.user)
# SQL: SELECT * FROM notebook WHERE pk=43 AND user_id=42
# pk=43 + user_id=42 → не знайдено → 404

# ✓ ПІСЛЯ для спільних об'єктів (Note — може бути груповою):
user_groups = request.user.groups.all()
note = get_object_or_404(
    Note.objects.filter(Q(user=request.user) | Q(group__in=user_groups)),
    pk=pk,
)
# SQL: SELECT * FROM note WHERE pk=43 AND (user_id=42 OR group_id IN (1,3))
# Власник АБО член групи — обидва отримують доступ для читання
# Для запису: ще перевіряємо note.user == request.user окремо

Таблиця захищених views у notes_app

View Захист Як реалізовано
note_list @login_required Selector Q(user=user) \| Q(group__in=...)
note_edit @login_required + owner Q-filter для доступу, потім note.user != request.user для редагування
note_delete @login_required + owner Те саме
notebook_edit @login_required + owner get_object_or_404(Notebook, pk=pk, user=request.user)
notebook_delete @login_required + owner Те саме
group_detail @login_required + membership get_group_with_members(pk, user) → None якщо не член
group_delete @login_required + membership group.user_set.filter(pk=user.pk).exists()

Далі

Наступна глава: Group Sharing — Django Group, FK SET_NULL, Q-filter, Services, Views, Templates.