FlawOpen

flawopen.com/ssrf/Swift

● CWE-918 · High
FlawOpen Security Research

Swift SSRF: secure Vapor URL fetches

User URLs can expose Vapor internal services. Hostname checks miss private IPs; use a destination-validating egress proxy and disable redirects.

💡 Plain English Explainer (ELI5)

A hotel lets guests ask the concierge to collect a parcel from any address in town. You write down the address of the hotel's own staff-only office upstairs, where the master keys hang. The concierge wears a staff badge, walks straight in and brings you the key box, because the form only asked for an address, never whether that address is outside the hotel. A server that fetches URLs for users works the same way: from inside the network it can reach 127.0.0.1, internal APIs and the cloud metadata service at 169.254.169.254. The fix is to check where the concierge actually arrives, not just the address on the form, and to refuse notes on the door that say 'go upstairs instead'.

Core Concepts & Subsystem Terms

URLSession follows redirects
URLSession follows HTTP redirects unless a delegate's urlSession(_:task:willPerformHTTPRedirection:newRequest:) refuses them. A public URL answering Location: http://169.254.169.254/ reaches the metadata service after any check on the original URL.
Hostname string checks
Comparing url.host with localhost or 127. misses 169.254.169.254, 10.0.0.1, [::1], 0.0.0.0 and any DNS name that resolves to an internal address. A string check says nothing about where the connection will go.
No resolver hook to pin addresses
Neither URLSession nor AsyncHTTPClient lets you supply a DNS resolver or pin the connection to an address you checked. Resolving the name yourself and then fetching leaves a DNS rebinding window, because the client resolves the name again.
An egress proxy that checks at connect time
An egress proxy such as Stripe's Smokescreen resolves the name itself, refuses loopback, private and link-local destinations by default, and then opens the connection, so the check and the connection use the same address. It only protects you if the network blocks direct outbound traffic from the app.
AsyncHTTPClient configuration
redirectConfiguration = .disallow stops redirects, timeout bounds connect and read time, proxy = .server(...) sends every request through the egress proxy, and response.body.collect(upTo:) throws once the body passes a size limit.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A Vapor route decodes FetchRequest and passes url to URLSession.shared.data(from:) or data(for:).

Step 2

The server connects to an internal address

The URL names 169.254.169.254, a private host, [::1], a DNS name that points inward, or a public page that redirects there. The benign test value http://127.0.0.1:8080/health illustrates the case.

Step 3

The internal service answers the trusted caller

Admin endpoints, sidecars and the metadata service accept the request because it comes from inside the network.

Step 4

The attacker reads secrets or pivots

The response or its status leaks credentials, internal data or open ports, and cloud keys can be reused from outside.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// Fetch.swift: both routes let the caller choose the destination
import Foundation
import Vapor

struct FetchRequest: Content {
    let url: String
}

// any URL is fetched, and URLSession follows redirects
func preview(_ req: Request) async throws -> String {
    let input = try req.content.decode(FetchRequest.self)
    let (data, _) = try await URLSession.shared.data(from: URL(string: input.url)!)
    return String(decoding: data.prefix(5000), as: UTF8.self)
}

// string check on the host: misses 169.254.169.254, IPv6 and inward DNS names
func testWebhook(_ req: Request) async throws -> [String: Int] {
    let input = try req.content.decode(FetchRequest.self)
    guard let url = URL(string: input.url), let host = url.host,
          host != "localhost", !host.hasPrefix("127.") else {
        throw Abort(.badRequest)
    }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.httpBody = Data(#"{"ping":true}"#.utf8)
    let (_, response) = try await URLSession.shared.data(for: request)
    return ["status": (response as? HTTPURLResponse)?.statusCode ?? 0]
}
HARDENED SECURE PATCH
// Fetch.swift: no redirects, strict URLs, and every fetch goes through the egress proxy
import AsyncHTTPClient
import Foundation
import NIOCore
import Vapor

struct FetchRequest: Content {
    let url: String
}

// the proxy resolves and checks the destination when it connects
let egressClient: HTTPClient = {
    var config = HTTPClient.Configuration()
    config.redirectConfiguration = .disallow
    config.timeout = .init(connect: .seconds(3), read: .seconds(5))
    config.proxy = .server(host: "smokescreen.internal", port: 4750)
    return HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
}()

// http and https only, standard ports, a host is required
func checkedURL(_ raw: String) throws -> URL {
    guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(),
          ["http", "https"].contains(scheme), url.host?.isEmpty == false,
          [nil, 80, 443].contains(url.port) else {
        throw Abort(.badRequest, reason: "only http and https URLs on standard ports are allowed")
    }
    return url
}

// request deadline and a 1 MB body limit
func preview(_ req: Request) async throws -> String {
    let url = try checkedURL(try req.content.decode(FetchRequest.self).url)
    let response = try await egressClient.execute(HTTPClientRequest(url: url.absoluteString), timeout: .seconds(10))
    let body = try await response.body.collect(upTo: 1_000_000)
    return String(String(buffer: body).prefix(5000))
}

func testWebhook(_ req: Request) async throws -> [String: Int] {
    let url = try checkedURL(try req.content.decode(FetchRequest.self).url)
    var request = HTTPClientRequest(url: url.absoluteString)
    request.method = .POST
    request.headers.add(name: "Content-Type", value: "application/json")
    request.body = .bytes(ByteBuffer(string: #"{"ping":true}"#))
    let response = try await egressClient.execute(request, timeout: .seconds(10))
    return ["status": Int(response.status.code)]
}

Engineering & System Hardening Checklist

Sources