From fbda9487d5b61e3b5f891f008656cb66e7867ead Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 04:09:40 +0800 Subject: [PATCH] feat: serve the web apps from the Backend (embedded, allow-listed, no filesystem access) so passkeys bind to the panel's own domain [CI] --- backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../MoShiro.Backend.Api.csproj | 7 + .../src/MoShiro.Backend.Api/Web/StaticSite.cs | 172 +++++++++++++ .../Web/StaticSiteEndpoints.cs | 78 ++++++ .../MoShiro.Backend.Tests/StaticSiteTests.cs | 227 ++++++++++++++++++ ci/release-linux.sh | 5 + docs/deployment.md | 2 + 7 files changed, 492 insertions(+) create mode 100644 backend/src/MoShiro.Backend.Api/Web/StaticSite.cs create mode 100644 backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index f064f0e..d47b1f3 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -119,6 +119,7 @@ public static class ApiModule OpsEndpoints.Map(app); app.MapOpenApi("/openapi/v1.json"); + app.MapStaticSite(); return app; } diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index 3ef7001..f824f56 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -11,4 +11,11 @@ + + + + + + + diff --git a/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs b/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs new file mode 100644 index 0000000..2b892a1 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs @@ -0,0 +1,172 @@ +using System.Reflection; +using System.Security.Cryptography; + +namespace MoShiro.Backend.Api; + +/// 一个可提供的前端文件。Html 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。 +public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html); + +/// +/// 解析的结果:Asset 是要返回的文件;Redirect 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。 +/// +public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect); + +/// +/// 内嵌在 Backend 里的前端:展示端在 /,管理端在 /admin/。 +/// +/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题): +/// 文件全部是启动时从程序集资源读进内存的白名单,请求时不碰文件系统,所以没有路径穿越和符号链接的问题; +/// 路径里出现 ..、.、反斜杠、%(没解码干净的编码)、控制字符、空段(//)、过长,一律 404,不去「规范化」它; +/// SPA 回退只给没有扩展名的路径,缺失的文件(/assets/x.js)返回 404 而不是 HTML; +/// /api 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。 +/// +/// +public sealed class StaticSite +{ + public const string AdminPrefix = "/admin/"; + private const int MaxPathLength = 1024; + + private readonly Dictionary _files = new(StringComparer.Ordinal); + private readonly SiteAsset? _displayIndex; + private readonly SiteAsset? _adminIndex; + + /// (站内路径,内容);站内路径以 / 开头,管理端的以 /admin/ 开头。 + 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; + + /// 从程序集的嵌入资源读取:www/display/… 对应 /…,www/admin/… 对应 /admin/…。没有嵌入前端(本地没构建)时是空站点。 + 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); + } + + /// + /// 路径是不是「干净」的:以 / 开头、不过长、没有反斜杠、%、控制字符、. / .. 段和中间的空段。 + /// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(%2e%2e、..%5c、双重编码)的来源。 + /// + 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", + }; +} diff --git a/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs b/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs new file mode 100644 index 0000000..cf786d1 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MoShiro.Backend.Api; + +/// 把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。 +public static class StaticSiteEndpoints +{ + /// 后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。 + 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; + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs new file mode 100644 index 0000000..a16e76d --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Text; +using MoShiro.Backend.Api; + +namespace MoShiro.Backend.Tests; + +/// +/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404; +/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。 +/// +public sealed class StaticSiteTests +{ + private static StaticSite Site() => new( + [ + ("/index.html", Encoding.UTF8.GetBytes("display")), + ("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")), + ("/flags/cn.svg", Encoding.UTF8.GetBytes("")), + ("/admin/index.html", Encoding.UTF8.GetBytes("admin")), + ("/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("display", Text(site.Resolve("/").Asset)); + Assert.Equal("admin", 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("display", Text(site.Resolve("/lines/abc").Asset)); + Assert.Equal("admin", Text(site.Resolve("/admin/agents").Asset)); + Assert.Equal("admin", 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("display", Text(site.Resolve("/administrator").Asset)); + Assert.Equal("display", 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" 没登记 + } +} + +/// HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。 +[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("&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 \ diff --git a/docs/deployment.md b/docs/deployment.md index f534dd0..150fd42 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -40,6 +40,8 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。 Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:` 分发,支持 `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