Merge branch 'main' into dev
This commit is contained in:
commit
1f70c8d31e
9 files changed
+496
-7
No files matched your search
@@ -119,6 +119,7 @@ public static class ApiModule
|
||||
|
||||
OpsEndpoints.Map(app);
|
||||
app.MapOpenApi("/openapi/v1.json");
|
||||
app.MapStaticSite();
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,11 @@
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
</ItemGroup>
|
||||
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
||||
<EmbeddedResource Include="..\..\..\web\display\dist\**\*" Exclude="..\..\..\web\display\dist\**\*.map" LogicalName="www/display/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="Exists('..\..\..\web\admin\dist\index.html')">
|
||||
<EmbeddedResource Include="..\..\..\web\admin\dist\**\*" Exclude="..\..\..\web\admin\dist\**\*.map" LogicalName="www/admin/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using MoShiro.Backend.Api;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404;
|
||||
/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。
|
||||
/// </summary>
|
||||
public sealed class StaticSiteTests
|
||||
{
|
||||
private static StaticSite Site() => new(
|
||||
[
|
||||
("/index.html", Encoding.UTF8.GetBytes("<html>display</html>")),
|
||||
("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")),
|
||||
("/flags/cn.svg", Encoding.UTF8.GetBytes("<svg/>")),
|
||||
("/admin/index.html", Encoding.UTF8.GetBytes("<html>admin</html>")),
|
||||
("/admin/assets/app-3c4d.css", Encoding.UTF8.GetBytes("body{}")),
|
||||
("/../secret.txt", Encoding.UTF8.GetBytes("must never be registered")),
|
||||
]);
|
||||
|
||||
private static string Text(SiteAsset? asset) => Encoding.UTF8.GetString(asset!.Content);
|
||||
|
||||
[Fact]
|
||||
public void KnownFiles_AreServed_WithTypeAndCaching()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/").Asset));
|
||||
|
||||
SiteAsset? js = site.Resolve("/assets/app-1a2b.js").Asset;
|
||||
Assert.StartsWith("text/javascript", js!.ContentType, StringComparison.Ordinal);
|
||||
Assert.Contains("immutable", js.CacheControl, StringComparison.Ordinal);
|
||||
Assert.False(js.Html);
|
||||
|
||||
SiteAsset? index = site.Resolve("/index.html").Asset;
|
||||
Assert.Equal("no-cache", index!.CacheControl);
|
||||
Assert.True(index.Html);
|
||||
Assert.Equal("image/svg+xml", site.Resolve("/flags/cn.svg").Asset!.ContentType);
|
||||
Assert.StartsWith("text/css", site.Resolve("/admin/assets/app-3c4d.css").Asset!.ContentType, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminWithoutSlash_RedirectsToAFixedPath()
|
||||
{
|
||||
SiteResolution resolution = Site().Resolve("/admin");
|
||||
|
||||
Assert.Null(resolution.Asset);
|
||||
Assert.Equal("/admin/", resolution.Redirect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/../secret.txt")]
|
||||
[InlineData("/admin/../../etc/passwd")]
|
||||
[InlineData("/assets/../index.html")]
|
||||
[InlineData("/./index.html")]
|
||||
[InlineData("/%2e%2e/secret.txt")]
|
||||
[InlineData("/%2E%2E%2Fsecret.txt")]
|
||||
[InlineData("/..%2fsecret.txt")]
|
||||
[InlineData("/..%5csecret.txt")]
|
||||
[InlineData("/%252e%252e/secret.txt")]
|
||||
[InlineData("/..\\secret.txt")]
|
||||
[InlineData("/assets\\app-1a2b.js")]
|
||||
[InlineData("/assets//app-1a2b.js")]
|
||||
[InlineData("//assets/app-1a2b.js")]
|
||||
[InlineData("/index.html\0.png")]
|
||||
[InlineData("/index.html\r\nSet-Cookie: a=b")]
|
||||
[InlineData("/文件")]
|
||||
[InlineData("assets/app-1a2b.js")]
|
||||
[InlineData("")]
|
||||
public void TraversalAndMalformedPaths_AreNotFound_NeverNormalized(string path)
|
||||
{
|
||||
SiteResolution resolution = Site().Resolve(path);
|
||||
|
||||
Assert.Null(resolution.Asset);
|
||||
Assert.Null(resolution.Redirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongPath_IsNotFound()
|
||||
{
|
||||
Assert.Null(Site().Resolve("/" + new string('a', 5000)).Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpaFallback_OnlyForPathsWithoutAnExtension()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/lines/abc").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/agents").Asset));
|
||||
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/system/trust").Asset));
|
||||
|
||||
// 缺失的文件不是页面:脚本、样式、图片 404 要保持 404,不能变成 HTML
|
||||
Assert.Null(site.Resolve("/assets/missing.js").Asset);
|
||||
Assert.Null(site.Resolve("/admin/assets/missing.css").Asset);
|
||||
Assert.Null(site.Resolve("/favicon.ico").Asset);
|
||||
Assert.Null(site.Resolve("/robots.txt").Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminAndDisplay_AreNotMixed()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
// 展示端的路径永远不会得到管理端的页面,反过来管理端的前缀之外也一样
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/administrator").Asset));
|
||||
Assert.Equal("<html>display</html>", Text(site.Resolve("/adminx/agents").Asset));
|
||||
Assert.Null(site.Resolve("/admin/index.htm").Asset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptySite_ServesNothing()
|
||||
{
|
||||
var site = new StaticSite([]);
|
||||
|
||||
Assert.Equal(0, site.Count);
|
||||
Assert.Null(site.Resolve("/").Asset);
|
||||
Assert.Null(site.Resolve("/admin/agents").Asset);
|
||||
Assert.Null(site.Resolve("/admin").Redirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UncleanPathsAreRejectedWhenRegistering()
|
||||
{
|
||||
StaticSite site = Site();
|
||||
|
||||
Assert.Equal(5, site.Count - 2); // 五个干净的文件;两个别名 "/" 和 "/admin/" 不算;"/../secret.txt" 没登记
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class StaticSiteHttpTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public StaticSiteHttpTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/admin/does-not-exist")]
|
||||
[InlineData("/api/public/does-not-exist")]
|
||||
[InlineData("/api/agent/v1/nope")]
|
||||
[InlineData("/api")]
|
||||
[InlineData("/openapi/missing.json")]
|
||||
[InlineData("/install/missing")]
|
||||
[InlineData("/healthz/x")]
|
||||
public async Task BackendPaths_KeepTheirOwnNotFound_NotTheFrontend(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("<html", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/admin/agents")]
|
||||
[InlineData("/healthz")]
|
||||
public async Task ExistingEndpoints_AreNotShadowedByTheCatchAll(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.DoesNotContain("<html", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/../etc/passwd")]
|
||||
[InlineData("/%2e%2e/etc/passwd")]
|
||||
[InlineData("/admin/..%2f..%2fetc/passwd")]
|
||||
[InlineData("/..%5c..%5cwindows/win.ini")]
|
||||
public async Task Traversal_NeverReturnsAFileFromTheHost(string path)
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync(path);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.DoesNotContain("root:", body, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("[fonts]", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritesToTheSite_AreRejected()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
foreach (HttpMethod method in new[] { HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch })
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, "/admin/index.html");
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
Assert.True(response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotFound, $"{method}: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pages_CarryTheSecurityHeaders_WhenTheFrontendIsEmbedded()
|
||||
{
|
||||
using HttpClient client = _backend.NewClient();
|
||||
using HttpResponseMessage response = await client.GetAsync("/admin/");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return; // 本地没构建前端时不嵌入(CI 的 Backend 测试同样没有);响应头的逻辑由上面的单元测试和发布构建覆盖
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
||||
string csp = response.Headers.GetValues("Content-Security-Policy").Single();
|
||||
Assert.Contains("script-src 'self'", csp, StringComparison.Ordinal);
|
||||
Assert.Contains("frame-ancestors 'none'", csp, StringComparison.Ordinal);
|
||||
Assert.Equal("no-cache", response.Headers.CacheControl!.ToString());
|
||||
|
||||
string etag = response.Headers.ETag!.ToString();
|
||||
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/admin/");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||
using HttpResponseMessage notModified = await client.SendAsync(conditional);
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ export MOSHIRO_TAG="${MOSHIRO_TAG:-${tag:-main-${CI_COMMIT_SHA:0:10}}}"
|
||||
export MOSHIRO_COMMIT="${MOSHIRO_COMMIT:-${CI_COMMIT_SHA:0:10}}"
|
||||
export MOSHIRO_BUILT_AT="${MOSHIRO_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
|
||||
# 前端先构建:Backend.Api 在 web/*/dist 存在时把它们内嵌进程序(展示端 /,管理端 /admin/),这样 passkey 能绑定在面板自己的域名上
|
||||
if [[ -z "${NPM_CONFIG_CACHE:-}" && -d /home/justme/.npm ]]; then export NPM_CONFIG_CACHE=/home/justme/.npm; fi
|
||||
(cd "$root" && npm ci --prefix web --prefer-offline && npm run build --prefix web)
|
||||
[[ -f "$root/web/display/dist/index.html" && -f "$root/web/admin/dist/index.html" ]] || { echo "the web build did not produce both apps" >&2; exit 1; }
|
||||
|
||||
(cd "$root/backend" && dotnet publish src/MoShiro.Backend \
|
||||
-m:3 -c Release -r "$rid" --self-contained true \
|
||||
-p:DebugType=none -p:DebugSymbols=false \
|
||||
|
||||
@@ -40,6 +40,8 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。
|
||||
|
||||
Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:<version>` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。
|
||||
|
||||
**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
|
||||
|
||||
在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MOSHIRO_ADMIN_PASSWORD`(可选设置 `MOSHIRO_ADMIN_USER`、`MOSHIRO_VERSION`),然后启动:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -244,7 +244,8 @@ export function App() {
|
||||
}
|
||||
if (!me) return <Login />
|
||||
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||||
if (me.kind === 'session' && me.auth_method === 'password') return <PasskeyOnly />
|
||||
// 以数据源方式(会话令牌)连别的后端时 kind 是 token,所以只看登录方式
|
||||
if (me.auth_method === 'password') return <PasskeyOnly />
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
@@ -271,7 +272,7 @@ export function App() {
|
||||
<span className="grow">
|
||||
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
|
||||
<div className="faint" style={{ fontSize: 12 }}>
|
||||
{me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
|
||||
{me.kind === 'token' && !me.auth_method ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
|
||||
</div>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { KeyRound, LogOut } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import { getBase } from '@shared/api/source'
|
||||
import type { Page, PasskeyDto } from '@shared/api/types'
|
||||
import { passkeySupported } from '@shared/api/webauthn'
|
||||
import { useAuth } from './auth'
|
||||
@@ -23,7 +22,6 @@ export function PasskeyOnly() {
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
const remote = !!getBase()
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||
@@ -34,9 +32,7 @@ export function PasskeyOnly() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="notice">用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。</div>
|
||||
{remote ? (
|
||||
<div className="notice warn">这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。</div>
|
||||
) : !passkeySupported() ? (
|
||||
{!passkeySupported() ? (
|
||||
<div className="notice warn">这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。</div>
|
||||
) : count === 0 || count === null ? (
|
||||
<div className="stack">
|
||||
|
||||
Reference in new issue
Block a user