Harden app security controls from audit

This commit is contained in:
2026-02-08 18:40:13 +01:00
parent a6364b0802
commit 42e60d2a5a
20 changed files with 689 additions and 109 deletions

View File

@@ -62,6 +62,24 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
}
[Fact]
public async Task Register_rejects_weak_passwords()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var weak = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "weakpw",
Password = "alllowercase1!",
DisplayName = "weak"
});
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Password must include uppercase, lowercase, number, and symbol.", json.GetProperty("error").GetString());
}
[Fact]
public async Task Login_sets_last_login_and_fills_missing_display_name()
{
@@ -101,6 +119,23 @@ public class AuthTests
Assert.True(json.GetProperty("isAdmin").GetBoolean());
}
[Fact]
public async Task Register_admin_key_is_bootstrap_only()
{
await using var factory = new TestWebApplicationFactory();
var first = factory.CreateClientWithCookies();
var second = factory.CreateClientWithCookies();
var firstAdmin = await first.RegisterAsync("firstadmin", admin: true);
firstAdmin.EnsureSuccessStatusCode();
var secondAdmin = await second.RegisterAsync("secondadmin", admin: true);
Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode);
var body = await secondAdmin.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Admin registration via admin key is disabled after the first admin account.", body.GetProperty("error").GetString());
}
[Fact]
public async Task Register_duplicate_username_returns_conflict()
{

View File

@@ -142,6 +142,31 @@ public class HelperTests
Assert.False(result);
}
[Fact]
public async Task IsReachableImageAsync_rejects_private_and_reserved_ranges()
{
var factory = new StubHttpClientFactory(new StubHttpMessageHandler());
var blockedUrls = new[]
{
"http://0.0.0.1/img.png",
"http://10.0.0.1/img.png",
"http://100.64.1.1/img.png",
"http://169.254.169.254/img.png",
"http://192.168.0.20/img.png",
"http://198.51.100.2/img.png",
"http://203.0.113.8/img.png",
"http://[::1]/img.png",
"http://[fc00::1]/img.png",
"http://[::ffff:127.0.0.1]/img.png"
};
foreach (var url in blockedUrls)
{
var reachable = await EndpointHelpers.IsReachableImageAsync(url, factory);
Assert.False(reachable);
}
}
[Fact]
public void Link_root_helpers_handle_groups()
{
@@ -252,6 +277,78 @@ public class HelperTests
}
}
[Fact]
public async Task Security_headers_are_applied_to_responses()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/health");
response.EnsureSuccessStatusCode();
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
Assert.Equal("no-referrer", response.Headers.GetValues("Referrer-Policy").Single());
Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single());
}
[Fact]
public async Task Auth_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("ratelimit-user");
HttpResponseMessage? last = null;
for (var i = 0; i < 8; i++)
{
last = await client.PostAsJsonAsync("/api/auth/login", new
{
Username = "ratelimit-user",
Password = "wrong-pass"
});
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public async Task Admin_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("ratelimit-admin", admin: true);
HttpResponseMessage? last = null;
for (var i = 0; i < 25; i++)
{
last = await admin.GetAsync("/api/admin/vote-status");
if (last.StatusCode == HttpStatusCode.TooManyRequests)
break;
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public void Frontend_regressions_prevent_modal_html_interpolation_for_untrusted_values()
{
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var modalJsPath = Path.Combine(root, "wwwroot", "js", "modals-ui.js");
var adminJsPath = Path.Combine(root, "wwwroot", "js", "admin-ui.js");
var modalJs = File.ReadAllText(modalJsPath);
var adminJs = File.ReadAllText(adminJsPath);
Assert.DoesNotContain("<h3>${title}</h3>", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("<p>${body}</p>", modalJs, StringComparison.Ordinal);
Assert.Contains("heading.textContent = title ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.Contains("bodyText.textContent = body ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
}
private class FakeEnv : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "";