diff --git a/app/api/webhooks/autotask/route.ts b/app/api/webhooks/autotask/route.ts index e782e4a..9949c68 100644 --- a/app/api/webhooks/autotask/route.ts +++ b/app/api/webhooks/autotask/route.ts @@ -13,10 +13,16 @@ import { AutotaskWebhookPayload } from '@/lib/types/webhook'; */ export async function POST(request: NextRequest) { try { + // Extract source IP and user agent for logging + const sourceIp = request.headers.get('x-forwarded-for')?.split(',')[0].trim() + || request.headers.get('x-real-ip') + || 'unknown'; + const userAgent = request.headers.get('user-agent') || 'unknown'; + // Parse webhook payload const payload: AutotaskWebhookPayload = await request.json(); - console.log(`[WEBHOOK API] Received ${payload.eventType} event for ${payload.entityType} #${payload.entityId}`); + console.log(`[WEBHOOK API] Received ${payload.eventType} event for ${payload.entityType} #${payload.entityId} from IP: ${sourceIp}`); // Validate required fields if (!payload.eventId || !payload.eventType || !payload.entityType || !payload.entityId) { @@ -29,7 +35,7 @@ export async function POST(request: NextRequest) { // Process the webhook asynchronously // Note: We return 200 immediately to Autotask, then process in background // This prevents timeouts for slow processing - const result = await webhookService.processWebhook(payload); + const result = await webhookService.processWebhook(payload, sourceIp, userAgent); if (result.success) { return NextResponse.json({ diff --git a/app/api/webhooks/ips/route.ts b/app/api/webhooks/ips/route.ts new file mode 100644 index 0000000..d07b029 --- /dev/null +++ b/app/api/webhooks/ips/route.ts @@ -0,0 +1,77 @@ +/** + * Webhook Source IPs API + * View unique source IPs from webhook logs for whitelisting + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +/** + * GET /api/webhooks/ips + * Get unique source IPs from webhook logs + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const hours = parseInt(searchParams.get('hours') || '168'); // Default 7 days + const entityType = searchParams.get('entityType') || undefined; + + // Build query to get unique IPs with counts + let query = ` + SELECT + source_ip, + user_agent, + COUNT(*) as request_count, + MIN(received_at) as first_seen, + MAX(received_at) as last_seen, + COUNT(*) FILTER (WHERE status = 'processed') as successful_count, + COUNT(*) FILTER (WHERE status = 'failed') as failed_count, + array_agg(DISTINCT entity_type) as entity_types + FROM webhook_logs + WHERE received_at >= NOW() - INTERVAL '${hours} hours' + AND source_ip IS NOT NULL + `; + + const params: any[] = []; + + if (entityType) { + query += ` AND entity_type = $1`; + params.push(entityType); + } + + query += ` + GROUP BY source_ip, user_agent + ORDER BY request_count DESC + `; + + const result = await postgresClient.query(query, params); + + // Format the results + const ips = result.rows.map((row: any) => ({ + ip: row.source_ip, + userAgent: row.user_agent, + requestCount: parseInt(row.request_count), + successfulCount: parseInt(row.successful_count), + failedCount: parseInt(row.failed_count), + firstSeen: row.first_seen, + lastSeen: row.last_seen, + entityTypes: row.entity_types, + })); + + return NextResponse.json({ + success: true, + ips, + count: ips.length, + period: `${hours} hours`, + }); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[WEBHOOK IPS API] Error fetching IPs:', errorMessage); + + return NextResponse.json( + { error: 'Failed to fetch webhook IPs', details: errorMessage }, + { status: 500 } + ); + } +} diff --git a/docs/WEBHOOK_IP_WHITELISTING.md b/docs/WEBHOOK_IP_WHITELISTING.md new file mode 100644 index 0000000..b2c54eb --- /dev/null +++ b/docs/WEBHOOK_IP_WHITELISTING.md @@ -0,0 +1,540 @@ +# Webhook IP Whitelisting Guide + +## Overview + +This guide explains how to identify Autotask's webhook source IPs and configure IP whitelisting to secure your webhook endpoint. + +--- + +## Why IP Whitelisting? + +**Benefits:** +- ✅ Only allow webhooks from Autotask's servers +- ✅ Block unauthorized webhook attempts +- ✅ Additional security layer beyond HTTPS +- ✅ Prevent abuse and DDoS attacks + +**When to Use:** +- Production environments +- High-security requirements +- After identifying Autotask's IP ranges + +--- + +## Step 1: Identify Autotask IPs + +### **Method 1: View Webhook Logs (Recommended)** + +After receiving a few webhooks from Autotask, check the logged IPs: + +**Via API:** +```bash +curl https://your-domain.com/api/webhooks/ips?hours=168 +``` + +Response: +```json +{ + "success": true, + "ips": [ + { + "ip": "52.1.2.3", + "userAgent": "Autotask-Webhook/1.0", + "requestCount": 150, + "successfulCount": 148, + "failedCount": 2, + "firstSeen": "2026-01-20T10:00:00Z", + "lastSeen": "2026-01-24T15:00:00Z", + "entityTypes": ["Tickets", "Companies", "Tasks"] + }, + { + "ip": "52.5.6.7", + "userAgent": "Autotask-Webhook/1.0", + "requestCount": 75, + "successfulCount": 75, + "failedCount": 0, + "firstSeen": "2026-01-22T08:00:00Z", + "lastSeen": "2026-01-24T14:30:00Z", + "entityTypes": ["TimeEntries", "Projects"] + } + ], + "count": 2, + "period": "168 hours" +} +``` + +**Via Database:** +```sql +-- Get unique IPs with request counts +SELECT + source_ip, + user_agent, + COUNT(*) as request_count, + MIN(received_at) as first_seen, + MAX(received_at) as last_seen, + COUNT(*) FILTER (WHERE status = 'processed') as successful, + COUNT(*) FILTER (WHERE status = 'failed') as failed +FROM webhook_logs +WHERE received_at >= NOW() - INTERVAL '7 days' + AND source_ip IS NOT NULL +GROUP BY source_ip, user_agent +ORDER BY request_count DESC; +``` + +**Identify Autotask IPs:** +- Look for IPs with `Autotask-Webhook` user agent +- High request counts (if you have active webhooks) +- Multiple entity types +- Consistent activity pattern + +### **Method 2: Contact Autotask Support** + +Request official IP ranges from Autotask: +- Open support ticket +- Ask for "Webhook source IP ranges" +- They may provide CIDR blocks (e.g., `52.1.0.0/16`) + +### **Method 3: Check Autotask Documentation** + +Some Autotask zones publish their IP ranges: +- Check Autotask developer documentation +- Look for "Webhook IP ranges" or "API IP addresses" +- May vary by zone (ww1, ww5, ww15, etc.) + +--- + +## Step 2: Configure IP Whitelist + +### **Option A: Nginx** + +Edit your nginx configuration: + +```nginx +server { + listen 443 ssl http2; + server_name webhooks.yourdomain.com; + + # ... SSL configuration ... + + location = /api/webhooks/autotask { + # IP Whitelist - Autotask webhook IPs + allow 52.1.2.3; # Autotask IP 1 + allow 52.5.6.7; # Autotask IP 2 + allow 52.10.0.0/16; # Autotask IP range (CIDR) + deny all; # Block everything else + + # Rate limiting + limit_req zone=webhook_limit burst=20 nodelay; + + # Proxy to Pulse app + proxy_pass http://pulse_app; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +**Test and reload:** +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +### **Option B: Pangolin** + +Update your Pangolin configuration: + +```yaml +# /etc/pangolin/config.yml + +name: pulse-webhooks + +ingress: + - hostname: webhooks.yourdomain.com + path: /api/webhooks/autotask + service: http://localhost:3100 + +security: + # IP Whitelist + ipWhitelist: + - "52.1.2.3" # Autotask IP 1 + - "52.5.6.7" # Autotask IP 2 + - "52.10.0.0/16" # Autotask IP range + + # Rate limiting + rateLimit: + enabled: true + requestsPerMinute: 100 + burstSize: 20 +``` + +**Restart tunnel:** +```bash +sudo systemctl restart pangolin-tunnel +``` + +### **Option C: Cloudflare Tunnel** + +Use Cloudflare Access rules: + +1. **Go to Cloudflare Dashboard** → Zero Trust → Access → Applications +2. **Create Application:** + - Name: `Pulse Webhooks` + - Domain: `webhooks.yourdomain.com` + - Path: `/api/webhooks/autotask` +3. **Add Policy:** + - Name: `Autotask IPs Only` + - Action: `Allow` + - Rule: `IP ranges` + - Values: `52.1.2.3`, `52.5.6.7`, `52.10.0.0/16` +4. **Save** + +### **Option D: Firewall (UFW)** + +If using UFW on the server: + +```bash +# Allow specific IPs to port 443 +sudo ufw allow from 52.1.2.3 to any port 443 +sudo ufw allow from 52.5.6.7 to any port 443 +sudo ufw allow from 52.10.0.0/16 to any port 443 + +# Deny all other traffic to port 443 (if not already blocked) +# Be careful with this - ensure you have other access methods! +# sudo ufw deny 443 + +# Check rules +sudo ufw status numbered +``` + +**Warning:** Be careful with firewall rules. Ensure you don't lock yourself out! + +--- + +## Step 3: Test IP Whitelist + +### **1. Test from Allowed IP (Autotask)** + +Trigger a webhook from Autotask: +- Create a test ticket or company +- Check webhook logs to verify it was received + +```bash +# Check recent webhooks +curl https://your-domain.com/api/webhooks/logs?limit=5 +``` + +### **2. Test from Blocked IP** + +From a different IP (your computer): + +```bash +curl -X POST https://webhooks.yourdomain.com/api/webhooks/autotask \ + -H "Content-Type: application/json" \ + -d '{"eventId":"test","eventType":"create","entityType":"Tickets","entityId":1}' +``` + +**Expected result:** +- **Nginx:** `403 Forbidden` +- **Pangolin/Cloudflare:** Connection refused or 403 +- **Firewall:** Connection timeout + +### **3. Verify Logs** + +Check that only Autotask IPs are getting through: + +```sql +-- Recent webhook IPs +SELECT source_ip, COUNT(*) as count +FROM webhook_logs +WHERE received_at >= NOW() - INTERVAL '1 hour' +GROUP BY source_ip; +``` + +Should only show Autotask IPs. + +--- + +## Monitoring IP Changes + +### **Set Up Alerts for New IPs** + +Create a monitoring query to detect new source IPs: + +```sql +-- Find IPs seen in last 24 hours that weren't seen before +SELECT DISTINCT wl.source_ip, wl.user_agent, MIN(wl.received_at) as first_seen +FROM webhook_logs wl +WHERE wl.received_at >= NOW() - INTERVAL '24 hours' + AND wl.source_ip NOT IN ( + SELECT DISTINCT source_ip + FROM webhook_logs + WHERE received_at < NOW() - INTERVAL '24 hours' + AND received_at >= NOW() - INTERVAL '30 days' + ) +GROUP BY wl.source_ip, wl.user_agent; +``` + +### **Regular IP Audit** + +Run weekly to check for IP changes: + +```bash +# Get IPs from last 7 days +curl https://your-domain.com/api/webhooks/ips?hours=168 > webhook-ips-$(date +%Y%m%d).json + +# Compare with previous week +diff webhook-ips-20260117.json webhook-ips-20260124.json +``` + +If new IPs appear: +1. Verify they're legitimate Autotask IPs +2. Check user agent matches `Autotask-Webhook` +3. Update whitelist configuration +4. Reload/restart proxy or tunnel + +--- + +## Troubleshooting + +### **Webhooks Stopped Working After Whitelisting** + +**Check 1: Verify Autotask IP** +```sql +-- Check recent failed webhooks +SELECT source_ip, user_agent, error_message, received_at +FROM webhook_logs +WHERE status = 'failed' + AND received_at >= NOW() - INTERVAL '1 hour' +ORDER BY received_at DESC; +``` + +If you see legitimate Autotask IPs being blocked, add them to whitelist. + +**Check 2: Proxy Logs** + +**Nginx:** +```bash +tail -f /var/log/nginx/pulse-webhooks-error.log | grep 403 +``` + +**Pangolin:** +```bash +sudo journalctl -u pangolin-tunnel -f | grep denied +``` + +**Check 3: Test Without Whitelist** + +Temporarily disable IP whitelist to confirm that's the issue: + +**Nginx:** Comment out `allow`/`deny` lines and reload +**Pangolin:** Remove `ipWhitelist` section and restart + +If webhooks work without whitelist, the issue is IP configuration. + +### **Autotask Changed IPs** + +Autotask may change their webhook source IPs: + +1. **Check webhook logs** for new IPs +2. **Verify** they're legitimate (user agent, request pattern) +3. **Update whitelist** with new IPs +4. **Keep old IPs** for a few days (in case of rollback) +5. **Remove old IPs** after confirming they're no longer used + +### **Getting Blocked Yourself** + +If you accidentally block legitimate traffic: + +**Quick Fix (Nginx):** +```bash +# Edit config to remove IP whitelist +sudo nano /etc/nginx/sites-available/pulse-webhooks + +# Comment out the deny rules +# deny all; + +# Reload +sudo systemctl reload nginx +``` + +**Quick Fix (Pangolin):** +```bash +# Edit config +sudo nano /etc/pangolin/config.yml + +# Remove or comment ipWhitelist section + +# Restart +sudo systemctl restart pangolin-tunnel +``` + +--- + +## Best Practices + +### **1. Use CIDR Blocks When Possible** + +Instead of individual IPs: +```nginx +# Better +allow 52.10.0.0/16; + +# Less flexible +allow 52.10.1.1; +allow 52.10.1.2; +allow 52.10.1.3; +``` + +### **2. Document Your Whitelist** + +Keep a record of: +- Which IPs are whitelisted +- When they were added +- Source of information (Autotask support, logs, etc.) +- Last verified date + +Example: +```yaml +# IP Whitelist Documentation +# Last updated: 2026-01-24 + +ipWhitelist: + - "52.1.2.3" # Added: 2026-01-20, Source: Webhook logs + - "52.5.6.7" # Added: 2026-01-22, Source: Webhook logs + - "52.10.0.0/16" # Added: 2026-01-24, Source: Autotask support ticket #12345 +``` + +### **3. Monitor Regularly** + +- Weekly: Check for new IPs in logs +- Monthly: Audit whitelist against active IPs +- After Autotask updates: Verify webhooks still work + +### **4. Combine with Other Security** + +IP whitelisting is one layer. Also use: +- ✅ HTTPS/TLS encryption +- ✅ Rate limiting +- ✅ Path-based access control +- ✅ Request validation +- ✅ Monitoring and alerting + +### **5. Have a Rollback Plan** + +Keep configuration backups: +```bash +# Backup before changes +sudo cp /etc/nginx/sites-available/pulse-webhooks \ + /etc/nginx/sites-available/pulse-webhooks.backup-$(date +%Y%m%d) + +# Or for Pangolin +sudo cp /etc/pangolin/config.yml \ + /etc/pangolin/config.yml.backup-$(date +%Y%m%d) +``` + +--- + +## API Reference + +### **GET /api/webhooks/ips** + +Get unique source IPs from webhook logs. + +**Parameters:** +- `hours` (optional): Number of hours to look back (default: 168 = 7 days) +- `entityType` (optional): Filter by entity type + +**Example:** +```bash +# Last 7 days +curl https://your-domain.com/api/webhooks/ips?hours=168 + +# Last 24 hours, tickets only +curl https://your-domain.com/api/webhooks/ips?hours=24&entityType=Tickets +``` + +**Response:** +```json +{ + "success": true, + "ips": [ + { + "ip": "52.1.2.3", + "userAgent": "Autotask-Webhook/1.0", + "requestCount": 150, + "successfulCount": 148, + "failedCount": 2, + "firstSeen": "2026-01-20T10:00:00Z", + "lastSeen": "2026-01-24T15:00:00Z", + "entityTypes": ["Tickets", "Companies"] + } + ], + "count": 1, + "period": "168 hours" +} +``` + +--- + +## Database Queries + +### **Get All Unique IPs** +```sql +SELECT DISTINCT source_ip, user_agent +FROM webhook_logs +WHERE source_ip IS NOT NULL +ORDER BY source_ip; +``` + +### **Get IP Activity Summary** +```sql +SELECT + source_ip, + COUNT(*) as total_requests, + COUNT(DISTINCT entity_type) as entity_types_count, + MIN(received_at) as first_seen, + MAX(received_at) as last_seen, + ROUND(AVG(processing_time_ms), 2) as avg_processing_ms +FROM webhook_logs +WHERE source_ip IS NOT NULL +GROUP BY source_ip +ORDER BY total_requests DESC; +``` + +### **Detect Suspicious IPs** +```sql +-- IPs with high failure rates +SELECT + source_ip, + user_agent, + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'failed') as failed, + ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'failed') / COUNT(*), 2) as failure_rate +FROM webhook_logs +WHERE source_ip IS NOT NULL + AND received_at >= NOW() - INTERVAL '24 hours' +GROUP BY source_ip, user_agent +HAVING COUNT(*) FILTER (WHERE status = 'failed') > 5 +ORDER BY failure_rate DESC; +``` + +--- + +## Summary + +**Steps to Enable IP Whitelisting:** + +1. ✅ **Run migration** to add IP logging columns +2. ✅ **Deploy updated code** with IP capture +3. ✅ **Receive webhooks** from Autotask (let it run for a day) +4. ✅ **Identify Autotask IPs** via `/api/webhooks/ips` endpoint +5. ✅ **Configure whitelist** in your proxy/tunnel +6. ✅ **Test** that webhooks still work +7. ✅ **Monitor** for new IPs regularly + +**Result:** +- Only Autotask's IPs can send webhooks +- Unauthorized requests are blocked +- Additional security layer for your webhook endpoint +- Logged IP data for audit and troubleshooting diff --git a/lib/services/webhook-service.ts b/lib/services/webhook-service.ts index 634bf04..f1af79b 100644 --- a/lib/services/webhook-service.ts +++ b/lib/services/webhook-service.ts @@ -13,12 +13,12 @@ export class WebhookService { /** * Process an incoming webhook from Autotask */ - async processWebhook(payload: AutotaskWebhookPayload): Promise { + async processWebhook(payload: AutotaskWebhookPayload, sourceIp?: string, userAgent?: string): Promise { const startTime = Date.now(); try { // Log the webhook event - await this.logWebhookEvent(payload, 'pending'); + await this.logWebhookEvent(payload, 'pending', sourceIp, userAgent); console.log(`[WEBHOOK] Processing ${payload.eventType} event for ${payload.entityType} #${payload.entityId}`); @@ -169,10 +169,10 @@ export class WebhookService { /** * Log webhook event to database */ - private async logWebhookEvent(payload: AutotaskWebhookPayload, status: 'pending' | 'processed' | 'failed'): Promise { + private async logWebhookEvent(payload: AutotaskWebhookPayload, status: 'pending' | 'processed' | 'failed', sourceIp?: string, userAgent?: string): Promise { const query = ` - INSERT INTO webhook_logs (event_id, entity_type, entity_id, event_type, status, payload) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO webhook_logs (event_id, entity_type, entity_id, event_type, status, source_ip, user_agent, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (event_id) DO NOTHING `; @@ -182,6 +182,8 @@ export class WebhookService { payload.entityId, payload.eventType, status, + sourceIp || null, + userAgent || null, JSON.stringify(payload), ]); } diff --git a/lib/types/webhook.ts b/lib/types/webhook.ts index 6fa3f5a..75b3b61 100644 --- a/lib/types/webhook.ts +++ b/lib/types/webhook.ts @@ -90,6 +90,8 @@ export interface WebhookLog { event_type: WebhookEventType; status: 'pending' | 'processed' | 'failed'; error_message?: string; + source_ip?: string; + user_agent?: string; received_at: Date; processed_at?: Date; processing_time_ms?: number; diff --git a/migrations/004_webhook_support.sql b/migrations/004_webhook_support.sql index 5eae3df..5fbb917 100644 --- a/migrations/004_webhook_support.sql +++ b/migrations/004_webhook_support.sql @@ -10,6 +10,8 @@ CREATE TABLE IF NOT EXISTS webhook_logs ( event_type VARCHAR(50) NOT NULL, -- create, update, delete status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, processed, failed error_message TEXT, + source_ip VARCHAR(45), -- IPv4 or IPv6 address + user_agent TEXT, -- User agent string from request received_at TIMESTAMP NOT NULL DEFAULT NOW(), processed_at TIMESTAMP, processing_time_ms INTEGER, @@ -36,6 +38,7 @@ CREATE INDEX IF NOT EXISTS idx_webhook_logs_entity ON webhook_logs(entity_type, CREATE INDEX IF NOT EXISTS idx_webhook_logs_status ON webhook_logs(status); CREATE INDEX IF NOT EXISTS idx_webhook_logs_received_at ON webhook_logs(received_at DESC); CREATE INDEX IF NOT EXISTS idx_webhook_logs_entity_type ON webhook_logs(entity_type); +CREATE INDEX IF NOT EXISTS idx_webhook_logs_source_ip ON webhook_logs(source_ip); -- Indexes for webhook_configs CREATE INDEX IF NOT EXISTS idx_webhook_configs_entity_type ON webhook_configs(entity_type); diff --git a/migrations/005_add_webhook_ip_logging.sql b/migrations/005_add_webhook_ip_logging.sql new file mode 100644 index 0000000..16f7d2e --- /dev/null +++ b/migrations/005_add_webhook_ip_logging.sql @@ -0,0 +1,35 @@ +-- Migration: Add IP logging to webhook_logs +-- Description: Add source_ip and user_agent columns for IP whitelisting + +-- Add source_ip column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'webhook_logs' AND column_name = 'source_ip' + ) THEN + ALTER TABLE webhook_logs ADD COLUMN source_ip VARCHAR(45); + COMMENT ON COLUMN webhook_logs.source_ip IS 'Source IP address of webhook request (IPv4 or IPv6)'; + END IF; +END $$; + +-- Add user_agent column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'webhook_logs' AND column_name = 'user_agent' + ) THEN + ALTER TABLE webhook_logs ADD COLUMN user_agent TEXT; + COMMENT ON COLUMN webhook_logs.user_agent IS 'User agent string from webhook request'; + END IF; +END $$; + +-- Add index on source_ip for efficient IP-based queries +CREATE INDEX IF NOT EXISTS idx_webhook_logs_source_ip ON webhook_logs(source_ip); + +-- Verify columns were added +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'webhook_logs' + AND column_name IN ('source_ip', 'user_agent');