Contents

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.strict blocks 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 Classification tells you why an address was refused: which range matched,

or that the input was malformed.

  • Off-pool resolver. The bundled resolver runs getaddrinfo on 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-guard

Package.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 input

Test 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

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

  1. Check the policy. Test the canonical address against each CIDR in the policy. The first

range that contains it wins, and the classifier reports that range back.

  1. Return a verdict. .allowed when no range matched, otherwise .blocked with 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 default

Contributing

Issues and pull requests are welcome.

swift build
swift test                    # deterministic suite
SSRFGUARD_LIVE_TESTS=1 swift test   # opt-in live resolution
swiftlint --strict

Tests 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