Тесты безопасности¶
Самый специфичный уровень. Проверяют, что система не отдаёт данные тому, кому не должна, и не позволяет обойти проверки прав.
Когда писать¶
- Добавил новую роль или Rule — обязательно тест на изоляцию.
- Реализовал bypass-механизм (
SystemSession, ручной обход ACL) — тест что он работает только в нужном контексте. - Добавил публичный endpoint — тест на anonymous-доступ и rate-limiting.
- Поправил баг с правами — регрессионный тест, чтобы он не вернулся.
Когда не писать¶
- В качестве замены integration — у них разные цели. Integration-тест проверяет «что endpoint работает». Security-тест — «что endpoint не работает в обход прав».
- На «тривиальные» проверки (только админ может делать X) если эта логика чисто декларативная — Rule достаточно. Тест нужен на сложных сценариях.
Структура¶
tests/security/
├── conftest.py # многопользовательские фикстуры
├── test_acl_isolation.py # юзер A не видит данные юзера B
├── test_rules_filtering.py # Rules корректно фильтруют SELECT
├── test_admin_bypass.py # is_admin реально обходит проверки
├── test_session_security.py # сессии: protocol, expiration, hijacking
├── test_anonymous_endpoints.py # что доступно без авторизации
└── test_input_validation.py # SQL-инъекции, XSS, path traversal
Главные сценарии¶
Изоляция данных между пользователями¶
tests/security/test_acl_isolation.py
@pytest.mark.security
async def test_manager_sees_only_own_leads(env, two_managers):
"""Менеджер видит только своих лидов через Rule user_id={{user_id}}."""
manager_a, manager_b = two_managers
# Manager A создаёт лид
set_access_session(Session(user_id=manager_a))
lead_a = await env.models.lead.create(payload=Lead(name="Lead A"))
# Manager B создаёт другой лид
set_access_session(Session(user_id=manager_b))
lead_b = await env.models.lead.create(payload=Lead(name="Lead B"))
# Manager A видит только свой
set_access_session(Session(user_id=manager_a))
leads_for_a = await env.models.lead.search()
lead_ids = [l.id for l in leads_for_a]
assert lead_a.id in lead_ids
assert lead_b.id not in lead_ids # ← главная проверка
Прямой доступ по ID — обход через знание PK¶
Частая ошибка: фронт фильтрует записи правильно, а на бэке GET /api/leads/{id} отдаёт по ID без проверки прав.
async def test_direct_access_by_id_blocked(client, manager_a, manager_b):
"""GET /api/leads/{lead_b_id} от manager_a → 403/404."""
# manager_b создаёт лид
headers_b = login_headers(manager_b)
res = await client.post("/api/leads", json={"name": "Secret"}, headers=headers_b)
secret_lead_id = res.json()["id"]
# manager_a пытается прочитать его напрямую
headers_a = login_headers(manager_a)
res = await client.get(f"/api/leads/{secret_lead_id}", headers=headers_a)
assert res.status_code in (403, 404), \
f"manager_a смог прочитать чужой лид: {res.json()}"
Rules не обходятся через JSON-фильтр¶
async def test_filter_cannot_bypass_rules(client, manager):
"""Попытка вытянуть чужие лиды через filter: [['user_id', '!=', me]]."""
headers = login_headers(manager)
# Кажется, можно искать по обратному условию
res = await client.get(
"/api/crud-auto/leads/search",
params={"filter": '[["user_id", "!=", ' + str(manager.id) + "]]"},
headers=headers,
)
assert res.status_code == 200
leads = res.json()["data"]
# Все вернувшиеся записи всё равно должны быть свои —
# Rule доминирует над пользовательским фильтром
for lead in leads:
assert lead["user_id"]["id"] == manager.id
Admin реально админ¶
async def test_admin_bypasses_rules(env, admin, regular_user):
"""is_admin=true — видит ВСЁ, минуя ACL и Rules."""
set_access_session(Session(user_id=regular_user))
user_lead = await env.models.lead.create(payload=Lead(name="User's lead"))
set_access_session(Session(user_id=admin))
admin_view = await env.models.lead.search()
assert any(l.id == user_lead.id for l in admin_view)
Session hijacking невозможен¶
async def test_expired_session_rejected(client):
"""Использовать токен после expiration — 401."""
expired_token = create_session_with_expiry(expires_in=-3600) # уже истёк
res = await client.get(
"/api/users/me",
headers={"Authorization": f"Bearer {expired_token}"},
)
assert res.status_code == 401
Input validation¶
@pytest.mark.parametrize("payload", [
"'; DROP TABLE users; --",
"<script>alert(1)</script>",
"../../../etc/passwd",
"x" * 10000, # огромная строка
{"$ne": None}, # NoSQL-injection style
"%00", # null byte
])
async def test_malicious_input_handled(client, manager, payload):
"""Любой malicious-input не падает с 500 и не уходит в БД сырым."""
headers = login_headers(manager)
res = await client.post(
"/api/leads",
json={"name": payload},
headers=headers,
)
# Допустимо: 400 (validation), 422 (Pydantic), 200 (записалось безопасно)
# Недопустимо: 500 (упал) или success+вернулся как HTML/JS
assert res.status_code in (200, 201, 400, 422)
Многопользовательские фикстуры¶
tests/security/conftest.py обычно содержит готовых разных пользователей:
@pytest.fixture
async def two_managers(env, db_pool):
"""Два менеджера с разными ID, чтобы тестировать изоляцию."""
role = await env.models.role.search(filter=[("code", "=", "manager")])
a = await env.models.user.create(payload=User(name="A", login="a", role_ids=[role[0].id]))
b = await env.models.user.create(payload=User(name="B", login="b", role_ids=[role[0].id]))
return a, b
@pytest.fixture
async def admin_and_user(env):
admin = await env.models.user.create(payload=User(name="Admin", login="adm", is_admin=True))
user = await env.models.user.create(payload=User(name="User", login="user"))
return admin, user
Запуск¶
# Все security-тесты
pytest tests/security/ -v -m security
# Только конкретная категория
pytest tests/security/test_acl_isolation.py -v
Что должно быть покрыто¶
Каждая модель с пользовательскими данными должна иметь хотя бы:
- Test isolation: user A не видит записи user B (если нет соответствующего права).
- Test direct-id access:
GET /model/{other_user_id}→ 403/404. - Test filter bypass: попытка прочитать через хитрый фильтр блокируется.
Это минимум. Хорошо, если есть ещё:
- Test admin bypass:
is_adminреально работает. - Test role escalation: user A не может присвоить себе админскую роль через update.
CI¶
Security-тесты должны идти на каждом PR — это самый болезненный класс багов в production. Если security-тесты медленные (нагрузочные сценарии перебора), вынеси их в отдельный сюит и гоняй раз в сутки.