后台里有一个很常见的需求:给一个图片地址,让服务器把它下载下来存到本地。采集文章配图、导入商品图、批量替换外链图片,都会用到。
这个功能看起来很无害,实际上它是 SSRF 的经典入口。这篇文章用 EasyAdminBlazor 2.3 的实现,完整讲一遍"一个安全的远程下载器应该长什么样"。
一、SSRF 到底能造成什么后果
SSRF(Server-Side Request Forgery,服务端请求伪造)的本质是:攻击者让服务器代替自己去访问他访问不到的资源。
后台的"远程图片下载"正好符合条件:
- 输入是一个 URL,由用户(或采集任务)提供;
- 执行者是服务器,通常在可信内网里;
- 结果会落盘,甚至能被下载回来看。
几个真实能打的目标:
http://127.0.0.1:6379/ → 本机 Redis
http://127.0.0.1:9200/_cat/ → 本机 Elasticsearch
http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ 云厂商元数据服务(可能拿到临时凭证)
http://10.0.0.8:8080/admin → 内网管理后台
http://192.168.1.1/ → 内网设备
file:///etc/passwd → 本地文件(协议利用)
gopher://127.0.0.1:6379/_info → 通过 gopher 直接构造 Redis 命令
注意最后两条:协议白名单没做的话,连 file:// 都能读文件。
二、只校验域名为什么不够
很多项目的第一版防护是"域名黑名单"或"域名后缀白名单",然后很快就发现防不住。
1. Content-Type 和扩展名都是声明,不是事实
远程服务返回的 Content-Type: image/png 完全由对方控制。攻击者可以让一个内网接口返回 image/png,或者干脆不带 Content-Type。
所以不能因为 Content-Type 是 image 就放行。这也是为什么源码里把 Content-Type 降级成"参考信息":
var fetch = await downloader.DownloadToTempFileAsync(
remoteUrl,
MaxSize,
maxRedirects: SecurityOptions.Current.MaxRemoteRedirects,
// 图片直链常返回 application/octet-stream 或不带 Content-Type,
// 因此 Content-Type 仅作参考,真实性以 扩展名 + Magic Number + ImageSharp 解码 三重校验为准
requireImageContentType: false);
2. DNS Rebinding:解析时是公网,连接时是内网
最经典的绕过方式:
第 1 次解析 evil.com → 8.8.8.8(公网,校验通过)
第 2 次解析 evil.com → 127.0.0.1(连接时命中内网)
如果你"先解析域名校验 IP,再用域名发起请求",HttpClient 内部会重新解析一次。这两次解析之间的窗口就是 TOCTOU(Time-of-Check to Time-of-Use)漏洞。
3. 重定向绕过
第 1 跳:https://evil.com/a.png 校验通过
第 2 跳:302 → http://169.254.169.254/latest/meta-data/
如果用了 HttpClient 的自动重定向(AllowAutoRedirect = true),第 2 跳根本不会再走你的校验逻辑。
4. 协议降级
https://evil.com/a.png 跳转到 http://...,明文传输,同时也可能借此绕过只针对 HTTPS 的规则。
三、完整防护:七层,一层都不能少
EasyAdminBlazor 的实现把防护拆成七层,分布在 FileSecurityValidator、RemoteContentDownloader 和 FileService 里。
1. 协议 + 主机名 + 字面 IP 校验 IsSafeRemoteUri
2. DNS 解析结果全部必须是公网地址 ResolvePublicAddressesOrNullAsync
3. 禁止自动重定向,逐跳重新校验 DownloadToTempFileAsync
4. 连接建立前再次校验真实 IP CreatePinnedHandler / ConnectCallback
5. 大小限制 + 直接落盘 declaredLength + 累计计数 + FileStream
6. 内容真实性三重校验 扩展名 + Magic Number + ImageSharp 解码
7. 临时文件 + 原子移动 + 权限 File.Move + SysFileController 授权
第 1 层:协议、主机名、字面 IP
public static bool IsSafeRemoteUri(Uri uri)
{
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return false;
}
if (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (Uri.CheckHostName(uri.Host) != UriHostNameType.Dns && IPAddress.TryParse(uri.Host, out var literalIp))
{
return IsPublicIpAddress(literalIp);
}
return true;
}
三件事:
- 协议白名单只允许
http/https。file://、gopher://、ftp://一律拒绝。 localhost直接拒绝(注意localhost会被Uri.CheckHostName识别为 DNS,不走 IP 分支,所以必须单独判)。- 字面 IP 要判公网:
http://127.0.0.1/、http://192.168.1.1/这类直接拒绝;域名留到第 2 层解析后再判。
IsPublicIpAddress 覆盖的网段相当完整:
| 类型 | 拒绝范围 |
|---|---|
| 回环 / 未指定 | 127.0.0.0/8、::1、0.0.0.0、:: |
| 私网 | 10/8、172.16/12、192.168/16、IPv6 ULA fc00::/7 |
| 链路本地 | 169.254/16(含云元数据 169.254.169.254)、fe80::/10 |
| 运营商级 NAT | 100.64/10 |
| 组播 / 保留 | 224/4、240/4、ff00::/8 |
| 文档 / 测试网段 | 192.0.2/24、198.51.100/24、203.0.113/24、2001:db8::/32 |
| 隧道 / 转换 | Teredo 2001::/32、6to4 2002::/16、NAT64 64:ff9b::/96 |
| IPv4 映射的 IPv6 | 递归按 IPv4 规则判定 |
| 其他 IPv6 | 只放行 2000::/3 全球单播空间 |
最后一条特别值得注意:IPv6 不是"没写就默认放行",而是只在 2000::/3 内才认为是公网地址。
第 2 层:DNS 解析结果必须全是公网
/// <summary>
/// 解析主机名并返回全部已通过公网校验的地址;任一地址不是公网地址(含解析失败)时返回 null。
/// 该方法的结果可以安全地用于"锁定"后续的 TCP 连接目标,避免 DNS Rebinding。
/// </summary>
public static async Task<IPAddress[]?> ResolvePublicAddressesOrNullAsync(string host, CancellationToken cancellationToken = default)
{
...
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
}
catch (SocketException) { return null; }
catch (ArgumentException) { return null; }
if (addresses.Length == 0) return null;
// 只要存在任意一个非公网地址就整体拒绝(防止解析出内网地址后连接命中)
foreach (var address in addresses)
{
if (!IsPublicIpAddress(address)) return null;
}
return addresses;
}
这是 fail-closed 策略:一个域名解析出多个 IP 时,只要有一个是非公网,整体拒绝。因为攻击者可以控制 DNS 返回 [公网IP, 127.0.0.1],让连接碰运气命中第二个。
第 3 层:禁止自动重定向,逐跳校验
public async Task<RemoteFetchResult> DownloadToTempFileAsync(
string remoteUrl,
long maxSize,
int maxRedirects = DefaultMaxRedirects,
bool requireImageContentType = true,
CancellationToken cancellationToken = default)
{
if (maxRedirects < 0)
{
maxRedirects = DefaultMaxRedirects;
}
if (!Uri.TryCreate(remoteUrl, UriKind.Absolute, out var currentUri) ||
!FileSecurityValidator.IsSafeRemoteUri(currentUri))
{
return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress);
}
using var handler = FileSecurityValidator.CreatePinnedHandler();
using var client = new HttpClient(handler, disposeHandler: false)
{
Timeout = _timeout
};
...
}
重定向的处理是手工循环:
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
// 每一跳都必须重新做主机/地址校验(重定向目标可能是内网地址)
if (!await FileSecurityValidator.IsSafeRemoteHostAsync(currentUri, cancellationToken))
{
return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress, currentUri);
}
using var request = new HttpRequestMessage(HttpMethod.Get, currentUri);
...
if (IsRedirect(statusCode))
{
var location = response.Headers.Location;
if (location == null)
return RemoteFetchResult.Fail(RemoteFetchStatus.BadStatus, currentUri, statusCode);
if (redirects >= maxRedirects)
return RemoteFetchResult.Fail(RemoteFetchStatus.TooManyRedirects, currentUri, statusCode);
var nextUri = location.IsAbsoluteUri ? location : new Uri(currentUri, location);
// 禁止 HTTPS → HTTP 降级
if (currentUri.Scheme == Uri.UriSchemeHttps && nextUri.Scheme != Uri.UriSchemeHttps)
{
return RemoteFetchResult.Fail(RemoteFetchStatus.InsecureRedirect, currentUri, statusCode);
}
if (!FileSecurityValidator.IsSafeRemoteUri(nextUri))
{
return RemoteFetchResult.Fail(RemoteFetchStatus.BlockedAddress, nextUri, statusCode);
}
redirects++;
currentUri = nextUri;
continue;
}
...
}
要点:
- 每一跳都重新校验域名、解析 IP、检查协议;
- 重定向次数有上限(默认 3,可被
SecurityOptions.MaxRemoteRedirects覆盖),负数会被规范化成默认值,不会变成无限重定向; - HTTPS → HTTP 降级被显式拒绝,返回
InsecureRedirect。
第 4 层:连接建立前锁定 IP(防 DNS Rebinding)
这是整个方案里最关键、也最少见的一层:
/// <summary>
/// 创建一个禁止自动重定向、且在建立 TCP 连接前再次校验真实目标地址的 HttpClientHandler。
/// </summary>
public static SocketsHttpHandler CreatePinnedHandler(TimeSpan? connectTimeout = null)
{
return new SocketsHttpHandler
{
// 重定向由调用方手工处理,每一步都会重新做 SSRF 校验
AllowAutoRedirect = false,
ConnectTimeout = connectTimeout ?? TimeSpan.FromSeconds(10),
// 连接建立前校验实际解析到的 IP,杜绝"校验公网 DNS 后连接内网 IP"
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await ResolvePublicAddressesOrNullAsync(context.DnsEndPoint.Host, cancellationToken)
?? throw new HttpRequestException($"目标主机 {context.DnsEndPoint.Host} 未通过安全校验");
Exception? lastError = null;
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
try
{
await socket.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch (OperationCanceledException) { socket.Dispose(); throw; }
catch (Exception ex) { socket.Dispose(); lastError = ex; }
}
ExceptionDispatchInfo.Capture(lastError ?? new HttpRequestException("无法连接到目标主机")).Throw();
throw lastError ?? new HttpRequestException("无法连接到目标主机");
}
};
}
ConnectCallback 是 SocketsHttpHandler 提供的扩展点:它替换了默认的"解析域名 → 建连接"过程。在这里:
- 重新解析域名,并要求所有地址都是公网地址;
- 直接用
Socket.ConnectAsync连接这个已经校验过的 IP,不再让框架自己解析一次。
这就把 TOCTOU 窗口关掉了:校验的 IP 和实际连接的 IP 是同一批。
HttpClient 的注册也直接用了这个 handler:
builder.Services.AddHttpClient("EasyAdminBlazor.RemoteImage", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
...
}).ConfigurePrimaryHttpMessageHandler(() =>
// 禁止自动重定向:重定向由 RemoteContentDownloader 逐跳校验后再发起(SSRF 防护)
// 同时 ConnectCallback 会在建立连接前再次校验真实 IP,杜绝 DNS Rebinding
FileSecurityValidator.CreatePinnedHandler(TimeSpan.FromSeconds(10)));
第 5 层:大小限制 + 直接落盘
var declaredLength = response.Content.Headers.ContentLength;
if (declaredLength.HasValue && declaredLength.Value > maxSize)
{
return RemoteFetchResult.Fail(RemoteFetchStatus.TooLarge, currentUri, statusCode);
}
var tempPath = Path.Combine(Path.GetTempPath(), $"eab-remote-{Guid.NewGuid():N}.tmp");
try
{
await using var remoteStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
CopyBufferSize, useAsync: true);
var buffer = new byte[CopyBufferSize];
long total = 0;
int read;
while ((read = await remoteStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
total += read;
if (total > maxSize)
{
fileStream.Close();
TryDelete(tempPath);
return RemoteFetchResult.Fail(RemoteFetchStatus.TooLarge, currentUri, statusCode);
}
await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
...
}
两个限制同时存在:
- 声明长度(
Content-Length)超限直接拒绝; - 实际累计长度超限立即中断并删除临时文件——因为
Content-Length可以是假的,也可能是分块传输。
而且响应流是边读边写磁盘,不会把整个远程响应读进内存。这样即使对方返回一个 10GB 的流,也不会把服务器内存打爆。
第 6 层:内容真实性三重校验
下载完只是"拿到了字节",还要证明它确实是图片。FileService.SaveRemoteImageAsync 做了三重校验:
// 内容真实性校验之一:Magic Number 与扩展名匹配
await using (var validationStream = new FileStream(tempFile!, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
{
if (!await FileSecurityValidator.HasAllowedContentAsync(validationStream, extention))
{
return (null, _commonLocalizer["远程文件内容与扩展名不匹配"]);
}
}
// 内容真实性校验之二:Content-Type 如果存在且不是图片,直接拒绝
if (!string.IsNullOrEmpty(fetch.ContentType) &&
!fetch.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
return (null, _commonLocalizer["远程文件不是图片"]);
}
// 内容真实性校验之三:ImageSharp 实际解码
if (!IsImageByImageSharp(tempFile!))
{
return (null, _commonLocalizer["远程文件内容与扩展名不匹配"]);
}
Magic Number 对照表在 FileSecurityValidator 里(节选):
private static readonly Dictionary<string, byte[][]> FileSignatures = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
[".jpeg"] = [new byte[] { 0xFF, 0xD8, 0xFF }],
[".png"] = [new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }],
[".gif"] = ["GIF87a"u8.ToArray(), "GIF89a"u8.ToArray()],
[".webp"] = ["RIFF"u8.ToArray()],
[".pdf"] = ["%PDF"u8.ToArray()],
...
};
三层的关系是互补的:
| 校验 | 防什么 |
|---|---|
| 扩展名 | 只允许白名单内的类型 |
| Magic Number | 防"改扩展名"的伪装 |
| ImageSharp 解码 | 防"头部合法但内容不是图片"的构造文件 |
第 7 层:临时文件 + 原子移动 + 访问权限
下载全程在临时文件里完成,只有全部校验通过才移动到正式目录:
// 从临时文件移动到正式位置(全程不把整个文件读进内存)
System.IO.File.Move(tempFile!, filePath, overwrite: true);
tempFile = null;
任何一步失败都会清理临时文件:
private static void TryDelete(string path)
{
try
{
if (File.Exists(path)) File.Delete(path);
}
catch
{
// 临时文件清理失败不影响主流程
}
}
"先写临时文件再移动"的意义:
- 校验期间不会在正式目录留下半成品;
- 多个并发下载不会互相覆盖;
- 删除/回滚简单,只删临时文件即可。
最后,文件下载回来还要过访问授权。SysFileController 的私有文件判定采用与数据权限一致的规则:
private async Task<bool> IsAuthorizedAsync(SysFile file)
{
await _adminContext.InitRoles();
if (_adminContext.Roles.Any(r => r.IsAdministrator)) return true;
if (_adminContext.Roles.Any(r => r.DataPermission == DataPermissionType.AllData)) return true;
return file.CreatedUserId == _adminContext.User?.Id;
}
上传人信息缺失时按拒绝处理(fail-closed)。下载下来不等于谁都能看——这是 SSRF 防护之外的另一半。
四、测试锁定了哪些攻击面
SsrfTests.cs 用一组真实调用覆盖了主要攻击路径:
| 测试 | 输入 | 期望 |
|---|---|---|
Download_Localhost_IsBlocked |
http://localhost/secret.png |
BlockedAddress |
Download_LoopbackIp_IsBlocked |
http://127.0.0.1/secret.png |
BlockedAddress |
Download_NonPublicTargets_AreBlocked |
10.0.0.5、192.168.1.10、172.20.0.1、169.254.169.254、[::1]、[fc00::1]、[fe80::1]、file://、gopher://、not-a-url |
全部 BlockedAddress |
Download_NullOrEmpty_IsBlocked |
空字符串 | BlockedAddress |
Download_NegativeMaxRedirects_FallsBackToDefault |
maxRedirects = -5 |
转为默认值,内网地址仍被拒绝 |
PinnedHandler_MustNotAutoRedirect |
—— | handler.AllowAutoRedirect == false |
IsSafeRemoteUri_SchemeAndHostRules |
example.com / localhost / 127.0.0.1 / 192.168.0.1 / ftp:// |
与规则一致 |
注意 PinnedHandler_MustNotAutoRedirect 这条断言的措辞:
handler.AllowAutoRedirect.Should().BeFalse("自动重定向会绕过逐跳 SSRF 校验");
它锁定的是设计决策,而不是某个实现细节。以后有人为了"支持跳转"把自动重定向打开,测试会立刻失败。
五、这套方案不覆盖的部分
安全方案要讲清楚边界,否则会给人虚假的安全感。
- 它保护的是"框架提供的远程下载路径"。 如果你在业务代码里直接
new HttpClient().GetStringAsync(userUrl),这套防护不会生效。团队里应该约定:所有由外部输入触发的出站请求都必须走同一个安全下载器。 - HTTP 代理场景要注意。 如果部署环境强制走正向代理(
HTTP_PROXY/HTTPS_PROXY),ConnectCallback连接的会是代理地址而不是目标地址,SSRF 校验语义就会变化。这种环境下需要额外校验代理配置并确认代理自身的访问控制。 - 不要记录完整 URL。 攻击者可能把敏感信息(token)放在 URL 里;日志里建议只记录 host 和路径,或者做脱敏。
- 重定向的目标域名变化要考虑业务影响。 有些图床会跳到 CDN 域名,这些域名的 IP 也必须是公网地址;如果对方用了内网 CDN 节点,请求会被拒绝,这是安全换来的代价。
localhost在容器里的语义。 容器内127.0.0.1指向容器自身,不等于宿主机;但即使如此也必须拒绝,因为容器内可能运行着数据库、缓存、指标服务。- 文件权限是另一条战线。 SSRF 防的是"下载什么",访问授权防的是"谁能读",两者都要做。
六、小结
一个安全的远程下载器,至少要回答七个问题:
| 问题 | 回答 |
|---|---|
| 只允许哪些协议? | 只允许 http / https |
| 目标地址是不是内网? | 字面 IP 直接判;域名解析后要求全部是公网地址 |
| 重定向怎么办? | 禁止自动重定向,逐跳重新校验,限制次数,禁止 HTTPS 降级 |
| 校验完的 IP 和连接的 IP 是同一个吗? | 用 ConnectCallback 锁定实际连接地址,关闭 TOCTOU 窗口 |
| 对方返回超大响应怎么办? | 声明长度 + 实际累计双重限制,边读边落盘 |
| 内容真的是图片吗? | 扩展名 + Magic Number + ImageSharp 解码三重校验 |
| 文件是谁的、谁能下载? | 临时文件 + 原子移动 + 基于角色/数据权限的下载授权 |
SSRF 的防护不是"加一个黑名单",而是在整条链路的每个可被绕过的地方都加上校验。EasyAdminBlazor 的实现把这个原则落到了代码里,也用测试锁定了每一条攻击路径。
如果你的 .NET 10 + Blazor 后台有远程图片/文件下载需求,可以直接参考 EasyAdminBlazor 的实现:协议白名单、逐跳校验、连接前锁定 IP、大小限制、内容真实性校验、临时文件与权限,一整套都开放源码。