173 lines
6.8 KiB
C#
173 lines
6.8 KiB
C#
using System.Reflection;
|
||
using System.Security.Cryptography;
|
||
|
||
namespace MoShiro.Backend.Api;
|
||
|
||
/// <summary>一个可提供的前端文件。<c>Html</c> 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。</summary>
|
||
public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
|
||
|
||
/// <summary>
|
||
/// 解析的结果:<c>Asset</c> 是要返回的文件;<c>Redirect</c> 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
|
||
/// </summary>
|
||
public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
|
||
|
||
/// <summary>
|
||
/// 内嵌在 Backend 里的前端:展示端在 <c>/</c>,管理端在 <c>/admin/</c>。
|
||
/// <para>
|
||
/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
|
||
/// 文件全部是启动时从程序集资源读进内存的<b>白名单</b>,请求时<b>不碰文件系统</b>,所以没有路径穿越和符号链接的问题;
|
||
/// 路径里出现 <c>..</c>、<c>.</c>、反斜杠、<c>%</c>(没解码干净的编码)、控制字符、空段(<c>//</c>)、过长,一律 404,不去「规范化」它;
|
||
/// SPA 回退只给没有扩展名的路径,缺失的文件(<c>/assets/x.js</c>)返回 404 而不是 HTML;
|
||
/// <c>/api</c> 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class StaticSite
|
||
{
|
||
public const string AdminPrefix = "/admin/";
|
||
private const int MaxPathLength = 1024;
|
||
|
||
private readonly Dictionary<string, SiteAsset> _files = new(StringComparer.Ordinal);
|
||
private readonly SiteAsset? _displayIndex;
|
||
private readonly SiteAsset? _adminIndex;
|
||
|
||
/// <param name="files">(站内路径,内容);站内路径以 <c>/</c> 开头,管理端的以 <c>/admin/</c> 开头。</param>
|
||
public StaticSite(IEnumerable<(string Path, byte[] Content)> files)
|
||
{
|
||
foreach ((string path, byte[] content) in files)
|
||
{
|
||
if (!IsCleanPath(path))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
_files[path] = Describe(path, content);
|
||
}
|
||
|
||
_displayIndex = _files.GetValueOrDefault("/index.html");
|
||
_adminIndex = _files.GetValueOrDefault(AdminPrefix + "index.html");
|
||
if (_displayIndex is not null)
|
||
{
|
||
_files["/"] = _displayIndex;
|
||
}
|
||
|
||
if (_adminIndex is not null)
|
||
{
|
||
_files[AdminPrefix] = _adminIndex;
|
||
}
|
||
}
|
||
|
||
public int Count => _files.Count;
|
||
|
||
/// <summary>从程序集的嵌入资源读取:<c>www/display/…</c> 对应 <c>/…</c>,<c>www/admin/…</c> 对应 <c>/admin/…</c>。没有嵌入前端(本地没构建)时是空站点。</summary>
|
||
public static StaticSite FromAssembly(Assembly assembly)
|
||
{
|
||
var files = new List<(string, byte[])>();
|
||
foreach (string name in assembly.GetManifestResourceNames())
|
||
{
|
||
string? url = name.StartsWith("www/display/", StringComparison.Ordinal) ? "/" + name["www/display/".Length..]
|
||
: name.StartsWith("www/admin/", StringComparison.Ordinal) ? AdminPrefix + name["www/admin/".Length..]
|
||
: null;
|
||
if (url is null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
using Stream stream = assembly.GetManifestResourceStream(name)!;
|
||
using var memory = new MemoryStream();
|
||
stream.CopyTo(memory);
|
||
files.Add((url.Replace('\\', '/'), memory.ToArray()));
|
||
}
|
||
|
||
return new StaticSite(files);
|
||
}
|
||
|
||
public SiteResolution Resolve(string? path)
|
||
{
|
||
if (!IsCleanPath(path))
|
||
{
|
||
return default;
|
||
}
|
||
|
||
if (_files.TryGetValue(path, out SiteAsset? exact))
|
||
{
|
||
return new SiteResolution(exact, null);
|
||
}
|
||
|
||
if (path == "/admin")
|
||
{
|
||
return _adminIndex is null ? default : new SiteResolution(null, AdminPrefix);
|
||
}
|
||
|
||
// 缺失的文件不回退成 HTML(否则脚本和样式 404 变成「HTML 当脚本」,很难排查,也可能被缓存污染)
|
||
string last = path[(path.LastIndexOf('/') + 1)..];
|
||
if (last.Contains('.', StringComparison.Ordinal))
|
||
{
|
||
return default;
|
||
}
|
||
|
||
SiteAsset? index = path.StartsWith(AdminPrefix, StringComparison.Ordinal) ? _adminIndex : _displayIndex;
|
||
return index is null ? default : new SiteResolution(index, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 路径是不是「干净」的:以 <c>/</c> 开头、不过长、没有反斜杠、<c>%</c>、控制字符、<c>.</c> / <c>..</c> 段和中间的空段。
|
||
/// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(<c>%2e%2e</c>、<c>..%5c</c>、双重编码)的来源。
|
||
/// </summary>
|
||
public static bool IsCleanPath([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path)
|
||
{
|
||
if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length > MaxPathLength)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
foreach (char c in path)
|
||
{
|
||
if (c is '\\' or '%' or '\0' || c < 0x20 || c == 0x7f || c > 0x7e)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
string[] segments = path.Split('/');
|
||
for (int i = 1; i < segments.Length; i++)
|
||
{
|
||
string segment = segments[i];
|
||
bool trailing = i == segments.Length - 1;
|
||
if (segment is "." or ".." || (segment.Length == 0 && !trailing))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static SiteAsset Describe(string path, byte[] content)
|
||
{
|
||
string extension = Path.GetExtension(path).ToLowerInvariant();
|
||
bool html = extension == ".html";
|
||
string etag = "\"" + Convert.ToHexStringLower(SHA256.HashData(content))[..32] + "\"";
|
||
string cache = html ? "no-cache" : path.Contains("/assets/", StringComparison.Ordinal) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
||
return new SiteAsset(content, ContentTypeOf(extension), etag, cache, html);
|
||
}
|
||
|
||
private static string ContentTypeOf(string extension) => extension switch
|
||
{
|
||
".html" => "text/html; charset=utf-8",
|
||
".js" or ".mjs" => "text/javascript; charset=utf-8",
|
||
".css" => "text/css; charset=utf-8",
|
||
".svg" => "image/svg+xml",
|
||
".png" => "image/png",
|
||
".jpg" or ".jpeg" => "image/jpeg",
|
||
".webp" => "image/webp",
|
||
".gif" => "image/gif",
|
||
".ico" => "image/x-icon",
|
||
".woff2" => "font/woff2",
|
||
".woff" => "font/woff",
|
||
".json" => "application/json",
|
||
".webmanifest" => "application/manifest+json",
|
||
".txt" => "text/plain; charset=utf-8",
|
||
_ => "application/octet-stream",
|
||
};
|
||
}
|