n8n Automation

n8n securitate webhook: autentificare si validare

Petru Constantin
--12 min lectura
#n8n#webhooks#security#authentication#automation

Securitatea Webhook-urilor n8n: Autentificare, Validare si Rate Limiting

Webhook-urile sunt puncte de intrare puternice pentru workflow-urile de automatizare, dar prezinta si riscuri de securitate daca nu sunt protejate corespunzator. Acest ghid acopera masuri de securitate complete pentru webhook-urile n8n.

Metode de Autentificare

Verificarea Semnaturii HMAC

Cea mai robusta metoda pentru autentificarea webhook-urilor:

// Function node for HMAC verification
const crypto = require('crypto');
 
const secret = $env.WEBHOOK_SECRET;
const payload = JSON.stringify($json);
const signature = $request.headers['x-signature-256'];
 
if (!signature) {
  throw new Error('Missing signature header');
}
 
// Compute expected signature
const expectedSignature = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload, 'utf8')
  .digest('hex');
 
// Constant-time comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
);
 
if (!isValid) {
  throw new Error('Invalid signature');
}
 
return [{
  json: {
    ...$json,
    _verified: true,
    _timestamp: new Date().toISOString()
  }
}];

Autentificare prin API Key in Header

// Function node for API key validation
const apiKey = $request.headers['x-api-key'];
const validApiKeys = $env.VALID_API_KEYS.split(',');
 
if (!apiKey) {
  return [{
    json: {
      error: 'Missing API key',
      status: 401
    }
  }];
}
 
// Hash the provided key for comparison (if storing hashed keys)
const crypto = require('crypto');
const hashedKey = crypto
  .createHash('sha256')
  .update(apiKey)
  .digest('hex');
 
const isValid = validApiKeys.includes(hashedKey);
 
if (!isValid) {
  // Log failed attempt
  console.log(`Invalid API key attempt from ${$request.headers['x-forwarded-for']}`);
 
  return [{
    json: {
      error: 'Invalid API key',
      status: 401
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _authenticated: true,
    _keyId: hashedKey.substring(0, 8) // Partial hash for logging
  }
}];

Validarea OAuth 2.0 Bearer Token

// Function node for OAuth token validation
const authHeader = $request.headers['authorization'];
 
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  throw new Error('Missing or invalid authorization header');
}
 
const token = authHeader.split(' ')[1];
 
// Validate token with OAuth provider
const https = require('https');
 
