Seguridad y Verificación de Firma
Métodos de verificación de firma para webhooks y protección contra replay attacks
Seguridad y Verificación de Firma
Docutray proporciona dos métodos de verificación de firma para adaptarse a diferentes arquitecturas:
Método 1: Firma basada en body (Tradicional)
Valida el contenido completo del payload. Ideal para implementaciones tradicionales:
const crypto = require('crypto');
function verifyWebhookBody(bodyString, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(bodyString)
.digest('hex');
return `sha256=${expectedSignature}` === signature;
}
// Uso en Express.js
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-docutray-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifyWebhookBody(req.body.toString(), signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Procesar webhook
const payload = JSON.parse(req.body);
// ...
});Método 2: Firma de autenticación (Compatible con Lambda Authorizers)
Valida usando solo metadatos en headers, sin acceso al body. Ideal para AWS Lambda Authorizers, Azure Functions, o Google Cloud Functions:
const crypto = require('crypto');
function verifyWebhookAuth(headers, webhookUrl, secret) {
const authSignature = headers['x-docutray-auth-signature'];
const timestamp = headers['x-docutray-timestamp'];
const requestId = headers['x-docutray-request-id'];
const eventType = headers['x-docutray-event'];
// Validar timestamp (ventana de 5 minutos)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false; // Timestamp expirado
}
// Calcular firma esperada
const authPayload = `${requestId}|${timestamp}|${webhookUrl}|${eventType}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(authPayload)
.digest('hex');
return `sha256=${expectedSignature}` === authSignature;
}Ejemplo completo: AWS Lambda Authorizer
// Lambda Authorizer para AWS API Gateway
exports.handler = async (event) => {
const crypto = require('crypto');
try {
// Extraer headers
const authSignature = event.headers['x-docutray-auth-signature'];
const timestamp = parseInt(event.headers['x-docutray-timestamp']);
const requestId = event.headers['x-docutray-request-id'];
const eventType = event.headers['x-docutray-event'];
const webhookUrl = `https://${event.headers.host}${event.path}`;
// Validar presencia de headers
if (!authSignature || !timestamp || !requestId || !eventType) {
return generatePolicy('user', 'Deny', event.methodArn);
}
// Validar timestamp (ventana de 5 minutos)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
console.log('Webhook timestamp expired');
return generatePolicy('user', 'Deny', event.methodArn);
}
// Recalcular firma esperada
const secret = process.env.DOCUTRAY_WEBHOOK_SECRET;
const authPayload = `${requestId}|${timestamp}|${webhookUrl}|${eventType}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(authPayload)
.digest('hex');
// Validar firma
if (`sha256=${expectedSignature}` !== authSignature) {
console.log('Signature verification failed');
return generatePolicy('user', 'Deny', event.methodArn);
}
// Firma válida - permitir request
return generatePolicy('user', 'Allow', event.methodArn);
} catch (error) {
console.error('Error in authorizer:', error);
return generatePolicy('user', 'Deny', event.methodArn);
}
};
function generatePolicy(principalId, effect, resource) {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource
}]
}
};
}Ejemplo: Python para AWS Lambda Authorizer
import hmac
import hashlib
import time
import os
def lambda_handler(event, context):
try:
# Extraer headers
headers = {k.lower(): v for k, v in event['headers'].items()}
auth_signature = headers.get('x-docutray-auth-signature')
timestamp = int(headers.get('x-docutray-timestamp', 0))
request_id = headers.get('x-docutray-request-id')
event_type = headers.get('x-docutray-event')
webhook_url = f"https://{headers['host']}{event['path']}"
# Validar presencia de headers
if not all([auth_signature, timestamp, request_id, event_type]):
return generate_policy('user', 'Deny', event['methodArn'])
# Validar timestamp (ventana de 5 minutos)
now = int(time.time())
if abs(now - timestamp) > 300:
print('Webhook timestamp expired')
return generate_policy('user', 'Deny', event['methodArn'])
# Recalcular firma esperada
secret = os.environ['DOCUTRAY_WEBHOOK_SECRET']
auth_payload = f"{request_id}|{timestamp}|{webhook_url}|{event_type}"
expected_signature = hmac.new(
secret.encode(),
auth_payload.encode(),
hashlib.sha256
).hexdigest()
# Validar firma
if f"sha256={expected_signature}" != auth_signature:
print('Signature verification failed')
return generate_policy('user', 'Deny', event['methodArn'])
# Firma válida - permitir request
return generate_policy('user', 'Allow', event['methodArn'])
except Exception as error:
print(f'Error in authorizer: {error}')
return generate_policy('user', 'Deny', event['methodArn'])
def generate_policy(principal_id, effect, resource):
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}]
}
}Protección contra replay attacks
El sistema incluye protección automática contra replay attacks mediante:
- Timestamp único: Cada envío incluye
X-Docutray-Timestamp(Unix seconds) - Request ID único: Cada envío tiene un
X-Docutray-Request-Id(UUID) único
Recomendaciones de seguridad
- Validar timestamp: Rechazar requests con timestamps fuera de una ventana razonable (recomendado: 5 minutos)
- Cachear request-id: Guardar temporalmente los request-id procesados para detectar duplicados
- Usar HTTPS: Siempre usar endpoints HTTPS para prevenir interceptación
// Ejemplo de cache de request-id con Redis
const redis = require('redis');
const client = redis.createClient();
async function isReplayAttack(requestId) {
const key = `webhook:${requestId}`;
const exists = await client.exists(key);
if (exists) {
return true; // Request-id ya procesado
}
// Marcar como procesado (TTL de 10 minutos)
await client.setex(key, 600, '1');
return false;
}