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.html

Verificació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