Server-Side Request Forgery (SSRF)
ID |
swift.server_side_request_forgery |
Severity |
high |
Resource |
Injection |
Language |
Swift |
Tags |
CWE:918, NIST.SP.800-53, OWASP:2021:A10, PCI-DSS:6.5.1 |
Description
Improper validation of external input used to retrieve the content of a URL ('SSRF').
SSRF vulnerabilities arise when an application accepts user input to create or control URLs or networking requests without appropriate validation or restriction. This can allow attackers to craft malicious requests to sensitive internal endpoints or external services on behalf of the server.
In iOS, macOS, and server-side Swift applications, SSRF vulnerabilities commonly occur in:
-
URLSession: Making HTTP requests with user-controlled URLs -
Alamofire: HTTP client library making requests with external input
-
Vapor web applications: Server-side HTTP client requests
-
Incomplete hostname regex validation: Using flawed regex patterns to validate trusted domains
Rationale
Similar to other injection attacks, an SSRF vulnerability allows an attacker to manipulate the URLs used by a server-side application, to point to unexpected internal or external resources.
By carefully selecting the URLs, the attacker can read or update internal resources, such as cloud metadata, internal services, or databases. In addition, SSRF can bypass network access controls like firewalls and VPNs.
An SSRF vulnerability can be exploited by threat actors for network reconnaissance, data exfiltration, denial of service or pivot attacks to other systems.
In Swift applications, SSRF vulnerabilities manifest in several contexts:
iOS/macOS Applications (URLSession):
import Foundation
class NetworkService {
// VULNERABLE: User-controlled URL in URLSession request
func fetchData(from urlString: String, completion: @escaping (Data?) -> Void) {
// FLAW: User input flows directly into URL request
guard let url = URL(string: urlString) else {
completion(nil)
return
}
// ISSUE: SSRF - attacker can access internal services, cloud metadata, etc.
let task = URLSession.shared.dataTask(with: url) { data, response, error in
completion(data)
}
task.resume()
}
// VULNERABLE: User-controlled URL parameter
func downloadImage(imageURL: String, completion: @escaping (UIImage?) -> Void) {
guard let url = URL(string: imageURL) else {
completion(nil)
return
}
// ISSUE: Attacker can specify internal URLs like:
// - http://169.254.169.254/latest/meta-data/ (AWS metadata)
// - http://metadata.google.internal/computeMetadata/v1/ (GCP)
// - http://localhost:8080/admin (internal services)
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
completion(UIImage(data: data))
} else {
completion(nil)
}
}.resume()
}
// VULNERABLE: URLRequest with user-controlled URL
func makeRequest(endpoint: String, headers: [String: String]) {
guard let url = URL(string: endpoint) else { return }
var request = URLRequest(url: url) // ISSUE: SSRF via user endpoint
headers.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }
URLSession.shared.dataTask(with: request) { data, response, error in
// Process response
}.resume()
}
}
Alamofire HTTP Client:
import Alamofire
class APIClient {
// VULNERABLE: Alamofire request with user URL
func fetchResource(resourceURL: String, completion: @escaping (Result<Data, Error>) -> Void) {
// FLAW: User input directly in Alamofire request
AF.request(resourceURL).responseData { response in // ISSUE: SSRF vulnerability
switch response.result {
case .success(let data):
completion(.success(data))
case .failure(let error):
completion(.failure(error))
}
}
}
// VULNERABLE: Dynamic API endpoint from user input
func callAPI(baseURL: String, path: String) {
let fullURL = "\(baseURL)/\(path)" // ISSUE: User controls entire URL
AF.request(fullURL, method: .get).response { response in
// Attacker can specify: http://internal-api/admin/delete-all
print("Response: \(response)")
}
}
}
Vapor Server-Side HTTP Client:
import Vapor
class VaporSSRFHandler {
// VULNERABLE: Vapor Client with user URL
func fetchRemoteData(req: Request) async throws -> Response {
let targetURL = req.query[String.self, at: "url"] ?? ""
// FLAW: User input directly in HTTP client request
let response = try await req.client.get(URI(string: targetURL)) // ISSUE: SSRF
return Response(status: .ok, body: .init(data: response.body?.data ?? Data()))
}
// VULNERABLE: Webhook callback with user URL
func triggerWebhook(req: Request) async throws -> Response {
struct WebhookRequest: Content {
let callbackURL: String
let data: [String: String]
}
let webhook = try req.content.decode(WebhookRequest.self)
// ISSUE: Attacker controls webhook URL - can target internal services
let response = try await req.client.post(URI(string: webhook.callbackURL)) { webhookReq in
try webhookReq.content.encode(webhook.data)
}
return Response(status: .ok)
}
// VULNERABLE: Proxying user requests
func proxyRequest(req: Request) async throws -> Response {
let proxyTo = req.query[String.self, at: "proxy"] ?? ""
// FLAW: Application acts as open proxy
return try await req.client.get(URI(string: proxyTo)).map { clientResponse in
// ISSUE: SSRF - attacker can scan internal network via this proxy
Response(status: clientResponse.status, body: clientResponse.body)
}.get()
}
}
Incomplete Hostname Regex Validation:
A specific and common SSRF vulnerability occurs when using incomplete regular expressions to validate hostnames. This allows attackers to bypass domain restrictions.
import Foundation
class HostnameValidator {
// VULNERABLE: Incomplete hostname regex (missing anchors)
func isValidDomain(url: String) -> Bool {
// FLAW: Regex lacks ^ and $ anchors
// Matches "example.com" but also "evil.com/example.com" or "example.com.evil.com"
let pattern = "https?://example\\.com/"
let regex = try? NSRegularExpression(pattern: pattern)
let range = NSRange(url.startIndex..., in: url)
// ISSUE: Incomplete regex allows bypass
return regex?.firstMatch(in: url, range: range) != nil
}
// VULNERABLE: Missing start anchor
func validateHostname(url: String) -> Bool {
// FLAW: No ^ at start - allows prefix attacks
let pattern = "https://trusted-api\\.com/.*$"
return url.range(of: pattern, options: .regularExpression) != nil
// Bypassed by: http://evil.com/https://trusted-api.com/
}
// VULNERABLE: Missing end anchor
func checkDomain(url: String) -> Bool {
// FLAW: No $ at end - allows suffix attacks
let pattern = "^https://safe\\.com/"
return url.range(of: pattern, options: .regularExpression) != nil
// Bypassed by: https://safe.com.evil.com/
}
// VULNERABLE: Unescaped dot (matches any character)
func verifyURL(url: String) -> Bool {
// FLAW: Dot not escaped - "." matches any character
let pattern = "^https://api.example.com$"
return url.range(of: pattern, options: .regularExpression) != nil
// Bypassed by: https://apitexampleXcom (X can be any char)
}
// VULNERABLE: Case sensitivity not handled
func validateAPIEndpoint(url: String) -> Bool {
// FLAW: Case-sensitive matching
let pattern = "^https://api\\.trusted\\.com/.*$"
return url.range(of: pattern, options: .regularExpression) != nil
// Bypassed by: https://API.trusted.com/ or https://api.TRUSTED.com/
}
}
class SSRFAttacker {
func exploitIncompleteRegex() {
let validator = HostnameValidator()
// These should be rejected but pass incomplete validation:
let attacks = [
"https://example.com.evil.com/", // Suffix attack
"http://evil.com/https://example.com/", // Prefix attack
"https://api.trusted.com@evil.com/", // User info attack
"https://API.TRUSTED.COM/", // Case manipulation
"https://apitexample_com/" // Unescaped dot
]
attacks.forEach { attack in
if validator.isValidDomain(url: attack) {
print("BYPASS SUCCESSFUL: \(attack)")
// Attacker can now make SSRF requests to evil.com
}
}
}
}
These examples demonstrate common SSRF patterns:
-
Direct URL injection: User input passed directly to URLSession, Alamofire, or Vapor Client
-
Webhook/callback URLs: User-controlled callback URLs for webhooks or OAuth redirects
-
Proxy endpoints: Application acting as proxy with user-controlled destination
-
Incomplete hostname regex: Flawed regex patterns missing anchors, escaping, or case handling
-
Cloud metadata access: Requests to
169.254.169.254(AWS),metadata.google.internal(GCP) -
Internal service scanning: Requests to
localhost,127.0.0.1, or internal IP ranges
Remediation
To prevent SSRF vulnerabilities in applications, follow these remediation practices:
-
Input Validation and Whitelisting: Always sanitize and validate user-supplied data in URLs using a whitelist of known, safe URLs. Reject any URLs not meeting established criteria to limit exposure to external or unauthorized internal requests.
-
Use a Proxy or Gateway Approach: Route all outbound requests through a proxy or gateway, allowing finer control over which requests should be allowed and logged.
-
Network Segmentation and Firewalls: Implement network segmentation to restrict the application’s ability to access sensitive internal services. Use firewalls to block traffic by default and allow only specific, necessary requests.
-
Monitoring and Logging: Implement comprehensive request logging and monitoring solutions to detect suspicious outbound requests indicative of SSRF attempts.
By following these recommended practices, applications can effectively defend against SSRF vulnerabilities, ensuring that server-side requests are controlled and properly secured from unauthorized exploitation.
Swift-Specific SSRF Prevention
URL Whitelist Validation:
import Foundation
class SecureNetworkService {
// Whitelist of allowed domains
private let allowedHosts: Set<String> = [
"api.example.com",
"cdn.example.com",
"trusted-partner.com"
]
// Allowed URL schemes
private let allowedSchemes: Set<String> = ["https"]
// FIXED: Validate URL before making request
func fetchData(from urlString: String, completion: @escaping (Data?) -> Void) {
guard let url = URL(string: urlString),
isURLAllowed(url)
else {
print("Rejected untrusted URL: \(urlString)")
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
completion(data)
}.resume()
}
// URL validation with scheme and hostname whitelist
func isURLAllowed(_ url: URL) -> Bool {
// 1. Validate scheme (only HTTPS)
guard let scheme = url.scheme?.lowercased(),
allowedSchemes.contains(scheme)
else {
return false
}
// 2. Validate hostname against whitelist
guard let host = url.host?.lowercased() else {
return false
}
// 3. Check exact match or subdomain
return allowedHosts.contains { allowedHost in
host == allowedHost || host.hasSuffix("." + allowedHost)
}
}
// FIXED: Block private IP ranges
func isPrivateIP(_ host: String) -> Bool {
// Block localhost
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
// Block private IPv4 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
let privateRanges = ["10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168."]
return privateRanges.contains { host.hasPrefix($0) }
}
// FIXED: Block cloud metadata endpoints
func isCloudMetadata(_ host: String) -> Bool {
let metadataHosts = [
"169.254.169.254", // AWS, Azure, others
"metadata.google.internal", // GCP
"metadata",
"fd00:ec2::254" // AWS IPv6
]
return metadataHosts.contains(host.lowercased())
}
}
Complete Hostname Regex Validation:
import Foundation
class SecureHostnameValidator {
// FIXED: Complete hostname regex with proper anchors
func isValidDomain(url: String) -> Bool {
// Use anchors (^ and $), escape dots, handle case-insensitively
let pattern = "^https://example\\.com/.*$"
let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
let range = NSRange(url.startIndex..., in: url)
return regex?.firstMatch(in: url, range: range) != nil
}
// FIXED: Proper URL validation with components
func validateHostname(url: String) -> Bool {
guard let urlComponents = URLComponents(string: url),
let host = urlComponents.host?.lowercased()
else {
return false
}
// Exact hostname matching (no regex needed)
let allowedHosts = ["trusted-api.com", "api.example.com"]
return allowedHosts.contains(host)
}
// FIXED: Subdomain validation with proper suffix check
func checkDomain(url: String) -> Bool {
guard let urlComponents = URLComponents(string: url),
let host = urlComponents.host?.lowercased()
else {
return false
}
let baseDomain = "safe.com"
// Allow exact match or proper subdomain (with dot separator)
return host == baseDomain || host.hasSuffix("." + baseDomain)
}
// FIXED: Use URLComponents instead of regex
func verifyURL(url: String) -> Bool {
guard let components = URLComponents(string: url),
let scheme = components.scheme,
let host = components.host,
scheme == "https",
host.lowercased() == "api.example.com"
else {
return false
}
return true
}
}
Vapor SSRF Protection:
import Vapor
class SecureVaporHandler {
private let allowedHosts: Set<String> = ["api.trusted.com", "partner-api.com"]
// FIXED: Validate URL before making client request
func fetchRemoteData(req: Request) async throws -> Response {
let targetURL = req.query[String.self, at: "url"] ?? ""
// Validate URL
guard let uri = URI(string: targetURL),
let host = uri.host,
allowedHosts.contains(host.lowercased()),
uri.scheme == "https"
else {
throw Abort(.badRequest, reason: "Invalid or untrusted URL")
}
let response = try await req.client.get(uri)
return Response(status: .ok, body: response.body)
}
// FIXED: Use indirect reference map for webhooks
func triggerWebhook(req: Request) async throws -> Response {
struct WebhookRequest: Content {
let webhookID: String // Changed from callbackURL to ID
let data: [String: String]
}
let webhook = try req.content.decode(WebhookRequest.self)
// Map webhook IDs to pre-configured URLs
let webhookURLs: [String: String] = [
"payment_success": "https://api.partner.com/webhook/payment",
"order_created": "https://api.partner.com/webhook/order"
]
guard let callbackURL = webhookURLs[webhook.webhookID],
let uri = URI(string: callbackURL)
else {
throw Abort(.badRequest, reason: "Invalid webhook ID")
}
let response = try await req.client.post(uri) { webhookReq in
try webhookReq.content.encode(webhook.data)
}
return Response(status: .ok)
}
// FIXED: Deny proxy functionality or strictly limit it
func proxyRequest(req: Request) async throws -> Response {
// Option 1: Remove proxy functionality entirely
throw Abort(.forbidden, reason: "Proxy not allowed")
// Option 2: If proxy is required, use strict whitelist
/*
let proxyTo = req.query[String.self, at: "proxy"] ?? ""
let allowedProxyTargets = ["https://api.partner.com"]
guard allowedProxyTargets.contains(proxyTo) else {
throw Abort(.forbidden, reason: "Proxy target not allowed")
}
return try await req.client.get(URI(string: proxyTo)).map { clientResponse in
Response(status: clientResponse.status, body: clientResponse.body)
}.get()
*/
}
}
Key Prevention Strategies:
-
Whitelist allowed domains - Maintain explicit list of trusted hostnames
-
Validate URL schemes - Only allow
https://(rejecthttp://,file://,ftp://, etc.) -
Use URLComponents for parsing - Prefer
URLComponentsover regex for URL validation -
Block private IP ranges - Reject 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1.
-
Block cloud metadata - Reject 169.254.169.254, metadata.google.internal.
-
Use indirect references - Map user input to pre-configured URLs instead of direct usage
-
Proper regex anchoring - When validating hostnames and IP addresses, always use
^and$anchors, escape dots, and handle case. -
Network segmentation - Restrict outbound connections at network/firewall level
URLComponents Extension for SSRF Protection:
import Foundation
// For demonstration purpose only
extension URLComponents {
/// Checks if URL is safe for SSRF (allowed domain, not private IP, not metadata)
func isSafeForSSRF(allowedHosts: Set<String>) -> Bool {
// Check scheme
guard let scheme = self.scheme?.lowercased(),
scheme == "https"
else {
return false
}
// Check hostname
guard let host = self.host?.lowercased() else {
return false
}
// Block private IPs
if isPrivateIP(host) || isCloudMetadata(host) {
return false
}
// Check against whitelist
return allowedHosts.contains { allowedHost in
host == allowedHost || host.hasSuffix("." + allowedHost)
}
}
private func isPrivateIP(_ host: String) -> Bool {
let privateRanges = ["10.", "127.", "172.16.", "192.168.", "localhost"]
return privateRanges.contains { host.hasPrefix($0) } || host == "::1"
}
private func isCloudMetadata(_ host: String) -> Bool {
return host == "169.254.169.254" ||
host == "metadata.google.internal" ||
host == "metadata"
}
}
Configuration
The detector has the following configurable parameters:
-
sources, that indicates the source kinds to check. -
neutralizations, that indicates the neutralization kinds to check.
Unless you need to change the default behavior, you typically do not need to configure this detector.
References
-
CWE-918 : Server-Side Request Forgery (SSRF).
-
OWASP - Top 10 2021 Category A10 : Server-Side Request Forgery (SSRF).
-
Server-Side Request Forgery Prevention Cheat Sheet, in OWASP Cheat Sheet Series.
-
Alamofire - HTTP networking in Swift.
-
Vapor Client - Server-side Swift HTTP client.