Les ingénieurs QA peuvent tester les vulnérabilités de sécurité web les plus courantes sans expertise en test de pénétration. L'injection SQL, le XSS, les problèmes d'authentification, les références directes à des objets non sécurisées et les vérifications d'autorisation manquantes sont tous testables pendant les tests fonctionnels habituels.
Pourquoi le QA doit faire des tests de sécurité
La sécurité est la responsabilité de tous. Les ingénieurs QA ont des avantages :
- Accès au code source et aux environnements de test, que les pentesters n'ont souvent pas
- Compréhension de la logique applicative : vous savez ce qui devrait et ne devrait pas être possible
- Infrastructure de tests automatisés : les vérifications de sécurité peuvent faire partie de votre pipeline CI
- Cadence de test fréquente : les régressions de sécurité sont détectées tôt
Le Top 10 OWASP (le plus pertinent pour le QA)
L'Open Web Application Security Project publie les vulnérabilités web les plus courantes. Voici celles que vous rencontrerez le plus souvent.
1. Contrôle d'accès défaillant
Des utilisateurs accèdent à des ressources auxquelles ils ne devraient pas avoir accès.
Test :test('regular user cannot access admin API', async ({ request }) => {
// Connexion en tant que membre standard
const loginResp = await request.post('/api/auth/login', {
data: { email: 'member@test.com', password: 'MemberPass1' },
});
const { token } = await loginResp.json();
// Tentative d'accès à l'endpoint admin
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` },
});
// Doit être 403 Forbidden, pas 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');
// L'utilisateur 1 tente d'accéder au profil de l'utilisateur 2 en devinant l'ID
const response = await request.get('/api/users/999', {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Doit être 403 ou 404, pas 200 avec les données de l'utilisateur 2
expect([403, 404]).toContain(response.status());
});2. Problèmes d'authentification
test('account lockout after failed attempts', async ({ request }) => {
// Essayer le mauvais mot de passe 5 fois
for (let i = 0; i < 5; i++) {
await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
}
// La 6e tentative doit être bloquée
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 }) => {
// Demander une réinitialisation
await request.post('/api/auth/forgot-password', {
data: { email: 'user@test.com' },
});
// En test, utiliser un token expiré depuis la base de données ou un mock
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. Injection SQL
test('login form is not SQL injectable', async ({ page }) => {
await page.goto('/login');
// Payload d'injection SQL classique
await page.fill('[data-testid="email"]', "' OR '1'='1");
await page.fill('[data-testid="password"]', "' OR '1'='1");
await page.click('[data-testid="submit"]');
// Ne doit PAS être connecté
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}` },
});
// Doit retourner 200 avec des résultats vides/sûrs, pas une erreur ou un dump de données
expect(response.status()).toBe(200);
const body = await response.json();
// Ne doit pas retourner tous les utilisateurs (ce qui indiquerait une injection réussie)
const count = Array.isArray(body.data) ? body.data.length : body.length;
expect(count).toBeLessThan(100);
}
});4. Cross-Site Scripting (XSS)
test('user input is not executed as script', async ({ page }) => {
await page.goto('/login');
// Connexion d'abord
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Tentative d'injection de script dans un champ contrôlé par l'utilisateur
await page.goto('/profile');
const xssPayload = '<script>alert("XSS")</script>';
await page.fill('[data-testid="display-name"]', xssPayload);
await page.click('[data-testid="save"]');
// Naviguer ailleurs puis revenir pour voir si le script s'exécute
await page.goto('/dashboard');
await page.goto('/profile');
// Vérifier si une boîte de dialogue apparaît (elle apparaîtrait si le XSS fonctionnait)
let dialogAppeared = false;
page.on('dialog', dialog => {
dialogAppeared = true;
dialog.dismiss();
});
await page.waitForTimeout(1000);
expect(dialogAppeared).toBe(false);
// Le nom doit apparaître comme texte, pas être exécuté
const nameField = page.getByTestId('display-name');
const value = await nameField.inputValue();
expect(value).toBe(xssPayload);
});5. Exposition de données sensibles
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' },
});
// Le token doit être dans le body, pas exposé dans les en-têtes
const headers = response.headers();
expect(headers['x-auth-token']).toBeUndefined();
expect(headers['authorization']).toBeUndefined();
// Mais doit être dans le body
const body = await response.json();
expect(body.token).toBeDefined();
});
test('API uses HTTPS', async ({ request }) => {
const response = await request.get('/api/users');
// Vérifier la présence de l'en-tête HSTS
const headers = response.headers();
expect(headers['strict-transport-security']).toBeDefined();
});OWASP ZAP : scan de sécurité automatisé
OWASP ZAP est un outil de scan de sécurité gratuit. Il peut s'intégrer avec Playwright :
// Utiliser le proxy ZAP avec Playwright
test.use({ proxy: { server: 'http://localhost:8080' } }); // proxy ZAP
test('security scan through ZAP', async ({ page }) => {
await page.goto('/');
await page.goto('/login');
await page.goto('/products');
// ZAP scanne passivement toutes les requêtes émises par le navigateur
});Ou exécutez ZAP par programme :
# Scan ZAP via Docker
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.myapp.com \
-r zap-report.htmlVérification des en-têtes de sécurité
test('security headers present', async ({ request }) => {
const response = await request.get('/');
const headers = response.headers();
// Politique de sécurité du contenu
expect(headers['content-security-policy']).toBeDefined();
// Protection contre le clickjacking
expect(headers['x-frame-options']).toBeDefined();
// Empêcher le MIME type sniffing
expect(headers['x-content-type-options']).toBe('nosniff');
// Protection XSS (legacy, mais utile)
expect(headers['x-xss-protection']).toBeDefined();
// HTTPS uniquement
expect(headers['strict-transport-security']).toBeDefined();
// Ne pas envoyer d'informations referrer aux sites externes
expect(headers['referrer-policy']).toBeDefined();
});Tester les cas limites d'autorisation
test.describe('IDOR (Insecure Direct Object Reference)', () => {
test('user cannot delete another user\'s post', async ({ request }) => {
// Obtenir les tokens de deux utilisateurs différents
const user1Token = await getToken('user1@test.com', 'Pass1');
const user2Token = await getToken('user2@test.com', 'Pass2');
// L'utilisateur 2 crée un post
const createResp = await request.post('/api/posts', {
data: { title: 'Post utilisateur 2', content: 'Contenu' },
headers: { Authorization: `Bearer ${user2Token}` },
});
const post = await createResp.json();
// L'utilisateur 1 tente de supprimer le post de l'utilisateur 2
const deleteResp = await request.delete(`/api/posts/${post.id}`, {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Doit être 403 Forbidden
expect(deleteResp.status()).toBe(403);
// Le post doit toujours exister
const getResp = await request.get(`/api/posts/${post.id}`);
expect(getResp.status()).toBe(200);
});
});Récapitulatif
Tests de sécurité que le QA doit toujours inclure :
| Test | Ce qu'il vérifie |
|---|---|
| Contrôle d'accès | Les utilisateurs n'accèdent pas aux ressources au-delà de leur rôle |
| IDOR | Les utilisateurs n'accèdent pas aux ressources d'autres utilisateurs par ID |
| Blocage de compte | Protection contre le brute force |
| Injection SQL | Les entrées utilisateur ne sont pas interprétées comme du SQL |
| XSS | Les entrées utilisateur ne sont pas exécutées comme des scripts |
| Exposition de données | Les champs sensibles ne sont pas retournés dans l'API |
| En-têtes de sécurité | HTTPS, CSP, en-têtes de protection XSS présents |
Vous n'avez pas besoin de trouver des failles zero-day. Détecter et prévenir les dix vulnérabilités OWASP les plus courantes rend votre application considérablement plus sûre et protège contre les attaques réelles les plus fréquentes.
→ See also: Bases des Tests de Sécurité que Tout Ingénieur QA Devrait Connaître | Authentification dans les Tests d'API: Clés API, Tokens Bearer, OAuth2, JWT | Qu'est-ce qu'une API REST? Un Guide Pratique pour les Ingénieurs QA