Harden app security controls from audit
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
Reference in New Issue
Block a user