const validateToken = () => {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'oauth.provider.com',
      path: '/oauth/introspect',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from($env.OAUTH_CLIENT_ID + ':' + $env.OAUTH_CLIENT_SECRET).toString('base64')}`
      }
    };
 
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(JSON.parse(data)));
    });
 
    req.on('error', reject);
    req.write(`token=${token}`);
    req.end();
  });
};
 
const tokenInfo = await validateToken();
 
if (!tokenInfo.active) {
  throw new Error('Token is invalid or expired');
}
 
// Check required scopes
const requiredScopes = ['webhook:write', 'data:read'];
const tokenScopes = tokenInfo.scope.split(' ');
const hasAllScopes = requiredScopes.every(s => tokenScopes.includes(s));
 
if (!hasAllScopes) {
  throw new Error('Insufficient permissions');
}
 
return [{
  json: {
    ...$json,
    _user: tokenInfo.sub,
    _scopes: tokenScopes
  }
}];

Validarea JWT Token

// Function node for JWT validation
const jwt = require('jsonwebtoken');
 
const authHeader = $request.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) {
  throw new Error('Missing authorization header');
}
 
const token = authHeader.split(' ')[1];
 
try {
  // Verify JWT with public key
  const decoded = jwt.verify(token, $env.JWT_PUBLIC_KEY, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'webhook-api'
  });
 
  // Check token expiration
  const now = Math.floor(Date.now() / 1000);
  if (decoded.exp < now) {
    throw new Error('Token expired');
  }
 
  // Check required claims
  if (!decoded.permissions?.includes('webhook:invoke')) {
    throw new Error('Missing required permission');
  }
 
  return [{
    json: {
      ...$json,
      _user: decoded.sub,
      _permissions: decoded.permissions,
      _tokenId: decoded.jti
    }
  }];
} catch (error) {
  throw new Error(`JWT validation failed: ${error.message}`);
}

Validarea Payload-ului

Validare Schema cu JSON Schema

// Function node for JSON schema validation
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
 
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
 
// Define expected schema
const schema = {
  type: 'object',
  required: ['event', 'data', 'timestamp'],
  properties: {
    event: {
      type: 'string',
      enum: ['order.created', 'order.updated', 'order.cancelled']
    },
    data: {
      type: 'object',
      required: ['orderId', 'customerId'],
      properties: {
        orderId: { type: 'string', pattern: '^ORD-[A-Z0-9]{8}$' },
        customerId: { type: 'string', format: 'uuid' },
        amount: { type: 'number', minimum: 0 },
        currency: { type: 'string', pattern: '^[A-Z]{3}$' },
        items: {
          type: 'array',
          minItems: 1,
          items: {
            type: 'object',
            required: ['sku', 'quantity'],
            properties: {
              sku: { type: 'string' },
              quantity: { type: 'integer', minimum: 1 },
              price: { type: 'number', minimum: 0 }
            }
          }
        }
      }
    },
    timestamp: { type: 'string', format: 'date-time' }
  },
  additionalProperties: false
};
 
const validate = ajv.compile(schema);
const valid = validate($json);
 
if (!valid) {
  const errors = validate.errors.map(e => ({
    path: e.instancePath,
    message: e.message,
    params: e.params
  }));
 
  return [{
    json: {
      valid: false,
      errors,
      status: 400
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _validated: true
  }
}];

Sanitizarea Datelor de Intrare

// Function node for input sanitization
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
 
function sanitizeValue(value, fieldType) {
  if (value === null || value === undefined) {
    return value;
  }
 
  switch (fieldType) {
    case 'string':
      // Remove HTML and dangerous characters
      return sanitizeHtml(String(value), {
        allowedTags: [],
        allowedAttributes: {}
      }).trim();
 
    case 'email':
      if (!validator.isEmail(String(value))) {
        throw new Error(`Invalid email: ${value}`);
      }
      return validator.normalizeEmail(String(value));
 
    case 'url':
      if (!validator.isURL(String(value), { protocols: ['https'] })) {
        throw new Error(`Invalid URL: ${value}`);
      }
      return value;
 
    case 'integer':
      const num = parseInt(value, 10);
      if (isNaN(num)) {
        throw new Error(`Invalid integer: ${value}`);
      }
      return num;
 
    case 'uuid':
      if (!validator.isUUID(String(value))) {
        throw new Error(`Invalid UUID: ${value}`);
      }
      return String(value).toLowerCase();
 
    default:
      return value;
  }
}
 
// Define field types for sanitization
const fieldTypes = {
  'data.customerId': 'uuid',
  'data.email': 'email',
  'data.website': 'url',
  'data.description': 'string',
  'data.quantity': 'integer'
};
 
function sanitizeObject(obj, prefix = '') {
  const sanitized = {};
 
  for (const [key, value] of Object.entries(obj)) {
    const fullPath = prefix ? `${prefix}.${key}` : key;
 
    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
      sanitized[key] = sanitizeObject(value, fullPath);
    } else if (Array.isArray(value)) {
      sanitized[key] = value.map((item, index) =>
        typeof item === 'object' ? sanitizeObject(item, `${fullPath}[${index}]`) : item
      );
    } else {
      const fieldType = fieldTypes[fullPath] || 'string';
      sanitized[key] = sanitizeValue(value, fieldType);
    }
  }
 
  return sanitized;
}
 
try {
  const sanitizedPayload = sanitizeObject($json);
  return [{ json: sanitizedPayload }];
} catch (error) {
  return [{
    json: {
      error: error.message,
      status: 400
    }
  }];
}

Rate Limiting

Rate Limiter cu Token Bucket

// Function node for rate limiting
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const clientId = $request.headers['x-client-id'] ||
                 $request.headers['x-forwarded-for'] ||
                 'anonymous';
 
const config = {
  bucketSize: 100,      // Max tokens
  refillRate: 10,       // Tokens per second
  requestCost: 1        // Tokens per request
};
 
const key = `ratelimit:${clientId}`;
 
// Lua script for atomic token bucket
const luaScript = `
  local key = KEYS[1]
  local now = tonumber(ARGV[1])
  local bucketSize = tonumber(ARGV[2])
  local refillRate = tonumber(ARGV[3])
  local requestCost = tonumber(ARGV[4])
  local ttl = tonumber(ARGV[5])
 
  local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
  local tokens = tonumber(bucket[1]) or bucketSize
  local lastRefill = tonumber(bucket[2]) or now
 
  -- Calculate token refill
  local elapsed = now - lastRefill
  local refill = elapsed * refillRate
  tokens = math.min(bucketSize, tokens + refill)
 
  -- Check if request can proceed
  if tokens >= requestCost then
    tokens = tokens - requestCost
    redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
    redis.call('EXPIRE', key, ttl)
    return {1, tokens, 0}  -- allowed, remaining, retryAfter
  else
    local retryAfter = (requestCost - tokens) / refillRate
    return {0, tokens, retryAfter}  -- denied, remaining, retryAfter
  end
