API который возвращает 200 с данными другого пользователя при подмене ID в URL: это баг broken object-level authorization, и его можно поймать Playwright API-тестом в процессе обычной разработки без пентестера. То же применимо к SQL-инъекции, XSS, брутфорсу аккаунтов и утечке чувствительных данных в API-ответах. Статья разбирает категории OWASP Top 10 наиболее тестируемые во время QA, с полными Playwright TypeScript-тестами для каждой из них которые напрямую подключаются к существующему CI-сьюту.
Почему QA должны тестировать безопасность
Security: ответственность каждого. У QA-инженеров есть преимущества.
- Доступ к исходному коду и тестовым окружениям: у пентестеров часто нет
- Понимание логики приложения: знаешь что должно и не должно быть возможным
- Инфраструктура автотестов: security-проверки могут быть частью CI-пайплайна
- Высокая частота тестирования: security-регрессии ловятся рано
OWASP Top 10 (наиболее релевантные для QA)
Open Web Application Security Project публикует список самых распространённых веб-уязвимостей. Вот те с которыми сталкиваешься чаще всего.
1. Нарушение контроля доступа
Пользователи получают доступ к ресурсам к которым не должны.
Тест:test('regular user cannot access admin API', async ({ request }) => {
// Вход как обычный участник
const loginResp = await request.post('/api/auth/login', {
data: { email: 'member@test.com', password: 'MemberPass1' },
});
const { token } = await loginResp.json();
// Попытка получить доступ к admin-эндпоинту
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` },
});
// Должно быть 403 Forbidden, не 200
expect(response.status()).toBe(403);
});
test('user cannot view another user profile by changing ID', async ({ request }) => {
const user1Token = await getToken('user1@test.com', 'Pass1');
// Пользователь 1 пытается получить профиль Пользователя 2 по угаданному ID
const response = await request.get('/api/users/999', {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Должно быть 403 или 404, не 200 с данными пользователя 2
expect([403, 404]).toContain(response.status());
});2. Проблемы аутентификации
test('account lockout after failed attempts', async ({ request }) => {
// Неверный пароль 5 раз
for (let i = 0; i < 5; i++) {
await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
}
// 6-я попытка должна быть заблокирована
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
expect(response.status()).toBe(429); // Too Many Requests
const body = await response.json();
expect(body.message).toMatch(/locked|too many/i);
});
test('password reset token expires', async ({ request }) => {
// Запрос сброса
await request.post('/api/auth/forgot-password', {
data: { email: 'user@test.com' },
});
// В тестах используй истёкший токен из базы данных или мок
const expiredToken = 'expired-reset-token-12345';
const response = await request.post('/api/auth/reset-password', {
data: { token: expiredToken, password: 'NewPass1!' },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toMatch(/expired|invalid/i);
});3. Инъекции: SQL-инъекция
test('login form is not SQL injectable', async ({ page }) => {
await page.goto('/login');
// Классический SQL-инъекционный payload
await page.fill('[data-testid="email"]', "' OR '1'='1");
await page.fill('[data-testid="password"]', "' OR '1'='1");
await page.click('[data-testid="submit"]');
// НЕ должен войти в систему
await expect(page).not.toHaveURL('/dashboard');
await expect(page.getByTestId('error-message')).toBeVisible();
});
test('search is not SQL injectable', async ({ request, authToken }) => {
const injections = [
"'; DROP TABLE users; --",
"' UNION SELECT username, password FROM users--",
"1=1",
"' OR 1=1--",
];
for (const payload of injections) {
const response = await request.get(`/api/users?search=${encodeURIComponent(payload)}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
// Должен вернуть 200 с пустыми/безопасными результатами, не ошибку или дамп данных
expect(response.status()).toBe(200);
const body = await response.json();
// Не должен возвращать всех пользователей (означало бы что инъекция сработала)
const count = Array.isArray(body.data) ? body.data.length : body.length;
expect(count).toBeLessThan(100); // Санитарная проверка — не дамп всех пользователей
}
});4. Межсайтовый скриптинг (XSS)
test('user input is not executed as script', async ({ page }) => {
await page.goto('/login');
// Входим
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Пытаемся внедрить скрипт в поле управляемое пользователем
await page.goto('/profile');
const xssPayload = '<script>alert("XSS")</script>';
await page.fill('[data-testid="display-name"]', xssPayload);
await page.click('[data-testid="save"]');
// Уходим и возвращаемся чтобы проверить выполняется ли скрипт
await page.goto('/dashboard');
await page.goto('/profile');
// Проверяем диалог (появится если XSS сработал)
let dialogAppeared = false;
page.on('dialog', dialog => {
dialogAppeared = true;
dialog.dismiss();
});
await page.waitForTimeout(1000); // Даём время скрипту выполниться если он есть
expect(dialogAppeared).toBe(false);
// Имя должно отображаться как текст, а не выполняться
const nameField = page.getByTestId('display-name');
const value = await nameField.inputValue();
expect(value).toBe(xssPayload); // Сохранено как текст, не выполнено
});5. Утечка чувствительных данных
test('password not returned in API response', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.password).toBeUndefined();
expect(body.passwordHash).toBeUndefined();
expect(body.passwordSalt).toBeUndefined();
});
test('auth token not logged in response headers', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'ValidPass1' },
});
// Токен должен быть в теле, а не раскрываться в заголовках
const headers = response.headers();
expect(headers['x-auth-token']).toBeUndefined();
expect(headers['authorization']).toBeUndefined();
// Но должен быть в теле
const body = await response.json();
expect(body.token).toBeDefined();
});
test('API uses HTTPS', async ({ request }) => {
const response = await request.get('/api/users');
// Проверяем HSTS-заголовок
const headers = response.headers();
expect(headers['strict-transport-security']).toBeDefined();
});OWASP ZAP: автоматизированное security-сканирование
OWASP ZAP: бесплатный инструмент security-сканирования. Интегрируется с Playwright:
// Использование ZAP-прокси с Playwright
test.use({ proxy: { server: 'http://localhost:8080' } }); // ZAP proxy
test('security scan through ZAP', async ({ page }) => {
await page.goto('/');
await page.goto('/login');
await page.goto('/products');
// ZAP пассивно сканирует все запросы браузера
});Или запускай ZAP программно:
# Docker ZAP сканирование
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.myapp.com \
-r zap-report.htmlПроверка security-заголовков
test('security headers present', async ({ request }) => {
const response = await request.get('/');
const headers = response.headers();
// Content Security Policy
expect(headers['content-security-policy']).toBeDefined();
// Защита от clickjacking
expect(headers['x-frame-options']).toBeDefined();
// Предотвращение MIME type sniffing
expect(headers['x-content-type-options']).toBe('nosniff');
// XSS-защита (устаревший, но полезный заголовок)
expect(headers['x-xss-protection']).toBeDefined();
// Только HTTPS
expect(headers['strict-transport-security']).toBeDefined();
// Не передавать реферер внешним сайтам
expect(headers['referrer-policy']).toBeDefined();
});Тестирование edge case авторизации
test.describe('IDOR (Insecure Direct Object Reference)', () => {
test('user cannot delete another user\'s post', async ({ request }) => {
// Токены для двух разных пользователей
const user1Token = await getToken('user1@test.com', 'Pass1');
const user2Token = await getToken('user2@test.com', 'Pass2');
// Пользователь 2 создаёт пост
const createResp = await request.post('/api/posts', {
data: { title: 'User 2 Post', content: 'Content' },
headers: { Authorization: `Bearer ${user2Token}` },
});
const post = await createResp.json();
// Пользователь 1 пытается удалить пост Пользователя 2
const deleteResp = await request.delete(`/api/posts/${post.id}`, {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Должно быть 403 Forbidden
expect(deleteResp.status()).toBe(403);
// Пост должен существовать
const getResp = await request.get(`/api/posts/${post.id}`);
expect(getResp.status()).toBe(200);
});
});Что QA должен тестировать по security
| Тест | Что проверяет |
|------|--------------|
| Контроль доступа | Пользователи не получают доступ к ресурсам выше своей роли |
| IDOR | Пользователи не получают ресурсы других по ID |
| Блокировка аккаунта | Защита от брутфорса |
| SQL-инъекция | Ввод пользователя не интерпретируется как SQL |
| XSS | Ввод пользователя не выполняется как скрипт |
| Утечка данных | Чувствительные поля не возвращаются в API |
| Security-заголовки | Присутствуют HTTPS, CSP, заголовки XSS-защиты |
Не нужно искать zero-day эксплойты. Найти и предотвратить уязвимости из OWASP Top 10 делает приложение значительно более защищённым и предотвращает самые распространённые атаки которые реально происходят.
→ See also: Основы тестирования безопасности, которые должен знать каждый QA-инженер | Авторизация в API-тестах: API-ключи, Bearer-токены, OAuth2, JWT | Что такое REST API? Практическое руководство для QA-инженеров