feat: serve the web apps from the Backend (embedded, allow-listed, no filesystem access) so passkeys bind to the panel's own domain [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
ci/woodpecker/push/main-amd64-build Pipeline failed
This commit is contained in:
1 parent
4a3631f82b
commit
fbda9487d5
7 files changed
+492
No files matched your search
@@ -0,0 +1,172 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user