`;
 
const now = Date.now() / 1000;
const result = await redis.eval(
  luaScript,
  1,
  key,
  now,
  config.bucketSize,
  config.refillRate,
  config.requestCost,
  3600  // TTL in seconds
);
 
const [allowed, remaining, retryAfter] = result;
 
if (!allowed) {
  return [{
    json: {
      error: 'Rate limit exceeded',
      status: 429,
      retryAfter: Math.ceil(retryAfter),
      headers: {
        'X-RateLimit-Limit': config.bucketSize,
        'X-RateLimit-Remaining': Math.floor(remaining),
        'X-RateLimit-Reset': Math.ceil(now + retryAfter),
        'Retry-After': Math.ceil(retryAfter)
      }
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _rateLimit: {
      remaining: Math.floor(remaining),
      limit: config.bucketSize
    }
  }
}];

Rate Limiter cu Sliding Window

// Function node for sliding window rate limit
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const clientId = $request.headers['x-api-key'] ||
                 $request.headers['x-forwarded-for'];
 
const windowMs = 60000;  // 1 minute window
const maxRequests = 60;  // 60 requests per minute
 
const key = `ratelimit:sliding:${clientId}`;
const now = Date.now();
const windowStart = now - windowMs;
 
// Use sorted set with timestamps
const multi = redis.multi();
 
// Remove old entries outside the window
multi.zremrangebyscore(key, '-inf', windowStart);
 
// Add current request
multi.zadd(key, now, `${now}-${Math.random()}`);
 
// Count requests in window
multi.zcard(key);
 
// Set expiry
multi.expire(key, Math.ceil(windowMs / 1000) + 1);
 
const results = await multi.exec();
const requestCount = results[2][1];
 
if (requestCount > maxRequests) {
  // Get oldest request timestamp to calculate reset time
  const oldestInWindow = await redis.zrange(key, 0, 0, 'WITHSCORES');
  const resetTime = oldestInWindow.length > 1
    ? parseInt(oldestInWindow[1]) + windowMs
    : now + windowMs;
 
  return [{
    json: {
      error: 'Rate limit exceeded',
      status: 429,
      limit: maxRequests,
      remaining: 0,
      resetAt: new Date(resetTime).toISOString()
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _rateLimit: {
      limit: maxRequests,
      remaining: maxRequests - requestCount,
      windowMs
    }
  }
}];

Filtrare IP

Whitelist/Blacklist IP

// Function node for IP filtering
const ipaddr = require('ipaddr.js');
 
// Get client IP
const clientIp = $request.headers['x-forwarded-for']?.split(',')[0].trim() ||
                 $request.headers['x-real-ip'] ||
                 $request.connection?.remoteAddress;
 
// Configuration
const config = {
  mode: 'whitelist',  // 'whitelist' or 'blacklist'
  whitelist: [
    '10.0.0.0/8',
    '172.16.0.0/12',
    '192.168.0.0/16',
    '203.0.113.50',        // Specific IP
    '2001:db8::/32'        // IPv6 range
  ],
  blacklist: [
    '0.0.0.0/8',
    '224.0.0.0/4',
    '169.254.0.0/16'
  ]
};
 
function isIpInRange(ip, ranges) {
  try {
    const addr = ipaddr.parse(ip);
 
    for (const range of ranges) {
      if (range.includes('/')) {
        // CIDR notation
        const [network, bits] = range.split('/');
        const networkAddr = ipaddr.parse(network);
 
        if (addr.kind() === networkAddr.kind()) {
          if (addr.match(networkAddr, parseInt(bits))) {
            return true;
          }
        }
      } else {
        // Single IP
        if (ipaddr.parse(range).toString() === addr.toString()) {
          return true;
        }
      }
    }
    return false;
  } catch (error) {
    console.error(`Invalid IP address: ${ip}`);
    return false;
  }
}
 
let allowed = false;
 
if (config.mode === 'whitelist') {
  allowed = isIpInRange(clientIp, config.whitelist);
} else {
  allowed = !isIpInRange(clientIp, config.blacklist);
}
 
if (!allowed) {
  // Log blocked attempt
  console.log(`Blocked request from IP: ${clientIp}`);
 
  return [{
    json: {
      error: 'Access denied',
      status: 403
    }
  }];
}
 
return [{
  json: {
    ...$json,
    _clientIp: clientIp,
    _ipVerified: true
  }
}];

Filtrare IP Geografica

// Function node for geo-based IP filtering
const clientIp = $request.headers['x-forwarded-for']?.split(',')[0].trim();
 
// Using a geolocation service
const https = require('https');
 
const geoLookup = (ip) => {
  return new Promise((resolve, reject) => {
    https.get(`https://ipapi.co/${ip}/json/`, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          reject(e);
        }
      });
    }).on('error', reject);
  });
};
 
