企業內部系統常需顯示:
📁 本地非站點目錄:`D:\Archives\Images\` `E:\ScanData\`
🌐 映射網絡磁盤:`Z:\Shared\Photos\` (實為 `\\FileServer\Share\Photos\`)
🔗 UNC 路徑:`\\FileServer\Department\Images\`
直接用 `` **瀏覽器安全策略禁止**,必須通過 HTTP 端點代理讀取。
<%@ WebHandler Language="C#" Class="ShowOutSiteImage" %>
using System;
using System.IO;
using System.Web;
public class ShowOutSiteImage : IHttpHandler
{
// 白名單根目錄(配置在 Web.config appSettings 更佳)
private static readonly string[] AllowedRoots = new[]
{
@"D:\",
@"E:\",
@"\\FileServer\Share\",
@"\\BackupServer\Images\"
};
public void ProcessRequest(HttpContext context)
{
string rawPath = context.Request.QueryString["file"];
if (string.IsNullOrWhiteSpace(rawPath))
{
RespondError(context, 400, "Missing file parameter");
return;
}
// 1. 路徑規範化與安全檢查
string safePath;
try
{
string fullPath = Path.GetFullPath(rawPath); // 解析 .. 、UNC 規範化
bool allowed = AllowedRoots.Any(root =>
fullPath.StartsWith(Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase));
if (!allowed)
{
RespondError(context, 403, "Path not allowed");
return;
}
safePath = fullPath;
}
catch (Exception ex)
{
RespondError(context, 400, "Invalid path: " + ex.Message);
return;
}
// 2. 文件存在性與副檔名白名單
if (!File.Exists(safePath))
{
RespondError(context, 404, "File not found");
return;
}
string ext = Path.GetExtension(safePath).ToLowerInvariant();
string[] imgExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff" };
if (!imgExts.Contains(ext))
{
RespondError(context, 415, "Unsupported image format");
return;
}
// 3. 高效流式輸出
context.Response.Clear();
context.Response.BufferOutput = false; // 關鍵:大圖不緩衝
context.Response.ContentType = GetMimeType(ext);
context.Response.AddHeader("Content-Length", new FileInfo(safePath).Length.ToString());
context.Response.AddHeader("Cache-Control", "public, max-age=2592000"); // 30天
context.Response.AddHeader("ETag", $"\"{GetFileETag(safePath)}\"");
// 條件請求支援 (304 Not Modified)
string ifNoneMatch = context.Request.Headers["If-None-Match"];
if (!string.IsNullOrEmpty(ifNoneMatch) && ifNoneMatch == $"\"{GetFileETag(safePath)}\"")
{
context.Response.StatusCode = 304;
context.Response.End();
return;
}
try
{
using (FileStream fs = File.OpenRead(safePath))
{
fs.CopyTo(context.Response.OutputStream);
}
context.Response.Flush();
}
catch (Exception ex)
{
// 記錄日誌,不向前端暴露細節
System.Diagnostics.Trace.TraceError($"Image proxy error: {ex}");
context.Response.StatusCode = 500;
}
}
private static string GetMimeType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".bmp" => "image/bmp",
".webp" => "image/webp",
".tiff" => "image/tiff",
_ => "application/octet-stream"
};
private static string GetFileETag(string path)
{
var fi = new FileInfo(path);
return $"{fi.LastWriteTimeUtc.Ticks:x}-{fi.Length:x}";
}
private static void RespondError(HttpContext context, int code, string msg)
{
context.Response.StatusCode = code;
context.Response.ContentType = "text/plain; charset=utf-8";
context.Response.Write(msg);
context.Response.End();
}
public bool IsReusable => true; // 無狀態,線程安全
}
<!-- ASP.NET Web Forms -->
<asp:Image ID="Image1" runat="server"
ImageUrl='<%# "ShowOutSiteImage.ashx?file=" + Server.UrlEncode(Eval("PhysicalPath").ToString()) %>' />
<!-- MVC / Razor -->
<img src="@Url.Content("~/ShowOutSiteImage.ashx?file=" + Url.Encode(Model.PhysicalPath))" />
<!-- 純 HTML/JS 動態 -->
<script>
function loadImage(elementId, physicalPath) {
document.getElementById(elementId).src =
"ShowOutSiteImage.ashx?file=" + encodeURIComponent(physicalPath);
}
</script>
// 靜態緩存字典(生產建議用 MemoryCache / Redis)
private static readonly System.Collections.Concurrent.ConcurrentDictionary _cache
= new System.Collections.Concurrent.ConcurrentDictionary();
// 在 ProcessRequest 開頭添加
if (_cache.TryGetValue(safePath, out byte[] cached))
{
context.Response.Clear();
context.Response.ContentType = GetMimeType(ext);
context.Response.BinaryWrite(cached);
context.Response.End();
return;
}
// 輸出流結束前緩存(限制大小,如 < 500KB)
if (new FileInfo(safePath).Length < 500 * 1024)
{
using (var ms = new MemoryStream())
{
using (var fs = File.OpenRead(safePath)) fs.CopyTo(ms);
_cache.TryAdd(safePath, ms.ToArray());
}
}
| 風險 | 對策 |
|---|---|
| 路徑遍歷 (../../web.config) | `Path.GetFullPath` + 白名單前綴嚴格匹配 |
| 任意文件讀取 (C:\Windows\system32\drivers\etc\hosts) | 副檔名白名單 + 只允許圖片 MIME |
| 目錄遍歷洩露結構 | 不提供列表接口,僅單文件讀取 |
| 大文件 DoS (佔用記憶體/帶寬) | `BufferOutput=false` + 流式複製 + 速率限制 (IP/Token) |
| UNC 憑證洩露 | 應用程式池專用低權限賬戶,不嵌入代碼 |
| 方案 | 適用性 | 性能 | 安全性 | 維護性 |
|---|---|---|---|---|
| ASHX 代理 + 白名單 + 流式 | ✅ 通用、權限控制、UNC 支援 | ⭐⭐⭐⭐ (流式、可緩存、304) | ⭐⭐⭐⭐⭐ (多層防護) | ⭐⭐⭐⭐ (單文件、易擴展) |
| IIS 虛擬目錄直連 | 僅公開靜態資源 | ⭐⭐⭐⭐⭐ (內核傳輸) | ⭐ (無動態權限) | ⭐⭐⭐ (需 IIS 配置) |
| 資料庫存 Blob | 少量、強一致性 | ⭐⭐ (DB 壓力大) | ⭐⭐⭐⭐ | ⭐⭐ (備份/遷移複雜) |
**企業級推薦**:ASHX 代理作為統一圖片訪問網關,配合白名單、流式輸出、ETag 緩存、專用服務賬戶,滿足安全、性能、維護三重需求。