Skip to content

SSRF Protection

Server-Side Request Forgery (SSRF) is a security vulnerability where an attacker can trick a server into making requests to internal resources. Fluxbase includes built-in SSRF protection for webhooks and external HTTP requests.

SSRF (Server-Side Request Forgery) allows attackers to:

  • Access internal services (databases, admin panels, metadata services)
  • Bypass firewalls and access controls
  • Scan internal networks
  • Access cloud provider metadata services (e.g., AWS IMDSv1)
  • Port scan internal infrastructure

Common Attack Scenarios:

// Attacker creates webhook with malicious URL
const webhook = await client.webhook.create({
url: 'http://localhost:6379', // Redis database
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})
// Or access cloud metadata
const webhook = await client.webhook.create({
url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials', // AWS metadata
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})

Fluxbase automatically blocks webhook requests to internal resources:

IP RangeDescriptionRisk
10.0.0.0/8Private networkInternal services
172.16.0.0/12Private networkInternal services
192.168.0.0/16Private networkInternal services
127.0.0.0/8LoopbackLocal services
169.254.0.0/16Link-localAWS metadata endpoint
::1/128IPv6 loopbackLocal services
fc00::/7IPv6 unique localInternal services
fe80::/10IPv6 link-localLocal services
HostnamePurposeProtected Service
localhostLocal machineLocal services
metadata.google.internalGCP metadataCloud credentials
metadataAWS metadata (EC2)Cloud credentials
instance-dataAWS metadataCloud credentials
kubernetes.default.svcKubernetes APICluster services
kubernetes.defaultKubernetes APICluster services

Only http:// and https:// schemes are allowed:

# ❌ BLOCKED: File protocol
url: "file:///etc/passwd"
# ❌ BLOCKED: FTP protocol
url: "ftp://internal-server.com"
# ❌ BLOCKED: gopher protocol
url: "gopher://internal-host:70"
# ✅ ALLOWED: HTTP/HTTPS only
url: "https://api.example.com/webhook"

Default: SSRF protection is enabled by default.

fluxbase.yaml
webhook:
# ⚠️ WARNING: Only disable in development/testing
allow_private_ips: false # Default: false (SSRF protection enabled)

Environment Variable:

Terminal window
export FLUXBASE_WEBHOOK_ALLOW_PRIVATE_IPS=false

For local development with internal services:

# Development config (NOT for production)
webhook:
allow_private_ips: true # ⚠️ Only for local development!

Never enable allow_private_ips: true in production.

When a webhook is created or updated, Fluxbase validates the URL:

// Internal validation (automatic)
1. Parse URL and check scheme (http/https only)
2. Extract hostname
3. Check against blocked hostname list
4. Resolve hostname to IP addresses
5. Check each IP against private IP ranges
6. Reject if any check fails

Fluxbase performs DNS resolution with a 5-second timeout:

// DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resolver := net.Resolver{}
ips, err := resolver.LookupIPAddr(ctx, hostname)

Protected against:

  • DNS rebinding attacks
  • Slow DNS attacks (timeout protection)
  • DNS spoofing (uses system resolver)

Each resolved IP is checked against private ranges:

// Check for private IP blocks
privateBlocks := []string{
"10.0.0.0/8", // RFC 1918
"172.16.0.0/12", // RFC 1918
"192.168.0.0/16", // RFC 1918
"169.254.0.0/16", // AWS metadata
"127.0.0.0/8", // Loopback
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link local
}

Subdomains of blocked hostnames are also blocked:

metadata.google.internal ❌ BLOCKED
api.metadata.google.internal ❌ BLOCKED (subdomain)
kubernetes.default.svc ❌ BLOCKED
pod.kubernetes.default.svc ❌ BLOCKED (subdomain)

When SSRF protection blocks a webhook:

{
"error": "URL resolves to private IP address 192.168.1.1 which is not allowed"
}
{
"error": "localhost URLs are not allowed"
}
{
"error": "internal hostname 'metadata.google.internal' is not allowed"
}
{
"error": "URL scheme must be http or https, got: file"
}
// ✅ Should work
const webhook = await client.webhook.create({
url: 'https://webhook.site/unique-id',
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})
// ❌ Should be blocked
try {
await client.webhook.create({
url: 'http://192.168.1.1/webhook',
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})
} catch (error) {
console.log(error.message)
// "URL resolves to private IP address 192.168.1.1 which is not allowed"
}
// ❌ Should be blocked
try {
await client.webhook.create({
url: 'http://localhost:8080/webhook',
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})
} catch (error) {
console.log(error.message)
// "localhost URLs are not allowed"
}
// ❌ Should be blocked
try {
await client.webhook.create({
url: 'http://169.254.169.254/latest/meta-data/iam/',
events: [{ schema: 'public', table: 'users', event: 'INSERT' }]
})
} catch (error) {
console.log(error.message)
// "URL resolves to private IP address 169.254.169.254 which is not allowed"
}

Fluxbase also validates custom webhook headers to prevent injection:

// Blocked headers (cannot be overridden)
- content-length
- host
- transfer-encoding
- connection
- keep-alive
- proxy-authenticate
- proxy-authorization
- te
- trailors
- upgrade

Header Injection Protection:

// ❌ BLOCKED: CRLF injection
const webhook = await client.webhook.create({
url: 'https://api.example.com/webhook',
headers: {
'X-Custom': 'value\r\nX-Injected: malicious'
}
})
// Error: "header value for 'X-Custom' contains invalid characters"

Custom header values are limited to 8192 bytes:

// ❌ BLOCKED: Header too long
const webhook = await client.webhook.create({
url: 'https://api.example.com/webhook',
headers: {
'X-Huge': 'a'.repeat(10000) // Too long
}
})
// Error: "header value for 'X-Huge' exceeds maximum length of 8192 bytes"
// ✅ GOOD: HTTPS with valid certificate
const webhook = await client.webhook.create({
url: 'https://api.example.com/webhook',
events: [...]
})
// ⚠️ ACCEPTABLE: HTTP for development only
const webhook = await client.webhook.create({
url: 'http://localhost:3000/webhook', // Development only
events: [...]
})
// Client-side validation before sending
function validateWebhookURL(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
} catch {
return false
}
}
if (!validateWebhookURL(userInput)) {
throw new Error('Invalid webhook URL')
}
// Only allow specific domains
const ALLOWED_WEBHOOK_DOMAINS = [
'webhook.site',
'api.example.com',
'hooks.your-domain.com'
]
function isAllowedWebhookURL(url: string): boolean {
const parsed = new URL(url)
return ALLOWED_WEBHOOK_DOMAINS.some(domain =>
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain)
)
}
logging:
audit_enabled: true
audit_events:
- "webhook.ssrf_blocked"
- "webhook.delivery_failed"
ratelimit:
webhook_create_per_minute: 10
webhook_create_per_hour: 50

Protected:

  • EC2 metadata endpoint (169.254.169.254)
  • ECS metadata endpoint
  • Lambda metadata endpoints

Recommendations:

  • Use IMDSv2 (requires session tokens)
  • Restrict IAM roles for webhook URLs
  • Use VPC endpoints for internal services

Protected:

  • Compute Engine metadata (metadata.google.internal)
  • Cloud Run metadata

Recommendations:

  • Use service account impersonation
  • Restrict service account permissions

Protected:

  • Instance Metadata Service (169.254.169.254)

Recommendations:

  • Use Managed Identities
  • Restrict network access

Webhook Creation Fails with “Private IP” Error

Section titled “Webhook Creation Fails with “Private IP” Error”

Issue: Legitimate webhook URL blocked

Solutions:

  1. Check DNS resolution:

    Terminal window
    nslookup your-webhook-domain.com
  2. Verify no CNAME to internal IP:

    Terminal window
    dig your-webhook-domain.com
  3. Check CDN/proxy configuration:

    • Some CDNs resolve to internal IPs
    • Use specific CDN edge endpoints

Webhook Works Locally but Fails in Production

Section titled “Webhook Works Locally but Fails in Production”

Cause: Localhost only works with allow_private_ips: true

Solution: Use external webhook testing service:

// Development: Use webhook testing service
const webhook = await client.webhook.create({
url: 'https://webhook.site/your-unique-id',
events: [...]
})
  • SSRF protection enabled (allow_private_ips: false)
  • Webhooks use HTTPS only
  • Custom headers validated
  • Webhook creation rate limited
  • Failed webhook attempts logged
  • Cloud metadata endpoints blocked
  • Private IP ranges blocked
  • Localhost variants blocked
  • DNS timeout configured (5 seconds)
  • Production webhooks use allowlist