const allowedCountries = ['US', 'CA', 'GB', 'DE', 'FR'];
const blockedRegions = ['CN', 'RU', 'KP'];
 
try {
  const geoData = await geoLookup(clientIp);
 
  if (blockedRegions.includes(geoData.country_code)) {
    return [{
      json: {
        error: 'Access denied from this region',
        status: 403
      }
    }];
  }
 
  // Optional: Require specific countries
  // if (!allowedCountries.includes(geoData.country_code)) {
  //   return [{ json: { error: 'Access denied', status: 403 } }];
  // }
 
  return [{
    json: {
      ...$json,
      _geo: {
        country: geoData.country_code,
        region: geoData.region,
        city: geoData.city
      }
    }
  }];
} catch (error) {
  // Allow on geo lookup failure (fail open) or deny (fail closed)
  console.error(`Geo lookup failed: ${error.message}`);
  return [{ json: $json }];  // Fail open
}

Prevenirea Reluarii Cererilor (Replay Prevention)

Validarea Nonce si Timestamp

// Function node for replay attack prevention
const Redis = require('ioredis');
const redis = new Redis($env.REDIS_URL);
 
const timestamp = $request.headers['x-timestamp'];
const nonce = $request.headers['x-nonce'];
 
// Configuration
const config = {
  maxTimestampSkew: 300,  // 5 minutes
  nonceExpirySeconds: 600  // 10 minutes
};
 
// Validate timestamp
if (!timestamp) {
  throw new Error('Missing timestamp header');
}
 
const requestTime = parseInt(timestamp);
const now = Math.floor(Date.now() / 1000);
const timeDiff = Math.abs(now - requestTime);
 
if (timeDiff > config.maxTimestampSkew) {
  return [{
    json: {
      error: 'Request timestamp too old or too far in future',
      status: 400
    }
  }];
}
 
// Validate nonce
if (!nonce) {
  throw new Error('Missing nonce header');
}
 
// Check if nonce was already used
const nonceKey = `nonce:${nonce}`;
const nonceExists = await redis.exists(nonceKey);
 
if (nonceExists) {
  return [{
    json: {
      error: 'Duplicate request detected',
      status: 409
    }
  }];
}
 
// Store nonce to prevent replay
await redis.setex(nonceKey, config.nonceExpirySeconds, '1');
 
