Una API que devuelve un 200 con datos de otro usuario cuando cambias un ID en la URL tiene un bug de broken object-level authorization, y puedes detectarlo con un test de API de Playwright durante el desarrollo regular sin necesitar un pentester. Lo mismo aplica a la inyección SQL, XSS, fuerza bruta en cuentas y exposición de datos sensibles en respuestas de API. Este artículo cubre las categorías del OWASP Top 10 más testeables durante QA, con tests completos de Playwright TypeScript para cada una que se conectan directamente a una suite de CI existente.
Por qué QA debería hacer testing de seguridad
La seguridad es responsabilidad de todos. Los QA engineers tienen ventajas:
- Acceso al código fuente y entornos de prueba, que los pentesters frecuentemente no tienen
- Comprensión de la lógica de la aplicación: sabes qué debería y qué no debería ser posible
- Infraestructura de tests automatizados: las verificaciones de seguridad pueden ser parte de tu pipeline de CI
- Cadencia de testing frecuente: las regresiones de seguridad se detectan temprano
El OWASP Top 10 (más relevante para QA)
El Open Web Application Security Project publica las vulnerabilidades web más comunes. Estas son las que encontrarás con más frecuencia.
1. Control de acceso roto
Usuarios que acceden a recursos a los que no deberían poder acceder.
test('el usuario regular no puede acceder a la API de admin', async ({ request }) => {
// Login como miembro regular
const loginResp = await request.post('/api/auth/login', {
data: { email: 'member@test.com', password: 'MemberPass1' },
});
const { token } = await loginResp.json();
// Intentar acceder al endpoint de admin
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` },
});
// Debería ser 403 Forbidden, no 200
expect(response.status()).toBe(403);
});
test('el usuario no puede ver el perfil de otro usuario cambiando el ID', async ({ request }) => {
const user1Token = await getToken('user1@test.com', 'Pass1');
// Usuario 1 intenta acceder al perfil del Usuario 2 adivinando el ID
const response = await request.get('/api/users/999', {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Debería ser 403 o 404, no 200 con datos del usuario 2
expect([403, 404]).toContain(response.status());
});2. Problemas de autenticación
test('bloqueo de cuenta después de intentos fallidos', async ({ request }) => {
// Intentar contraseña incorrecta 5 veces
for (let i = 0; i < 5; i++) {
await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'WrongPassword' },
});
}
// El 6to intento debería ser bloqueado
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('el token de reseteo de contraseña expira', async ({ request }) => {
// Solicitar reseteo
await request.post('/api/auth/forgot-password', {
data: { email: 'user@test.com' },
});
// En tests, usar un token expirado de la base de datos o 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. Inyección: SQL injection
test('el formulario de login no es vulnerable a SQL injection', async ({ page }) => {
await page.goto('/login');
// Payload clásico de SQL injection
await page.fill('[data-testid="email"]', "' OR '1'='1");
await page.fill('[data-testid="password"]', "' OR '1'='1");
await page.click('[data-testid="submit"]');
// NO debería estar logueado
await expect(page).not.toHaveURL('/dashboard');
await expect(page.getByTestId('error-message')).toBeVisible();
});
test('la búsqueda no es vulnerable a SQL injection', 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}` },
});
// Debería devolver 200 con resultados vacíos/seguros, no error ni volcado de datos
expect(response.status()).toBe(200);
const body = await response.json();
// No debería devolver todos los usuarios (indicaría que la inyección funcionó)
const count = Array.isArray(body.data) ? body.data.length : body.length;
expect(count).toBeLessThan(100); // Verificación de sanidad — no volcando todos los usuarios
}
});4. Cross-Site Scripting (XSS)
test('el input del usuario no se ejecuta como script', async ({ page }) => {
await page.goto('/login');
// Login primero
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Intentar inyectar script en un campo controlado por el usuario
await page.goto('/profile');
const xssPayload = '<script>alert("XSS")</script>';
await page.fill('[data-testid="display-name"]', xssPayload);
await page.click('[data-testid="save"]');
// Navegar y volver para ver si el script se ejecuta
await page.goto('/dashboard');
await page.goto('/profile');
// Verificar si aparece un diálogo (aparecería si XSS funcionó)
let dialogAppeared = false;
page.on('dialog', dialog => {
dialogAppeared = true;
dialog.dismiss();
});
await page.waitForTimeout(1000); // Dar tiempo para que el script se ejecute si está presente
expect(dialogAppeared).toBe(false);
// El nombre debería aparecer como texto, no ejecutarse
const nameField = page.getByTestId('display-name');
const value = await nameField.inputValue();
expect(value).toBe(xssPayload); // Almacenado como texto, no ejecutado
});5. Exposición de datos sensibles
test('la contraseña no se devuelve en la respuesta de la API', 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('el token de auth no se registra en los encabezados de respuesta', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'ValidPass1' },
});
// El token debería estar en el body, no expuesto en encabezados para todos
const headers = response.headers();
expect(headers['x-auth-token']).toBeUndefined();
expect(headers['authorization']).toBeUndefined();
// Pero sí debería estar en el body
const body = await response.json();
expect(body.token).toBeDefined();
});
test('la API usa HTTPS', async ({ request }) => {
const response = await request.get('/api/users');
// Verificar encabezado HSTS
const headers = response.headers();
expect(headers['strict-transport-security']).toBeDefined();
});OWASP ZAP: escaneo de seguridad automatizado
OWASP ZAP es una herramienta de escaneo de seguridad gratuita. Puedes integrarla con Playwright:
// Usando proxy de ZAP con Playwright
test.use({ proxy: { server: 'http://localhost:8080' } }); // Proxy de ZAP
test('escaneo de seguridad a través de ZAP', async ({ page }) => {
await page.goto('/');
await page.goto('/login');
await page.goto('/products');
// ZAP escanea pasivamente todas las solicitudes hechas por el navegador
});O ejecutar ZAP programáticamente:
# Escaneo ZAP con Docker
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.myapp.com \
-r zap-report.htmlVerificación de encabezados de seguridad
test('encabezados de seguridad presentes', async ({ request }) => {
const response = await request.get('/');
const headers = response.headers();
// Content Security Policy
expect(headers['content-security-policy']).toBeDefined();
// Prevenir clickjacking
expect(headers['x-frame-options']).toBeDefined();
// Prevenir sniffing de tipo MIME
expect(headers['x-content-type-options']).toBe('nosniff');
// Protección XSS (heredado, pero bueno tenerlo)
expect(headers['x-xss-protection']).toBeDefined();
// Solo HTTPS
expect(headers['strict-transport-security']).toBeDefined();
// No enviar información de referrer a sitios externos
expect(headers['referrer-policy']).toBeDefined();
});Probar casos extremos de autorización
test.describe('IDOR (Insecure Direct Object Reference)', () => {
test('el usuario no puede eliminar el post de otro usuario', async ({ request }) => {
// Obtener tokens para dos usuarios diferentes
const user1Token = await getToken('user1@test.com', 'Pass1');
const user2Token = await getToken('user2@test.com', 'Pass2');
// Usuario 2 crea un post
const createResp = await request.post('/api/posts', {
data: { title: 'Post del Usuario 2', content: 'Contenido' },
headers: { Authorization: `Bearer ${user2Token}` },
});
const post = await createResp.json();
// Usuario 1 intenta eliminar el post del Usuario 2
const deleteResp = await request.delete(`/api/posts/${post.id}`, {
headers: { Authorization: `Bearer ${user1Token}` },
});
// Debería ser 403 Forbidden
expect(deleteResp.status()).toBe(403);
// El post debería seguir existiendo
const getResp = await request.get(`/api/posts/${post.id}`);
expect(getResp.status()).toBe(200);
});
});Resumen
Tests de seguridad que QA siempre debería incluir:
| Test | Qué verifica |
|------|-------------|
| Control de acceso | Los usuarios no pueden acceder a recursos por encima de su rol |
| IDOR | Los usuarios no pueden acceder a recursos de otros usuarios por ID |
| Bloqueo de cuenta | Protección contra fuerza bruta |
| SQL injection | El input del usuario no se interpreta como SQL |
| XSS | El input del usuario no se ejecuta como script |
| Exposición de datos | Los campos sensibles no se devuelven en la API |
| Encabezados de seguridad | HTTPS, CSP, encabezados de protección XSS presentes |
→ See also: Fundamentos de Pruebas de Seguridad que Todo Ingeniero QA Debe Conocer | Autenticación en Tests de API: Claves API, Tokens Bearer, OAuth2, JWT | ¿Qué es una API REST? Guía Práctica para Ingenieros QA