flawopen.com/ssrf/Swift
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.
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 redirectsURLSessionfollows HTTP redirects unless a delegate'surlSession(_:task:willPerformHTTPRedirection:newRequest:)refuses them. A public URL answeringLocation: http://169.254.169.254/reaches the metadata service after any check on the original URL.Hostname string checks- Comparing
url.hostwithlocalhostor127.misses169.254.169.254,10.0.0.1,[::1],0.0.0.0and 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
URLSessionnor 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 configurationredirectConfiguration = .disallowstops redirects,timeoutbounds connect and read time,proxy = .server(...)sends every request through the egress proxy, andresponse.body.collect(upTo:)throws once the body passes a size limit.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
A Vapor route decodes FetchRequest and passes url to URLSession.shared.data(from:) or data(for:).
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.
The internal service answers the trusted caller
Admin endpoints, sidecars and the metadata service accept the request because it comes from inside the network.
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
// 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]
}
// 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
- ✓Add a SwiftLint
custom_rulesregex or Semgrep rule that flagsURLSessionandHTTPClientcalls whose URL comes fromreq.content,req.queryorreq.parameters. - ✓Send every user-supplied fetch through one
HTTPClientconfigured with an egress proxy that blocks loopback, private, link-local and metadata destinations, and block direct outbound traffic from the app at the network layer. - ✓Disallow redirects, accept only
httpandhttpson ports 80 and 443, and reject URLs without a host. - ✓Set connect and read timeouts plus a request deadline, and read bodies with
collect(upTo:). - ✓Enforce IMDSv2 with a hop limit of 1 and give the service's cloud role only the permissions it needs.