securized/laravel-ssrf
GitHub: securized/laravel-ssrf
一个Laravel扩展包,通过校验用户提供的URL并阻断对私有网络和云元数据接口的请求,防止服务器端请求伪造(SSRF)攻击。
Stars: 0 | Forks: 0
# Laravel SSRF 防护
[](https://packagist.org/packages/securized/laravel-ssrf)
[](https://github.com/securized/laravel-ssrf/actions/workflows/tests.yml)
[](https://github.com/securized/laravel-ssrf/actions/workflows/quality.yml)
[](https://packagist.org/packages/securized/laravel-ssrf)
针对 Laravel 的 SSRF(服务器端请求伪造)防护。保护 `Http::`、原生 Guzzle 以及用户提供的 URL 免遭恶意利用,防止其被用于访问内部基础设施、云元数据接口或私有网络。
## 问题描述
获取用户提供的 URL 是一项常规操作。例如 Webhook、链接预览、头像导入、“从 URL 导入”按钮等。相关代码通常如下所示:
```
$response = Http::get($request->input('webhook_url'));
```
你的服务器会乐意获取任何指向它的目标地址,并且是处在一个你的用户所不具备的网络位置上。因此,攻击者可以提交:
```
http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
在未打补丁的 EC2 (IMDSv1) 上,这会返回你实例的 IAM 凭证。同样的手段也能访问 `http://localhost:6379`(Redis)、你的内部管理面板,或者私有子网中的 Kubernetes API。只要你的服务器能够路由到,而公共互联网无法访问的任何资源,都可能受到威胁。
要彻底阻断此类攻击,其难度远超表面所见。`127.0.0.1` 也可以写成 `http://0177.0.0.1/`、`http://2130706433/` 以及 `http://[::1]/`。一个在你检查时解析为公共 IP 的主机名,可能在 Guzzle 发起连接的下一秒就解析成了 `127.0.0.1`。本扩展包专门负责处理此类情况,并在请求离开你的应用程序之前对每一个 URL 进行校验。
## 安装说明
```
composer require securized/laravel-ssrf
```
发布配置文件:
```
php artisan vendor:publish --tag="ssrf-config"
```
## 快速入门
```
use Illuminate\Support\Facades\Http;
// Wrap any Http:: call with ->ssrf() to enable protection
Http::ssrf()->get($userSuppliedUrl);
Http::ssrf()->post($webhookUrl, $payload);
```
配置就此完成。对于私有 IP、localhost、链路本地地址(包括云元数据接口)以及非 HTTP(S) 协议的请求,开箱即用即被拦截。
## 功能
- **Laravel HTTP 客户端宏**:`Http::ssrf()` 和 `Http::withSsrfProtection()`
- **原生 Guzzle 中间件**:可直接插入任何 `HandlerStack` 中
- **验证规则**:使用 `new SsrfSafeUrl()` 进行表单/API 输入验证
- **Facade**:`Ssrf::validate($url)`、`Ssrf::isSafe($url)`、`Ssrf::safeUrl($url)`
- **IPv4 + IPv6**:阻断两种地址族的私有网段
- **DNS 绑定**:防止 DNS 重绑定攻击
- **灵活配置**:支持对 IP、端口、域名和协议进行白名单/黑名单控制
- **不可变选项**:在长时间运行的进程(Octane、RoadRunner)中保持安全
## 用法
### Laravel HTTP 客户端
`ssrf()` 和 `withSsrfProtection()` 宏会返回一个 `PendingRequest`,并支持像所有其他 HTTP 客户端方法一样进行正常链式调用:
```
use Illuminate\Support\Facades\Http;
// Protect a single request
$response = Http::ssrf()->get($userUrl);
// Chain with other options
$response = Http::ssrf()
->withHeaders(['Accept' => 'application/json'])
->timeout(10)
->get($userUrl);
// Both macros are identical
Http::withSsrfProtection()->post($webhookUrl, $data);
```
被拦截的请求会抛出 `\GuzzleHttp\Exception\RequestException`(与 Guzzle 请求失败时抛出的异常相同),因此你现有的错误处理机制无需修改即可正常工作。
### 全局防护
要自动为每一个发出的 `Http::` 请求提供保护,请在你的 `.env` 中设置 `auto_protect`:
```
SSRF_AUTO_PROTECT=true
```
或者在 `config/ssrf.php` 中设置:
```
'auto_protect' => true,
```
### 原生 Guzzle
使用静态工厂为任何 Guzzle 客户端添加 SSRF 防护,而无需依赖 Laravel 容器:
```
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Securized\Ssrf\Http\Middleware\SsrfProtectionMiddleware;
$stack = HandlerStack::create();
$stack->push(SsrfProtectionMiddleware::make());
$client = new Client(['handler' => $stack]);
$response = $client->get($userUrl);
```
### 验证规则
在表单请求或控制器中校验用户提供的 URL:
```
use Securized\Ssrf\Rules\SsrfSafeUrl;
$request->validate([
'webhook_url' => ['required', 'url', new SsrfSafeUrl()],
'preview_url' => ['required', 'url', new SsrfSafeUrl()],
]);
```
验证错误消息被特意设计为不向终端用户暴露内部网络细节。
### Facade
```
use Securized\Ssrf\Facades\Ssrf;
// Returns array{url: string, host: string, ips: list, pinned: bool}
// Throws SsrfException on failure.
$result = Ssrf::validate($url);
// Returns the validated URL string.
// When pin_dns is enabled, the host is replaced with the resolved IP.
// Always use this value for the actual request, not the original $url.
// Throws SsrfException on failure.
$safeUrl = Ssrf::safeUrl($url);
// Returns true/false, never throws.
if (!Ssrf::isSafe($url)) {
abort(422, 'URL is not permitted.');
}
```
## 配置
发布配置文件后,编辑 `config/ssrf.php`:
```
return [
// Apply SSRF protection to all Http:: requests globally.
'auto_protect' => env('SSRF_AUTO_PROTECT', false),
// Allow credentials (user:pass@host) in URLs. Disabled by default.
'send_credentials' => false,
// Replace hostname with resolved IP before sending the request.
// Prevents DNS rebinding attacks. See "DNS Pinning" below.
'pin_dns' => false,
'whitelist' => [
'ips' => [], // CIDR ranges or exact IPs
'ports' => [80, 443, 8080], // Allowed ports (empty = allow all)
'domains' => [], // Regex patterns (empty = allow all)
'schemes' => ['http', 'https'],
],
'blacklist' => [
'ips' => [
// RFC 1918 private ranges
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
// Loopback
'127.0.0.0/8',
// Link-local (cloud metadata: AWS, GCP, Azure)
'169.254.0.0/16',
// ... and more (see config/ssrf.php for the full list)
],
'ports' => [],
'domains' => [],
'schemes' => [],
],
];
```
### 白名单语义
对于某一类型的**空白名单**意味着“允许所有”(受限于黑名单)。**非空白名单**则意味着仅允许列出的值。黑名单始终具有最高优先级。
### 域名匹配规则
域名条目是包含 `*` 作为通配符的字面主机名,并且不区分大小写进行匹配:
```
'whitelist' => [
'domains' => [
'api.example.com', // exact host
'*.trusted.com', // any subdomain (not the bare apex)
],
],
```
点号是字面字符,因此一个匹配规则所匹配的主机范围绝不会超出其字面表述。
`api.example.com` 只会匹配该主机本身,不会匹配其他任何内容。不支持正则
表达式;像 `.` 和 `(` 这样的字符均按字面意义进行匹配。
### IP 范围
IP 条目同时支持 IPv4 和 IPv6 的 CIDR 表示法:
```
'blacklist' => [
'ips' => [
'10.0.0.0/8', // IPv4 range
'fc00::/7', // IPv6 unique local
],
],
```
## 单次请求选项
使用 `SsrfOptions` 覆盖单个请求的全局配置:
```
use Securized\Ssrf\SsrfOptions;
// Build from config and customise
$options = SsrfOptions::fromConfig(config('ssrf'))
->withPinDns()
->withWhitelistSchemes(['https']) // HTTPS only for this request
->addBlacklistIp('203.0.113.0/24');
Http::ssrf($options)->get($url);
// Also works with the validation rule
new SsrfSafeUrl($options)
```
`SsrfOptions` 是不可变的。每个方法都会返回一个新的实例,因此针对单次请求的定制化配置永远不会影响共享状态。
## DNS 绑定
DNS 重绑定是一种攻击手法,主机名最初解析为一个公共 IP(通过校验),但在实际请求时又重新解析为一个私有 IP。启用 `pin_dns` 可以防止此类攻击,其工作原理是:仅解析一次主机名,对其 IP 进行校验,然后在实际请求时,将该 IP 替换掉 URL 中的主机名。在此过程中,原始的 `Host` 头会被保留。
```
// In config/ssrf.php
'pin_dns' => true,
// Or per-request
Http::ssrf(SsrfOptions::fromConfig(config('ssrf'))->withPinDns())->get($url);
```
注意:DNS 绑定在某些配置下可能会影响 SSL 证书验证。
## 无法防范的情况
在依赖本扩展包之前,有必要了解以下信息:
- **二级请求需由你自行负责。** 重定向*已被覆盖*,因为 Guzzle 在每一次跳转时都会重新进入中间件,因此针对 `http://169.254.169.254/` 的 `302` 重定向也会被拦截(已包含相关测试)。但是,如果你获取了一个页面,并从其响应体中解析出一个 URL 然后自行发起请求,那么这第二个请求就需要你自行进行校验了。
- **未开启 DNS 绑定时的 TOCTOU。** 在禁用 `pin_dns` 的情况下,主机名会在校验阶段解析一次,并在 Guzzle 连接时再次解析。控制 DNS 响应的攻击者可以在第一次查询时返回公共 IP,在第二次查询时返回私有 IP。启用 `pin_dns` 即可堵住此漏洞。
- **被拦截的主机依然可以被区分。** 响应时间和错误信息的差异会让攻击者推断出内部网络中存在哪些主机。这类似于一种基于极其有限反馈的端口扫描,虽然不会造成数据泄露,但其影响依然不可忽视。
- **黑名单仅涵盖已公开的保留网段,无法覆盖你所在的实际网络环境。** 如果你的内部服务部署在公共 IP 上,你需要自行将它们加入黑名单,本扩展包无法自动推断这些信息。
## 异常层级
所有 SSRF 异常均继承自 `\Securized\Ssrf\Exceptions\SsrfException`(它本身就是 `RuntimeException` 的子类):
```
SsrfException
└── InvalidUrlException - URL cannot be parsed, is empty, or contains credentials
├── InvalidSchemeException - scheme not permitted (e.g. file://, gopher://)
├── InvalidPortException - port not permitted
├── InvalidDomainException - hostname not permitted or does not resolve
└── InvalidIpException - resolved IP matches a blacklisted range
```
你可以捕获 `SsrfException` 来统一处理所有 SSRF 故障,也可以通过捕获特定的子类来进行更细致的处理:
```
use Securized\Ssrf\Exceptions\InvalidIpException;
use Securized\Ssrf\Exceptions\SsrfException;
use Securized\Ssrf\Facades\Ssrf;
try {
$safeUrl = Ssrf::safeUrl($userUrl);
} catch (InvalidIpException $e) {
// Resolved to a private IP
} catch (SsrfException $e) {
// Any other validation failure
}
```
## 测试
```
composer test
```
## 更新日志
请查看 [更新日志](CHANGELOG.md) 获取近期变更的更多信息。
## 安全漏洞
如果你在本扩展包中发现安全问题,请发送邮件至 `root@securized.dev`,而不要公开发布 Issue。
## 许可证
MIT 许可证 (MIT)。请查看 [许可证文件](LICENSE.md) 获取更多信息。
标签:ffuf, Laravel, OpenVAS, PHP, SSRF防护, TLS, Web安全, 网络请求校验, 蓝队分析, 防御工具