Skip to content

Seguridad de Webhooks

Resumen de Seguridad

Quralo implementa un sistema de seguridad de doble capa para garantizar que los webhooks sean auténticos y no hayan sido modificados:

  1. Autenticación Bearer Token: Verifica la identidad del emisor
  2. Firma HMAC-SHA256: Garantiza la integridad del payload

Autenticación Bearer Token

Funcionamiento

Cada webhook incluye un token de autenticación en el header Authorization:

http
Authorization: Bearer 7mXMLcEwcnTBOJrjhbOJtw3KGrKhFMcrczEFJfSg

Implementación

Su endpoint debe validar este token antes de procesar el evento:

javascript
function authenticateRequest(req, res, next) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader) {
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Missing Authorization header'
    });
  }
  
  const token = authHeader.replace('Bearer ', '');
  
  if (token !== process.env.QURALO_AUTH_TOKEN) {
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Invalid authentication token'
    });
  }
  
  next();
}

Verificación de Firma HMAC

¿Qué es HMAC?

HMAC (Hash-based Message Authentication Code) es un mecanismo que usa una función hash criptográfica junto con una clave secreta para verificar tanto la integridad como la autenticidad de un mensaje.

Proceso de Generación

Quralo genera la firma usando:

  1. Payload: El cuerpo JSON del webhook (como string)
  2. Secret: Su clave secreta del webhook
  3. Algoritmo: HMAC-SHA256
javascript
const signature = crypto
  .createHmac('sha256', webhookSecret)
  .update(JSON.stringify(payload))
  .digest('hex');

Header de Firma

La firma se incluye en el header X-Webhook-Signature:

http
X-Webhook-Signature: 5f8d7c6b4a3e2f1a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5

Implementación de Verificación

javascript
function validateSignature(body, signature, secret) {
  // Generar firma esperada
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(body))
    .digest('hex');
  
  // Comparación segura para evitar timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

function validateWebhookSignature(req, res, next) {
  const signature = req.headers['x-webhook-signature'];
  
  if (!signature) {
    return res.status(400).json({
      error: 'Bad Request',
      message: 'Missing X-Webhook-Signature header'
    });
  }
  
  try {
    if (!validateSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).json({
        error: 'Unauthorized',
        message: 'Invalid webhook signature'
      });
    }
  } catch (error) {
    return res.status(400).json({
      error: 'Bad Request',
      message: 'Invalid signature format'
    });
  }
  
  next();
}

Implementación Completa

Node.js/Express

javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

// Middleware para obtener raw body (necesario para verificar firma)
app.use('/webhook', express.raw({ type: 'application/json' }));

function authenticateAndValidate(req, res, next) {
  // 1. Validar token de autenticación
  const authHeader = req.headers.authorization;
  if (!authHeader || authHeader.replace('Bearer ', '') !== process.env.QURALO_AUTH_TOKEN) {
    return res.status(401).json({ error: 'Invalid authentication' });
  }
  
  // 2. Validar firma HMAC
  const signature = req.headers['x-webhook-signature'];
  if (!signature) {
    return res.status(400).json({ error: 'Missing signature' });
  }
  
  const expectedSignature = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(req.body, 'utf8')
    .digest('hex');
  
  if (signature !== expectedSignature) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Parsear JSON después de validar
  req.body = JSON.parse(req.body);
  next();
}

app.post('/webhook', authenticateAndValidate, (req, res) => {
  // Webhook validado, procesar evento
  const eventType = req.headers['x-webhook-event'];
  const payload = req.body;
  
  console.log('Evento recibido:', eventType);
  console.log('Payload:', payload);
  
  res.status(200).json({ success: true });
});

PHP

php
<?php
function validateWebhookSecurity($body, $headers) {
    $authToken = $_ENV['QURALO_AUTH_TOKEN'];
    $webhookSecret = $_ENV['WEBHOOK_SECRET'];
    
    // Validar token de autenticación
    $authHeader = $headers['Authorization'] ?? '';
    if (str_replace('Bearer ', '', $authHeader) !== $authToken) {
        throw new Exception('Invalid authentication token');
    }
    
    // Validar firma HMAC
    $signature = $headers['X-Webhook-Signature'] ?? '';
    if (empty($signature)) {
        throw new Exception('Missing webhook signature');
    }
    
    $expectedSignature = hash_hmac('sha256', $body, $webhookSecret);
    
    if (!hash_equals($expectedSignature, $signature)) {
        throw new Exception('Invalid webhook signature');
    }
}

