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,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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user