Harden CSRF/CSP and add hash version upgrades
This commit is contained in:
@@ -107,6 +107,37 @@ public class AuthTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_upgrades_legacy_password_hash_version()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("rehashme");
|
||||
|
||||
byte[] originalHash = [];
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.SingleAsync();
|
||||
var (legacyHash, legacySalt) = PasswordHasher.HashPassword("Pass123!", PasswordHasher.LegacyVersion);
|
||||
|
||||
originalHash = legacyHash.ToArray();
|
||||
player.PasswordHash = legacyHash;
|
||||
player.PasswordSalt = legacySalt;
|
||||
player.PasswordHashVersion = PasswordHasher.LegacyVersion;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var login = await client.LoginAsync("rehashme", "Pass123!");
|
||||
login.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
|
||||
Assert.False(player.PasswordHash.SequenceEqual(originalHash));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_with_admin_key_sets_admin_flag()
|
||||
{
|
||||
|
||||
@@ -21,7 +21,13 @@ public class HelperTests
|
||||
public void PasswordHasher_roundtrip_and_empty_guard()
|
||||
{
|
||||
var (hash, salt) = PasswordHasher.HashPassword("secret");
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt));
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt, PasswordHasher.CurrentVersion, out var currentNeedsRehash));
|
||||
Assert.False(currentNeedsRehash);
|
||||
|
||||
var (legacyHash, legacySalt) = PasswordHasher.HashPassword("secret", PasswordHasher.LegacyVersion);
|
||||
Assert.True(PasswordHasher.Verify("secret", legacyHash, legacySalt, PasswordHasher.LegacyVersion, out var legacyNeedsRehash));
|
||||
Assert.True(legacyNeedsRehash);
|
||||
|
||||
Assert.False(PasswordHasher.Verify("other", hash, salt));
|
||||
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
|
||||
}
|
||||
@@ -264,7 +270,11 @@ public class HelperTests
|
||||
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());
|
||||
|
||||
var csp = response.Headers.GetValues("Content-Security-Policy").Single();
|
||||
Assert.Contains("default-src 'self'", csp);
|
||||
Assert.DoesNotContain("'unsafe-inline'", csp, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("http:", csp, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Tests.Support;
|
||||
|
||||
namespace GameList.Tests;
|
||||
@@ -36,4 +38,49 @@ public class MiddlewareTests
|
||||
var resp = await client.GetAsync("/api/state");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_without_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfm");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_with_cross_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfx");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", "https://evil.example");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
public HttpClient CreateClientWithCookies()
|
||||
{
|
||||
return CreateClient(new WebApplicationFactoryClientOptions
|
||||
var client = CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = true,
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (client.BaseAddress is { } baseAddress)
|
||||
{
|
||||
var origin = $"{baseAddress.Scheme}://{baseAddress.Authority}";
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", origin);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user