Securing Your E-commerce APIs: Preventing Server-Side Request Forgery (SSRF) in webhook parsers in Ruby Implementations
Understanding SSRF in Webhook Parsers
Server-Side Request Forgery (SSRF) is a critical vulnerability that allows an attacker to induce the server-side application to make HTTP requests to an arbitrary domain of the attacker’s choosing. In the context of webhook parsers, this often arises when an application receives a webhook payload containing URLs or other network-related data that it then uses to initiate outbound requests without proper validation. For Ruby-based e-commerce platforms, this is particularly concerning as webhooks are commonly used for integrating with third-party services (payment gateways, shipping providers, marketing tools) and internal microservices.
A typical SSRF attack vector in a webhook parser might involve an attacker sending a webhook payload with a crafted URL that points to internal network resources, such as metadata services (e.g., AWS EC2 metadata endpoint at 169.254.169.254), internal APIs, or even localhost. The server, trusting the incoming data, then makes a request to this internal resource, potentially exposing sensitive information or enabling further attacks.
Common Ruby Webhook Parsing Scenarios and SSRF Risks
Consider a scenario where your e-commerce platform uses webhooks to update order statuses from a shipping provider. The webhook payload might contain a URL to fetch shipment details or a tracking status update. If this URL is not rigorously validated, an attacker could manipulate it.
Another common pattern is receiving webhook events from a payment gateway. These events might include URLs for refund processing or transaction details. If the parser blindly trusts and uses these URLs, it becomes a prime target.
Illustrative Ruby Code Vulnerable to SSRF
Let’s examine a simplified, vulnerable Ruby controller action that might parse a webhook. We’ll use a hypothetical `WebhookController` and a `process_shipping_update` action.
Vulnerable Controller Action
This example uses the built-in `Net::HTTP` library, which is susceptible to SSRF if not carefully managed.
# app/controllers/webhook_controller.rb
require 'net/http'
require 'uri'
class WebhookController << ApplicationController
def process_shipping_update
payload = JSON.parse(request.body.read)
tracking_url = payload['tracking_url'] # Attacker can control this
if tracking_url.present?
begin
uri = URI.parse(tracking_url)
# Vulnerable: No validation of the host or IP address
response = Net::HTTP.get_response(uri)
Rails.logger.info "Shipping update response: #{response.body}"
# ... process response ...
render json: { status: 'success' }, status: :ok
rescue URI::InvalidURIError => e
Rails.logger.error "Invalid URI: #{e.message}"
render json: { status: 'error', message: 'Invalid URL' }, status: :bad_request
rescue StandardError => e
Rails.logger.error "HTTP request failed: #{e.message}"
render json: { status: 'error', message: 'Failed to fetch tracking info' }, status: :internal_server_error
end
else
render json: { status: 'error', message: 'Tracking URL missing' }, status: :bad_request
end
end
end
In this code, the `tracking_url` is directly passed to `URI.parse` and then used with `Net::HTTP.get_response`. An attacker could provide a URL like http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME to attempt to retrieve AWS instance credentials.
Mitigation Strategies: Input Validation and Network Controls
The primary defense against SSRF is robust input validation and, where possible, network-level controls. For webhook parsers, this means scrutinizing any URL provided in the incoming payload before it’s used to initiate an outbound request.
1. Whitelisting Allowed Domains/IPs
The most secure approach is to maintain a strict whitelist of domains or IP addresses that your application is permitted to connect to. Any URL not matching this whitelist should be rejected.
# app/controllers/webhook_controller.rb
require 'net/http'
require 'uri'
class WebhookController << ApplicationController
# Define your allowed domains/IPs
ALLOWED_HOSTS = %w(
api.shippingprovider.com
tracking.anotherprovider.net
192.168.1.100 # Example internal IP if absolutely necessary and secured
).freeze
def process_shipping_update
payload = JSON.parse(request.body.read)
tracking_url = payload['tracking_url']
if tracking_url.present?
begin
uri = URI.parse(tracking_url)
# --- SSRF Mitigation: Host Validation ---
unless uri.host && ALLOWED_HOSTS.include?(uri.host)
Rails.logger.warn "Blocked SSRF attempt: Host '#{uri.host}' not in ALLOWED_HOSTS."
return render json: { status: 'error', message: 'Invalid host' }, status: :bad_request
end
# --- End Mitigation ---
# Further validation: Ensure it's an HTTP/HTTPS request
unless %w(http https).include?(uri.scheme)
Rails.logger.warn "Blocked SSRF attempt: Invalid scheme '#{uri.scheme}'."
return render json: { status: 'error', message: 'Invalid URL scheme' }, status: :bad_request
end
# Prevent requests to localhost or private IP ranges if not explicitly allowed
if uri.host == 'localhost' || is_private_ip?(uri.host)
Rails.logger.warn "Blocked SSRF attempt: Request to private IP/localhost '#{uri.host}'."
return render json: { status: 'error', message: 'Request to private network disallowed' }, status: :bad_request
end
response = Net::HTTP.get_response(uri)
Rails.logger.info "Shipping update response: #{response.body}"
render json: { status: 'success' }, status: :ok
rescue URI::InvalidURIError => e
Rails.logger.error "Invalid URI: #{e.message}"
render json: { status: 'error', message: 'Invalid URL' }, status: :bad_request
rescue StandardError => e
Rails.logger.error "HTTP request failed: #{e.message}"
render json: { status: 'error', message: 'Failed to fetch tracking info' }, status: :internal_server_error
end
else
render json: { status: 'error', message: 'Tracking URL missing' }, status: :bad_request
end
end
private
# Helper to check for private IP addresses (RFC 1918)
def is_private_ip?(ip_address)
# This is a simplified check. For production, consider a more robust IP address parsing library.
# It also doesn't cover all private ranges (e.g., 100.64.0.0/10 for CGNAT).
return false unless ip_address =~ /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/
octets = ip_address.split('.').map(&:to_i)
return true if octets[0] == 10
return true if octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31)
return true if octets[0] == 192 && octets[1] == 168
# Add check for loopback address
return true if ip_address == '127.0.0.1' || ip_address == '::1'
# Add check for link-local addresses (169.254.x.x)
return true if octets[0] == 169 && octets[1] == 254
false
end
end
The `is_private_ip?` helper is a basic implementation. For production, consider using a gem like ipaddr or ipaddress for more comprehensive IP address validation, including IPv6 and various private/reserved ranges.
2. Using a More Secure HTTP Client Library
While `Net::HTTP` is standard, libraries like `Faraday` offer more abstraction and can be configured with middleware for enhanced security, including request validation and connection adapters that can enforce network policies.
# Gemfile
# gem 'faraday'
# gem 'faraday_middleware' # Optional, for more middleware
# app/controllers/webhook_controller.rb
require 'faraday'
require 'uri'
class WebhookController << ApplicationController
ALLOWED_HOSTS = %w(
api.shippingprovider.com
tracking.anotherprovider.net
).freeze
def process_shipping_update
payload = JSON.JSON.parse(request.body.read)
tracking_url = payload['tracking_url']
if tracking_url.present?
begin
uri = URI.parse(tracking_url)
# --- SSRF Mitigation: Host Validation with Faraday ---
unless uri.host && ALLOWED_HOSTS.include?(uri.host)
Rails.logger.warn "Blocked SSRF attempt: Host '#{uri.host}' not in ALLOWED_HOSTS."
return render json: { status: 'error', message: 'Invalid host' }, status: :bad_request
end
unless %w(http https).include?(uri.scheme)
Rails.logger.warn "Blocked SSRF attempt: Invalid scheme '#{uri.scheme}'."
return render json: { status: 'error', message: 'Invalid URL scheme' }, status: :bad_request
end
# Faraday's default adapter (Net::HTTP) can still be vulnerable if not configured.
# We'll rely on our explicit host validation above.
# For more advanced network isolation, consider custom adapters or proxy configurations.
# Configure Faraday connection
conn = Faraday.new(url: tracking_url) do |faraday|
faraday.request :url_encoded # Form-encode the body
faraday.response :logger, Rails.logger # Log requests
faraday.adapter Faraday.default_adapter # Use the default adapter (Net::HTTP)
end
response = conn.get
Rails.logger.info "Shipping update response: #{response.body}"
render json: { status: 'success' }, status: :ok
rescue URI::InvalidURIError => e
Rails.logger.error "Invalid URI: #{e.message}"
render json: { status: 'error', message: 'Invalid URL' }, status: :bad_request
rescue Faraday::ConnectionFailed => e
Rails.logger.error "Faraday connection failed: #{e.message}"
render json: { status: 'error', message: 'Failed to fetch tracking info' }, status: :internal_server_error
rescue StandardError => e
Rails.logger.error "An unexpected error occurred: #{e.message}"
render json: { status: 'error', message: 'An unexpected error occurred' }, status: :internal_server_error
end
else
render json: { status: 'error', message: 'Tracking URL missing' }, status: :bad_request
end
end
end
Even with `Faraday`, the core validation logic (checking `ALLOWED_HOSTS`, scheme, and private IPs) remains crucial. `Faraday` simplifies the HTTP request itself but doesn’t inherently solve the SSRF problem without explicit validation of the target URL.
3. Network-Level Controls and Isolation
Beyond application-level code, consider network configurations:
- Firewall Rules: Configure your server’s firewall (e.g., `iptables`, `ufw`) to block outbound connections to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1/8) and known malicious IPs.
- Proxy Servers: Route all outbound HTTP/S requests through a dedicated proxy server that can enforce stricter access controls, logging, and potentially perform deep packet inspection.
- Containerization/Sandboxing: If your webhook parser runs in a containerized environment (Docker, Kubernetes), leverage network policies to restrict outbound network access to only necessary destinations.
- DNS Resolution: Be cautious with DNS rebinding attacks. Ensure your DNS resolver is configured to prevent it, or implement checks within your application to verify the resolved IP address against expected ranges.
Advanced Considerations: DNS Rebinding and IP Address Resolution
DNS rebinding is a sophisticated SSRF technique where an attacker controls a DNS server. Initially, a malicious domain resolves to a public IP. When the server makes the request, the attacker quickly changes the DNS record to resolve to an internal IP address (e.g., 127.0.0.1 or an internal service IP). The application, having already initiated the request based on the initial DNS lookup, might then connect to the internal resource.
To combat this:
- Time-to-Live (TTL) for DNS Records: Use short TTLs for your whitelisted domains. This doesn’t prevent rebinding but can make it harder to execute reliably.
- IP Address Verification After Resolution: After resolving a hostname (if you’re not strictly whitelisting IPs), verify the resolved IP address against your allowed list and private IP checks. Libraries like
resolvin Ruby can help, but care must be taken to avoid race conditions. - Disable DNS Resolution for Untrusted URLs: If your webhook parser only expects specific hostnames, avoid performing DNS lookups for arbitrary URLs.
# Example of checking resolved IP (simplified)
require 'resolv'
require 'uri'
def resolve_and_check_ip(hostname)
resolver = Resolv::DNS.new
begin
# Resolve to an IP address
ip_address = resolver.getaddress(hostname)
# Perform private IP checks (reuse is_private_ip? from above)
if is_private_ip?(ip_address.to_s)
Rails.logger.warn "Resolved to private IP: #{ip_address}"
return false
end
# Further checks against ALLOWED_HOSTS if needed, mapping hostnames to IPs
# This can get complex with multiple IPs per hostname.
true
rescue Resolv::ResolvError => e
Rails.logger.error "DNS resolution failed for #{hostname}: #{e.message}"
false
end
end
# In your controller action:
# uri = URI.parse(tracking_url)
# if uri.host && !resolve_and_check_ip(uri.host)
# render json: { status: 'error', message: 'Invalid resolved IP' }, status: :bad_request
# end
It’s important to note that DNS rebinding is a complex attack, and a multi-layered defense is most effective. Relying solely on application-level DNS checks can be insufficient.
Conclusion: A Proactive Security Posture
Securing webhook parsers against SSRF requires a diligent approach to input validation and network security. By implementing strict whitelisting, validating URL components, and leveraging network-level controls, you can significantly reduce the attack surface. Regularly auditing your webhook processing logic and staying informed about emerging threats are essential components of a robust API security strategy for any e-commerce platform.