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
- Foloseste intotdeauna HTTPS - Nu expune niciodata webhook-uri prin HTTP
- Implementeaza verificarea semnaturii - HMAC este standardul de referinta
- Valideaza toate datele de intrare - Nu ai incredere in datele primite
- Aplica rate limiting agresiv - Protejeaza impotriva abuzurilor
- Logheaza evenimentele de securitate - Permite analiza forensica
- Foloseste credentiale cu durata scurta - Roteste secretele regulat
- Implementeaza protectie la replay - Foloseste nonce-uri si timestamp-uri
- 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 →