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:

  1. Direct URL injection: User input passed directly to URLSession, Alamofire, or Vapor Client

  2. Webhook/callback URLs: User-controlled callback URLs for webhooks or OAuth redirects

  3. Proxy endpoints: Application acting as proxy with user-controlled destination

  4. Incomplete hostname regex: Flawed regex patterns missing anchors, escaping, or case handling

  5. Cloud metadata access: Requests to 169.254.169.254 (AWS), metadata.google.internal (GCP)

  6. 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:

  1. Whitelist allowed domains - Maintain explicit list of trusted hostnames

  2. Validate URL schemes - Only allow https:// (reject http://, file://, ftp://, etc.)

  3. Use URLComponents for parsing - Prefer URLComponents over regex for URL validation

  4. Block private IP ranges - Reject 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1.

  5. Block cloud metadata - Reject 169.254.169.254, metadata.google.internal.

  6. Use indirect references - Map user input to pre-configured URLs instead of direct usage

  7. Proper regex anchoring - When validating hostnames and IP addresses, always use ^ and $ anchors, escape dots, and handle case.

  8. 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