SSRF-style URL policy validation for Swift, with a WHATWG-conformant host parser.
Documentation | Swift Package Index
.package(url: "https://github.com/adamtheturtle/SafeURLKit.git", from: "0.1.0")Add the SafeURLKit product to your target dependencies.
Describe what is acceptable as a value, then validate:
import SafeURLKit
let policy = URLPolicy(
allowedSchemes: ["https"],
allowedOrigins: [.hostSuffix("example.com")]
)
let validated = try policy.validate(userSuppliedURLString)
let (data, _) = try await URLSession.shared.data(from: validated.url)Validation returns a ValidatedURL - a value only validate can produce, so "this URL was
checked" is something the compiler tracks rather than a convention to remember - or throws a
URLValidationError naming the check that failed.
A hand-written check compares host strings, and 127.0.0.1 has many spellings that a string
comparison misses and the network stack does not:
| Written | Reaches |
|---|---|
0177.0.0.1 |
127.0.0.1 |
0x7f.1 |
127.0.0.1 |
2130706433 |
127.0.0.1 |
[::ffff:169.254.169.254] |
the cloud metadata endpoint |
[2002:a9fe:a9fe::] |
the cloud metadata endpoint, via 6to4 |
SafeURLKit implements the WHATWG host parser, so each of these resolves to the address it
denotes before any policy check runs, and the resolved address is matched against the IANA
special-purpose registries.
Validating with one parser and fetching with another is how a correct check ends up guarding
nothing. SafeURLKit splits the URL string itself, then re-reads it through URLComponents
and rejects if the two readings differ. Characters that parsers are known to disagree about -
tab, newline, backslash - are refused outright rather than normalized.
Every axis defaults to its strict setting: HTTPS only, the scheme's default port, no embedded credentials, no fragment, no IP literals, no reserved names or addresses. Loosening any of them is an explicit argument, so it shows up in a diff as a decision rather than an omission.
| Axis | Default | Notes |
|---|---|---|
allowedSchemes |
["https"] |
Compared case-insensitively |
allowedOrigins |
nil |
nil is "any host"; [] rejects everything, so a misconfiguration fails closed |
portRule |
.defaultForScheme |
Also .any and .allowed(Set<Int>) |
allowsCredentials |
false |
Blocks https://trusted.com@evil.com/ |
allowsFragment |
false |
|
allowsQuery |
true |
|
allowsIPLiteralHosts |
false |
|
allowsSpecialUseHostNames |
false |
localhost, .local, .internal, … |
allowsSpecialPurposeAddresses |
false |
Loopback, RFC 1918, link-local, and the rest |
maximumLength |
2048 |
Origin rules are .origin(scheme:host:port:) for an exact port-aware origin, .host(_:) for
an exact host on any permitted port, and .hostSuffix(_:) for a suffix anchored at a label
boundary - so .hostSuffix("example.com") matches app.example.com and does not match
evilexample.com.
portRule and allowedOrigins are separate checks and both must pass, so an exact origin on
a non-default port needs the port rule to admit it too:
URLPolicy(
allowedOrigins: [.origin(scheme: "https", host: .domain("a.example"), port: 8443)],
portRule: .allowed([8443]) // without this, everything is rejected
)Validating the supplied URL and then following redirects unchecked is the most common way a
string check ends up protecting nothing: the URL passes, and the server answers
302 Location: http://169.254.169.254/. Re-apply the policy to every hop:
let delegate = PolicyEnforcingRedirectDelegate(policy: policy)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let (data, response) = try await session.data(from: validated.url)A refused redirect is not an error: URLSession returns the 3xx response itself. Check the
status code, or pass an onRejection closure to observe refusals.
This is URL-string policy validation. It cannot address DNS rebinding: a name that passes
the check can resolve to 127.0.0.1 a moment later, and URLSession exposes no hook between
resolution and connection at which the resolved address could be re-checked.
The defences that do work live at other layers - resolving first and connecting to a vetted
address, as ssrf_filter does in Ruby, or an egress
proxy such as Smokescreen - and neither is buildable
on URLSession alone. Both are non-goals here.
Internationalized hosts are refused, not transcoded. Foundation exposes no IDNA/UTS-46 entry point, and an approximate implementation inside a security check is worse than none: it would produce a host that differs from the one the network stack uses, which is the exact condition this package exists to prevent. Punycode the host before validating.
The design owes a lot to doyensec/safeurl (Go) for the config-object shape, arkadiyt/ssrf_filter (Ruby) for the prefix table and per-hop revalidation, and JordanMilne/Advocate (Python) for keeping the policy object separate from the transport. The test corpus draws on the PortSwigger URL validation bypass cheat sheet and the CVE-2020-28360 regression cases from frenchbread/private-ip.
- Swift 6.2+
- macOS 14+, iOS 17+, tvOS 17+, watchOS 10+, or visionOS 1+, or Linux
- No dependencies
MIT. See LICENSE.