return [{
  json: {
    ...$json,
    _requestId: nonce,
    _timestamp: requestTime
  }
}];

Pipeline Complet de Securitate

Combinarea Tuturor Masurilor de Securitate

// Main webhook security pipeline - Function node
const securityPipeline = {
  async validateIp(request) {
    // IP filtering logic
    const clientIp = request.headers['x-forwarded-for']?.split(',')[0].trim();
    // ... IP validation
    return { valid: true, clientIp };
  },
 
  async validateAuth(request) {
    // Authentication logic
    const signature = request.headers['x-signature-256'];
    // ... HMAC validation
    return { valid: true };
  },
 
  async validateRateLimit(clientId) {
    // Rate limiting logic
    // ... Token bucket or sliding window
    return { allowed: true, remaining: 50 };
  },
 
  async validatePayload(payload, schema) {
    // Schema validation
    // ... JSON Schema validation
    return { valid: true };
  },
 
  async validateReplay(request) {
    // Replay prevention
    // ... Nonce validation
    return { valid: true };
  }
};
 
// Execute pipeline
const results = {
  ip: null,
  auth: null,
  rateLimit: null,
  payload: null,
  replay: null
};
 
try {
  // Step 1: IP Filtering
  results.ip = await securityPipeline.validateIp($request);
  if (!results.ip.valid) {
    throw new Error('IP validation failed');
  }
 
  // Step 2: Authentication
  results.auth = await securityPipeline.validateAuth($request);
  if (!results.auth.valid) {
    throw new Error('Authentication failed');
  }
 
  // Step 3: Rate Limiting
  const clientId = results.auth.clientId || results.ip.clientIp;
  results.rateLimit = await securityPipeline.validateRateLimit(clientId);
  if (!results.rateLimit.allowed) {
    throw new Error('Rate limit exceeded');
  }
 
  // Step 4: Replay Prevention
  results.replay = await securityPipeline.validateReplay($request);
  if (!results.replay.valid) {
    throw new Error('Replay attack detected');
  }
 
  // Step 5: Payload Validation
  results.payload = await securityPipeline.validatePayload($json, schema);
  if (!results.payload.valid) {
    throw new Error('Invalid payload');
  }
 
  // All checks passed
  return [{
    json: {
      ...$json,
      _security: {
        clientIp: results.ip.clientIp,
        authenticated: true,
        rateLimitRemaining: results.rateLimit.remaining,
        validatedAt: new Date().toISOString()
      }
    }
  }];
 
} catch (error) {
  // Log security event
  console.error(`Security check failed: ${error.message}`, {
    ip: results.ip?.clientIp,
    step: Object.keys(results).find(k => results[k] === null) || 'unknown'
  });
 
  return [{
    json: {
      error: error.message,
      status: error.message.includes('Rate limit') ? 429 : 403
    }
  }];
}

Rezumat Bune Practici

  1. Foloseste intotdeauna HTTPS - Nu expune niciodata webhook-uri prin HTTP
  2. Implementeaza verificarea semnaturii - HMAC este standardul de referinta
  3. Valideaza toate datele de intrare - Nu ai incredere in datele primite
  4. Aplica rate limiting agresiv - Protejeaza impotriva abuzurilor
  5. Logheaza evenimentele de securitate - Permite analiza forensica
  6. Foloseste credentiale cu durata scurta - Roteste secretele regulat
  7. Implementeaza protectie la replay - Foloseste nonce-uri si timestamp-uri
  8. Filtreaza dupa IP cand e posibil - Reduce suprafata de atac

Urmand aceste tipare vei imbunatati semnificativ postura de securitate a integrarilor tale n8n cu webhook-uri.


Sistemul tau AI e conform cu EU AI Act? Evaluare gratuita de risc - afla in 2 minute →

Ai nevoie de ajutor cu conformitatea EU AI Act sau securitatea AI?

Programeaza o consultatie gratuita de 30 de minute. Fara obligatii.

Programeaza un Apel

Weekly AI Security & Automation Digest

Get the latest on AI Security, workflow automation, secure integrations, and custom platform development delivered weekly.

No spam. Unsubscribe anytime.