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

This commit is contained in:
justme committed 2026-10-08 04:09:40 +08:00
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",
};
}
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace MoShiro.Backend.Api;
/// <summary>把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。</summary>
public static class StaticSiteEndpoints
{
/// <summary>后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。</summary>
private static readonly string[] s_reserved = ["/api", "/openapi", "/install", "/healthz"];
// 页面的 CSP:脚本只许同源;样式允许内联(React 的 style 属性);连接允许 https(管理端可以添加别的后端作为数据源)和本机 http
private const string PageCsp =
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; "
+ "connect-src 'self' https: http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
private const string AssetCsp = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
public static IEndpointRouteBuilder MapStaticSite(this IEndpointRouteBuilder app)
{
StaticSite site = StaticSite.FromAssembly(typeof(StaticSite).Assembly);
app.MapMethods("/{**path}", ["GET", "HEAD"], (HttpContext http) => Serve(site, http)).ExcludeFromDescription();
return app;
}
internal static IResult Serve(StaticSite site, HttpContext http)
{
string path = http.Request.Path.Value ?? "/";
if (IsReserved(path))
{
return Results.StatusCode(StatusCodes.Status404NotFound);
}
SiteResolution resolution = site.Resolve(path);
if (resolution.Redirect is { } location)
{
// 固定的站内路径,不拼请求里的任何东西(没有开放重定向,也不依赖 Host)
return Results.Redirect(location, permanent: true);
}
if (resolution.Asset is not { } asset)
{
return Results.StatusCode(StatusCodes.Status404NotFound);
}
IHeaderDictionary headers = http.Response.Headers;
headers.XContentTypeOptions = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
headers.ETag = asset.ETag;
headers.CacheControl = asset.CacheControl;
headers.ContentSecurityPolicy = asset.Html ? PageCsp : AssetCsp;
if (asset.Html)
{
headers.XFrameOptions = "DENY";
headers["Permissions-Policy"] = "publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=()";
}
if (http.Request.Headers.IfNoneMatch.Contains(asset.ETag))
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
return Results.Bytes(asset.Content, asset.ContentType);
}
private static bool IsReserved(string path)
{
foreach (string prefix in s_reserved)
{
if (path.StartsWith(prefix, StringComparison.Ordinal) && (path.Length == prefix.Length || path[prefix.Length] == '/' || path[prefix.Length] == '.'))
{
return true;
}
}
return false;
}
}