// Uso
try {
    $headers = getallheaders();
    $body = file_get_contents('php://input');
    
    validateWebhookSecurity($body, $headers);
    
    $data = json_decode($body, true);
    $eventType = $headers['X-Webhook-Event'] ?? '';
    
    // Procesar evento
    processWebhookEvent($eventType, $data);
    
    http_response_code(200);
    echo json_encode(['success' => true]);
    
} catch (Exception $e) {
    http_response_code(401);
    echo json_encode(['error' => $e->getMessage()]);
}
?>

Python/Flask

python
import hashlib
import hmac
import json
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

def validate_webhook_security(body, headers):
    auth_token = os.environ.get('QURALO_AUTH_TOKEN')
    webhook_secret = os.environ.get('WEBHOOK_SECRET')
    
    # Validar token de autenticación
    auth_header = headers.get('Authorization', '')
    if auth_header.replace('Bearer ', '') != auth_token:
        raise ValueError('Invalid authentication token')
    
    # Validar firma HMAC
    signature = headers.get('X-Webhook-Signature', '')
    if not signature:
        raise ValueError('Missing webhook signature')
    
    expected_signature = hmac.new(
        webhook_secret.encode(),
        body.encode(),
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(expected_signature, signature):
        raise ValueError('Invalid webhook signature')

@app.route('/webhook', methods=['POST'])
def webhook():
    try:
        body = request.get_data(as_text=True)
        headers = dict(request.headers)
        
        validate_webhook_security(body, headers)
        
        data = json.loads(body)
        event_type = headers.get('X-Webhook-Event', '')
        
        # Procesar evento
        process_webhook_event(event_type, data)
        
        return jsonify({'success': True})
        
    except ValueError as e:
        return jsonify({'error': str(e)}), 401
    except Exception as e:
        return jsonify({'error': 'Internal server error'}), 500

Consideraciones Importantes

Orden de Validación

Siempre valide en este orden:

  1. Autenticación (Bearer token)
  2. Integridad (Firma HMAC)
  3. Estructura (Payload válido)
  4. Lógica de negocio (Procesar evento)

Raw Body vs Parsed JSON

⚠️ Crítico: Para verificar la firma HMAC, debe usar el cuerpo HTTP sin parsear (raw body), no el JSON parseado, ya que el parsing puede cambiar el formato y invalidar la firma.

javascript
// ❌ Incorrecto
const parsedBody = req.body; // Ya parseado por Express
const signature = crypto.createHmac('sha256', secret)
  .update(JSON.stringify(parsedBody)) // Puede diferir del original
  .digest('hex');

// ✅ Correcto
app.use(express.raw({ type: 'application/json' })); // Raw body
const signature = crypto.createHmac('sha256', secret)
  .update(req.body, 'utf8') // Body original
  .digest('hex');

Timing Attacks

Use comparaciones de tiempo constante para evitar timing attacks:

javascript
// ❌ Vulnerable a timing attacks
if (signature === expectedSignature) {
  // ...
}

// ✅ Seguro contra timing attacks
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
  // ...
}

Gestión de Credenciales

Variables de Entorno

bash
# .env
QURALO_AUTH_TOKEN=7mXMLcEwcnTBOJrjhbOJtw3KGrKhFMcrczEFJfSg
WEBHOOK_SECRET=Fg/vvfO7BUP5THm2dJz4ph/FJyzgDMBXM3DGjKkE

Rotación de Credenciales

  1. Programada: Rote credenciales cada 90 días
  2. Emergencia: Si sospecha compromiso
  3. Proceso: Use el dashboard para regenerar, actualice su sistema

Almacenamiento Seguro

  • ✅ Variables de entorno
  • ✅ Sistemas de gestión de secretos (AWS Secrets Manager, HashiCorp Vault)
  • ✅ Archivos de configuración cifrados
  • Nunca en el código fuente
  • Nunca en logs o bases de datos

Monitoreo de Seguridad

Eventos a Monitorear

  • 401 Unauthorized: Intentos de acceso no autorizado
  • Múltiples fallos: Posibles ataques de fuerza bruta
  • Firmas inválidas: Intentos de manipulación
  • IPs sospechosas: Accesos desde ubicaciones inusuales

Alertas Recomendadas

javascript
// Ejemplo de logging de seguridad
function logSecurityEvent(event, details) {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    type: 'security',
    event: event,
    details: details,
    ip: req.ip,
    userAgent: req.get('User-Agent')
  }));
}

// Uso
if (!isValidToken(token)) {
  logSecurityEvent('invalid_auth_token', { token: token.substring(0, 8) + '...' });
  return res.status(401).json({ error: 'Unauthorized' });
}

Siguiente Paso

Con la seguridad implementada, continúe con la documentación de eventos para entender los tipos de datos que recibirá.

Documentación de Quralo