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, 安全库