387 lines
15 KiB
C#
387 lines
15 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Reflection;
|
|
using GameList.Infrastructure;
|
|
using GameList.Endpoints;
|
|
using GameList.Tests.Support;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.Configuration;
|
|
using System.Text.Json;
|
|
using System.Net.Http.Json;
|
|
|
|
namespace GameList.Tests;
|
|
|
|
public class HelperTests
|
|
{
|
|
[Fact]
|
|
public void PasswordHasher_roundtrip_and_empty_guard()
|
|
{
|
|
var (hash, salt) = PasswordHasher.HashPassword("secret");
|
|
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("secret", hash, salt, 999, out _));
|
|
|
|
Assert.False(PasswordHasher.Verify("other", hash, salt));
|
|
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Program_does_not_include_runtime_index_rewrite_hook()
|
|
{
|
|
var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
|
|
Assert.False(hasRewriteMethod);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenApi_document_exposes_stable_operation_ids()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClient();
|
|
|
|
var response = await client.GetAsync("/openapi/v1.json");
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
var paths = json.GetProperty("paths");
|
|
|
|
Assert.Equal("Login", paths.GetProperty("/api/auth/login").GetProperty("post").GetProperty("operationId").GetString());
|
|
Assert.Equal("GetState", paths.GetProperty("/api/state").GetProperty("get").GetProperty("operationId").GetString());
|
|
Assert.Equal("CreateSuggestion", paths.GetProperty("/api/suggestions").GetProperty("post").GetProperty("operationId").GetString());
|
|
Assert.Equal("DeletePlayer", paths.GetProperty("/api/admin/players/{playerId}").GetProperty("delete").GetProperty("operationId").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image()
|
|
{
|
|
Assert.True(await EndpointHelpers.IsReachableImageAsync(null, new StubHttpClientFactory(new StubHttpMessageHandler())));
|
|
Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler())));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsReachableImageAsync_handles_head_success_redirect_and_size_guard()
|
|
{
|
|
var handler = new StubHttpMessageHandler();
|
|
handler.SetResponder(req =>
|
|
{
|
|
if (req.Method == HttpMethod.Head)
|
|
{
|
|
var resp = new HttpResponseMessage(HttpStatusCode.OK);
|
|
resp.Content = new ByteArrayContent([]);
|
|
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
|
resp.Content.Headers.ContentLength = 100;
|
|
return resp;
|
|
}
|
|
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent([])
|
|
{
|
|
Headers =
|
|
{
|
|
ContentType = new MediaTypeHeaderValue("image/png"),
|
|
ContentLength = 100
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
var ok = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
|
|
Assert.True(ok);
|
|
|
|
handler.SetResponder(_ =>
|
|
{
|
|
var resp = new HttpResponseMessage(HttpStatusCode.Redirect);
|
|
resp.Headers.Location = new Uri("http://example.com/other");
|
|
return resp;
|
|
});
|
|
var redirect = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
|
|
Assert.False(redirect);
|
|
|
|
handler.SetResponder(_ =>
|
|
{
|
|
var resp = new HttpResponseMessage(HttpStatusCode.OK);
|
|
resp.Content = new ByteArrayContent(new byte[10]);
|
|
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
|
resp.Content.Headers.ContentLength = 6 * 1024 * 1024; // over 5 MB
|
|
return resp;
|
|
});
|
|
var tooLarge = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
|
|
Assert.False(tooLarge);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsReachableImageAsync_rejects_followed_redirect_chain()
|
|
{
|
|
var handler = new RedirectFollowingHandler();
|
|
var reachable = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()), handler);
|
|
Assert.False(reachable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsReachableImageAsync_rejects_non_image_content()
|
|
{
|
|
var handler = new StubHttpMessageHandler();
|
|
handler.SetResponder(_ =>
|
|
{
|
|
var resp = new HttpResponseMessage(HttpStatusCode.OK);
|
|
resp.Content = new ByteArrayContent("not image"u8.ToArray());
|
|
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
|
resp.Content.Headers.ContentLength = 9;
|
|
return resp;
|
|
});
|
|
|
|
var result = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
|
|
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()
|
|
{
|
|
var roots = EndpointHelpers.BuildLinkRoots([(1, null), (2, 1), (3, null)]);
|
|
Assert.Equal(1, roots[1]);
|
|
Assert.Equal(1, roots[2]);
|
|
Assert.Equal(3, roots[3]);
|
|
|
|
var linked = EndpointHelpers.LinkedIdsFor(2, roots);
|
|
Assert.Contains(1, linked);
|
|
Assert.Contains(2, linked);
|
|
}
|
|
|
|
[Fact]
|
|
public void Url_validation_rules()
|
|
{
|
|
Assert.True(EndpointHelpers.IsValidImageUrl("https://x.com/img.png"));
|
|
Assert.False(EndpointHelpers.IsValidImageUrl("ftp://x/img.png"));
|
|
Assert.True(EndpointHelpers.IsValidHttpUrl("http://x"));
|
|
Assert.False(EndpointHelpers.IsValidHttpUrl("file://x"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Find_root_handles_cycles()
|
|
{
|
|
var parentMap = new Dictionary<int, int?>
|
|
{
|
|
{ 1, 2 },
|
|
{ 2, 3 },
|
|
{ 3, 1 }
|
|
};
|
|
|
|
var root = EndpointHelpers.FindRootId(1, parentMap);
|
|
Assert.Equal(3, root); // cycle breaks on revisit
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Global_exception_handler_returns_json_error()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
|
|
{
|
|
builder.Configure(app =>
|
|
{
|
|
app.UseGlobalExceptionLogging();
|
|
app.UseRouting();
|
|
app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); });
|
|
});
|
|
});
|
|
|
|
var client = factory.CreateClient();
|
|
var resp = await client.GetAsync("/boom");
|
|
Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode);
|
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
Assert.Equal("Unexpected server error", json.GetProperty("error").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildForwardedHeadersOptions_reads_only_valid_proxies_and_networks()
|
|
{
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10",
|
|
["ForwardedHeaders:KnownProxies:1"] = "not-an-ip",
|
|
["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/16",
|
|
["ForwardedHeaders:KnownNetworks:1"] = "invalid"
|
|
}).Build();
|
|
|
|
var options = BuildForwardedHeadersOptionsForTest(config);
|
|
|
|
Assert.Single(options.KnownProxies);
|
|
Assert.Single(options.KnownIPNetworks);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Forwarded_headers_are_ignored_for_untrusted_proxy()
|
|
{
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10"
|
|
}).Build();
|
|
var options = BuildForwardedHeadersOptionsForTest(config);
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
|
|
await using var app = builder.Build();
|
|
app.Use((ctx, next) =>
|
|
{
|
|
ctx.Connection.RemoteIpAddress = IPAddress.Parse("198.51.100.25");
|
|
return next();
|
|
});
|
|
app.UseForwardedHeaders(options);
|
|
app.MapGet("/scheme", (HttpContext ctx) => ctx.Request.Scheme);
|
|
|
|
await app.StartAsync();
|
|
try
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Get, "/scheme");
|
|
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
|
var response = await app.GetTestClient().SendAsync(request);
|
|
var scheme = await response.Content.ReadAsStringAsync();
|
|
|
|
Assert.Equal("http", scheme);
|
|
}
|
|
finally
|
|
{
|
|
await app.StopAsync();
|
|
}
|
|
}
|
|
|
|
[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());
|
|
|
|
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]
|
|
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 static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)
|
|
{
|
|
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));
|
|
return (ForwardedHeadersOptions)method.Invoke(null, [config])!;
|
|
}
|
|
|
|
private sealed class RedirectFollowingHandler : HttpMessageHandler
|
|
{
|
|
private static readonly Uri Source = new("http://example.com/img.png");
|
|
private static readonly Uri Destination = new("http://example.com/final.png");
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
if (request.RequestUri != Source)
|
|
{
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request });
|
|
}
|
|
|
|
var redirectedRequest = new HttpRequestMessage(request.Method, Destination);
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
RequestMessage = redirectedRequest,
|
|
Content = new ByteArrayContent("PNG"u8.ToArray())
|
|
};
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
|
response.Content.Headers.ContentLength = 3;
|
|
|
|
return Task.FromResult(response);
|
|
}
|
|
}
|
|
}
|