ivan-magda/swift-ssrf-guard
Classify whether an IP address is a safe outbound target or one that points back into a private
Table of Contents
Background
Server-Side Request Forgery (SSRF) happens when a server fetches a URL on someone else's behalf and the attacker steers that fetch at an internal target: the loopback interface, a private subnet, or a cloud metadata endpoint such as 169.254.169.254. Any feature that turns user input into an outbound connection forces you to decide whether the resolved destination is a public address or an internal one.
SSRFGuard grew out of a personal assistant daemon that fetches URLs for its owner and must refuse anything that reaches back inside the host. It is small and honest about its scope: it classifies addresses. It does not open sockets, follow redirects, or pin connections, and it is not a rebinding defense on its own. Pair it with connection-time pinning for that (see the note above).
SSRFGuard handles the cases that are easy to get wrong: IPv4-mapped, NAT64, and IPv4-compatible IPv6 forms are unwrapped to the embedded IPv4 and re-checked, the legacy numeric IPv4 spellings that inet_pton rejects but getaddrinfo still resolves are recognized as literals, and a malformed address is refused rather than allowed.
Features
- Strict by default.
EgressPolicy.strictblocks private, loopback, link-local, CGNAT,
benchmarking, documentation, multicast, and reserved ranges for IPv4 and IPv6.
- Anti-bypass canonicalization. IPv4-mapped, NAT64 (
64:ff9b::/96), and IPv4-compatible IPv6
addresses are unwrapped to their embedded IPv4 and re-checked, so a v6 spelling cannot smuggle a private target through.
- Fails closed. A malformed address (an IPv6 value that is not sixteen bytes) is refused, never
allowed.
- Configurable policy. Layer your own CIDRs on top of the strict set, or supply your own list.
- Typed verdicts. A
Classificationtells you why an address was refused: which range matched,
or that the input was malformed.
- Off-pool resolver. The bundled resolver runs
getaddrinfoon a Dispatch worker, so a stalled
DNS lookup never pins one of the Swift concurrency pool's fixed threads.
- No third-party runtime dependencies. Pure libc, Dispatch, and Foundation. Builds and tests
green on Linux.
Requirements
- iOS 16.0+, macOS 13.0+, tvOS 16.0+, watchOS 9.0+, visionOS 1.0+, or Linux
- Swift 6.0+ / Xcode 16+
Installation
Xcode
In Xcode, open File -> Add Package Dependencies…, enter the repository URL, and add the SSRFGuard library to your target:
https://github.com/ivan-magda/swift-ssrf-guardPackage.swift
Add the package to your dependencies:
dependencies: [
.package(url: "https://github.com/ivan-magda/swift-ssrf-guard", from: "1.0.0")
]Then add SSRFGuard to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "SSRFGuard", package: "swift-ssrf-guard")
]
)Usage
Classify an address
EgressClassifier uses EgressPolicy.strict by default. classify(:) returns a typed verdict; isAllowed(:) is the yes/no form.
import SSRFGuard
let classifier = EgressClassifier()
guard let address = ResolvedAddress.parse("10.0.0.1") else { return }
switch classifier.classify(address) {
case .allowed:
connect(to: address)
case .blocked(.matchedRange(let range)):
log("refused: \(address) is in \(range)") // refused: 10.0.0.1 is in 10.0.0.0/8
case .blocked(.malformed):
log("refused: malformed address")
}Resolve a host, then classify
Resolve the host to concrete addresses, classify each one, and connect only to an address you classified. Do not connect back by hostname: that reintroduces the rebinding gap described at the top.
let resolver = SystemAddressResolver()
let classifier = EgressClassifier()
let addresses = try await resolver.resolve(host: "example.com")
for address in addresses where classifier.isAllowed(address) {
connect(to: address) // pin the socket to this exact address
}Customize the blocked ranges
Layer extra ranges on top of the strict set, or build a policy from scratch.
if let corporate = CIDR.parse("203.0.113.0/24") {
let classifier = EgressClassifier(policy: .strict.adding([corporate]))
}
let openPolicy = EgressPolicy(blockedRanges: []) // blocks nothing except malformed inputTest without the network
Inject a scripted AddressResolving to drive tests deterministically.
struct ScriptedResolver: AddressResolving {
let table: [String: [ResolvedAddress]]
func resolve(host: String) async throws -> [ResolvedAddress] {
guard let addresses = table[host] else {
throw AddressResolutionError.unresolvable(host: host)
}
return addresses
}
}How It Works
- Canonicalize. For an IPv6 address, unwrap an IPv4-mapped, NAT64, or IPv4-compatible form to
the embedded IPv4. A value that is not sixteen bytes is refused as malformed.
- Check the policy. Test the canonical address against each
CIDRin the policy. The first
range that contains it wins, and the classifier reports that range back.
- Return a verdict.
.allowedwhen no range matched, otherwise.blockedwith the reason.
Matching is strict per address family: an IPv4-mapped IPv6 form never matches an IPv4 block on its own, which is why the canonicalization step unwraps it first. That ordering stops 64:ff9b::7f00:1 from reaching 127.0.0.1 on a NAT64 network.
Project Structure
Sources/SSRFGuard/
├── Address/ # ResolvedAddress and CIDR value types
├── Classifier/ # EgressClassifier, EgressPolicy, and the Classification verdict
└── Resolver/ # the AddressResolving seam and the getaddrinfo defaultContributing
Issues and pull requests are welcome.
swift build
swift test # deterministic suite
SSRFGUARD_LIVE_TESTS=1 swift test # opt-in live resolution
swiftlint --strictTests follow Given-When-Then.
License
Released under the MIT License. See LICENSE for details.
Package Metadata
Repository: ivan-magda/swift-ssrf-guard
Default branch: main
README: README.md