228 lines
8.9 KiB
C#
228 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|