adamtheturtle/SafeURLKit
GitHub: adamtheturtle/SafeURLKit
SafeURLKit 是一个 Swift 库,通过符合 WHATWG 标准的 host parser 和可配置的 URL 策略,防御 SSRF 攻击中各种基于 URL 解析差异的绕过手法。
Stars: 0 | Forks: 0
# SafeURLKit
针对 Swift 的 SSRF 风格 URL 策略验证,附带符合 WHATWG 标准的 host parser。
[文档](https://swiftpackageindex.com/adamtheturtle/SafeURLKit/documentation/safeurlkit) |
[Swift Package Index](https://swiftpackageindex.com/adamtheturtle/SafeURLKit)
## 安装说明
```
.package(url: "https://github.com/adamtheturtle/SafeURLKit.git", from: "0.1.0")
```
将 `SafeURLKit` product 添加到您的 target dependencies 中。
## 使用方法
描述什么样的值是可接受的,然后进行验证:
```
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)
```
验证会返回一个 `ValidatedURL` —— 这是一个只有 `validate` 才能生成的值,因此“此 URL 已通过检查”是编译器可以直接追踪的状态,而不仅仅是一项需要记住的约定 —— 否则会抛出 `URLValidationError`,指出具体未通过检查的名称。
## 为什么需要这个 package
手动编写的检查通常只会比较 host 字符串,但 `127.0.0.1` 有许多变体,字符串比较会遗漏它们,而网络栈却不会:
| 书写形式 | 实际访问 |
| --- | --- |
| `0177.0.0.1` | `127.0.0.1` |
| `0x7f.1` | `127.0.0.1` |
| `2130706433` | `127.0.0.1` |
| `[::ffff:169.254.169.254]` | 云端 metadata endpoint |
| `[2002:a9fe:a9fe::]` | 通过 6to4 访问云端 metadata endpoint |
`SafeURLKit` 实现了 WHATWG host parser,因此在任何策略检查运行*之前*,上述每一项都会被解析为其代表的实际地址,并且解析后的地址会与 IANA special-purpose registries 进行匹配。
### Parser 分歧即拒绝
使用一个 parser 进行验证,而使用另一个进行获取,这就是原本正确的检查最终形同虚设的原因。`SafeURLKit` 会自行拆分 URL 字符串,然后通过 `URLComponents` 重新读取它,如果两次读取结果不同则予以拒绝。对于已知 parser 之间会存在分歧的字符(如 tab、换行符、反斜杠),将被直接拒绝,而不是进行规范化处理。
### 默认安全
每个维度默认都采用最严格的设置:仅限 HTTPS、scheme 的默认 port、无嵌入式 credentials、无 fragment、无 IP literals、无保留名称或地址。放宽其中任何一项都需要显式参数,因此在代码 diff 中,这会显示为一项明确的决策,而非疏漏。
## 策略维度
| 维度 | 默认值 | 备注 |
| --- | --- | --- |
| `allowedSchemes` | `["https"]` | 比较时不区分大小写 |
| `allowedOrigins` | `nil` | `nil` 表示“任意 host”;`[]` 表示拒绝一切,因此配置错误时会自动采取失败关闭(fail closed)原则 |
| `portRule` | `.defaultForScheme` | 也支持 `.any` 和 `.allowed(Set)` |
| `allowsCredentials` | `false` | 阻止 `https://trusted.com@evil.com/` |
| `allowsFragment` | `false` | |
| `allowsQuery` | `true` | |
| `allowsIPLiteralHosts` | `false` | |
| `allowsSpecialUseHostNames` | `false` | `localhost`, `.local`, `.internal`, … |
| `allowsSpecialPurposeAddresses` | `false` | Loopback, RFC 1918, link-local 等其他地址 |
| `maximumLength` | `2048` | |
Origin 规则包括:用于精确匹配带 port 的 origin 的 `.origin(scheme:host:port:)`,用于在任何允许的 port 上精确匹配 host 的 `.host(_:)`,以及用于锚定在 label 边界上的后缀匹配的 `.hostSuffix(_:)` —— 因此,`.hostSuffix("example.com")` 会匹配 `app.example.com`,但不会匹配 `evilexample.com`。
`portRule` 和 `allowedOrigins` 是相互独立的检查,并且必须同时通过,因此,在非默认 port 上的精确 origin 也需要 port 规则允许该 port:
```
URLPolicy(
allowedOrigins: [.origin(scheme: "https", host: .domain("a.example"), port: 8443)],
portRule: .allowed([8443]) // without this, everything is rejected
)
```
## 重定向
验证提供的 URL 后却不加检查地跟随重定向,是字符串检查最终失效的最常见原因:URL 通过了验证,而服务器却响应 `302 Location: http://169.254.169.254/`。请将策略应用于每一跳(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)
```
被拒绝的重定向并不会产生错误:`URLSession` 会直接返回 3xx 响应本身。您可以检查状态码,或者传递一个 `onRejection` 闭包来观察被拒绝的情况。
## 适用范围
**这是针对 URL 字符串的策略验证。** 它无法解决 DNS rebinding 问题:一个通过检查的域名可能在稍后解析为 `127.0.0.1`,而 `URLSession` 并没有在解析和连接之间暴露任何 hook,以便对解析后的地址进行二次检查。
真正有效的防御措施存在于其他层面——例如像 Ruby 的 [ssrf_filter](https://github.com/arkadiyt/ssrf_filter) 那样先进行解析再连接到已审查的地址,或者使用诸如 [Smokescreen](https://github.com/stripe/smokescreen) 之类的出口代理——而这两者都无法仅靠 `URLSession` 实现。这两者都不在本 package 的目标范围内。
**国际化 host 将被直接拒绝,而不进行转码。** Foundation 没有提供 IDNA/UTS-46 的入口点,而且在安全检查中实现一个不精确的方案比不实现还要糟糕:它生成的 host 会与网络栈实际使用的不一致,而这正是本 package 旨在防止的情况。请在验证之前先将 host 转换为 Punycode。
## 先前艺术
本设计极大地受益于 [doyensec/safeurl](https://github.com/doyensec/safeurl)(Go)的配置对象结构、[arkadiyt/ssrf_filter](https://github.com/arkadiyt/ssrf_filter)(Ruby)的前缀表和逐跳重新验证,以及 [JordanMilne/Advocate](https://github.com/JordanMilne/Advocate)(Python)将策略对象与传输层分离的理念。测试语料库借鉴了 [PortSwigger URL 验证绕过速查表](https://portswigger.net/research/introducing-the-url-validation-bypass-cheat-sheet) 以及来自 [frenchbread/private-ip](https://github.com/frenchbread/private-ip) 的 CVE-2020-28360 回归测试用例。
## 环境要求
- Swift 6.2+
- macOS 14+, iOS 17+, tvOS 17+, watchOS 10+, 或 visionOS 1+, 或 Linux
- 无依赖
## 许可证
MIT。详见 [LICENSE](LICENSE)。
标签:CISA项目, SSRF防护, Swift, URL验证, WHATWG, YAML, 安全库