mirror of
https://github.com/239573049/FastGateway.git
synced 2026-10-16 21:10:58 +08:00
test(bot-protection): 增加 Turnstile 服务测试
This commit is contained in:
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TunnelClient", "src\TunnelC
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Certes", "src\Certes\Certes.csproj", "{A6D3C652-9825-4D51-88AF-1F91E61348E4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastGateway.Tests", "tests\FastGateway.Tests\FastGateway.Tests.csproj", "{E7BBE0C4-33E1-4DD7-8B69-2E2C47052A7D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FastGateway.Dto;
|
||||
using FastGateway.Infrastructure;
|
||||
using FastGateway.Options;
|
||||
using FastGateway.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace FastGateway.Tests;
|
||||
|
||||
public sealed class BotProtectionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateClearance_ValidatesHostRoutePathAndSignature()
|
||||
{
|
||||
var service = CreateService();
|
||||
var clearance = service.CreateClearance("Example.com:443", "route-a", "docs", "203.0.113.10");
|
||||
|
||||
Assert.True(service.ValidateClearance(clearance, "example.com", "route-a", "/docs", "203.0.113.10"));
|
||||
Assert.False(service.ValidateClearance(clearance, "other.example.com", "route-a", "/docs", "203.0.113.10"));
|
||||
Assert.False(service.ValidateClearance(clearance, "example.com", "route-b", "/docs", "203.0.113.10"));
|
||||
Assert.False(service.ValidateClearance(clearance, "example.com", "route-a", "/other", "203.0.113.10"));
|
||||
Assert.False(service.ValidateClearance(Tamper(clearance), "example.com", "route-a", "/docs", "203.0.113.10"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateClearance_WhenIpBindingEnabled_RejectsDifferentIp()
|
||||
{
|
||||
var service = CreateService(options: new BotProtectionOptions { BindClearanceToIp = true });
|
||||
var clearance = service.CreateClearance("example.com", "route-a", "/", "203.0.113.10");
|
||||
|
||||
Assert.True(service.ValidateClearance(clearance, "example.com", "route-a", "/", "203.0.113.10"));
|
||||
Assert.False(service.ValidateClearance(clearance, "example.com", "route-a", "/", "203.0.113.11"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChallengeState_RejectsUnsafeReturnUrlAndConsumesOnce()
|
||||
{
|
||||
var service = CreateService();
|
||||
var unsafeState = service.CreateChallengeState("example.com", "route-a", "https://evil.example/", "/", "business-access");
|
||||
Assert.False(service.TryReadChallengeState(unsafeState, "example.com", out _));
|
||||
|
||||
var state = service.CreateChallengeState("example.com", "route-a", "/docs?q=1", "docs", "business-access");
|
||||
Assert.True(service.TryReadChallengeState(state, "example.com", out var payload));
|
||||
Assert.Equal("/docs?q=1", payload.ReturnUrl);
|
||||
Assert.Equal("/docs", payload.RoutePath);
|
||||
Assert.True(service.TryConsumeChallengeState(state, "example.com", out _));
|
||||
Assert.False(service.TryConsumeChallengeState(state, "example.com", out _));
|
||||
Assert.False(service.TryReadChallengeState(state, "other.example.com", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRouteMetadata_DefaultsOffAndPreservesExistingMetadata()
|
||||
{
|
||||
var existing = new Dictionary<string, string> { ["existing"] = "value" };
|
||||
var unprotected = new Core.Entities.DomainName { Id = "route-a", EnableBotProtection = false };
|
||||
var protectedRoute = new Core.Entities.DomainName { Id = "route-a", EnableBotProtection = true };
|
||||
|
||||
var withoutProtection = BotProtectionService.CreateRouteMetadata(unprotected, "/");
|
||||
var withProtection = BotProtectionService.CreateRouteMetadata(protectedRoute, "/docs", existing);
|
||||
|
||||
Assert.Empty(withoutProtection);
|
||||
Assert.Equal("value", withProtection["existing"]);
|
||||
Assert.Equal("true", withProtection[BotProtectionService.ProtectionMetadataKey]);
|
||||
Assert.Equal("route-a", withProtection[BotProtectionService.RouteIdMetadataKey]);
|
||||
Assert.Equal("/docs", withProtection[BotProtectionService.RoutePathMetadataKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConsumeVerifyRequest_EnforcesPerIpWindow()
|
||||
{
|
||||
var service = CreateService(options: new BotProtectionOptions { VerifyRequestsPerMinute = 2 });
|
||||
|
||||
Assert.True(service.TryConsumeVerifyRequest("203.0.113.10"));
|
||||
Assert.True(service.TryConsumeVerifyRequest("203.0.113.10"));
|
||||
Assert.False(service.TryConsumeVerifyRequest("203.0.113.10"));
|
||||
Assert.True(service.TryConsumeVerifyRequest("203.0.113.11"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyTurnstileAsync_MapsSuccessAndSendsServerOnlySecret()
|
||||
{
|
||||
var handler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
|
||||
{
|
||||
Success = true,
|
||||
Action = "business-access",
|
||||
Hostname = "example.com"
|
||||
}));
|
||||
var service = CreateService(handler);
|
||||
|
||||
var result = await service.VerifyTurnstileAsync(
|
||||
"turnstile-token", "business-access", "example.com", "203.0.113.10", CancellationToken.None);
|
||||
|
||||
Assert.Equal(BotVerificationStatus.Success, result.Status);
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Contains("secret=server-secret", handler.LastRequestBody);
|
||||
Assert.Contains("response=turnstile-token", handler.LastRequestBody);
|
||||
Assert.Contains("remoteip=203.0.113.10", handler.LastRequestBody);
|
||||
Assert.Equal("https://challenges.cloudflare.com/turnstile/v0/siteverify", handler.LastRequest.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyTurnstileAsync_RejectsActionAndHostnameMismatch()
|
||||
{
|
||||
var actionHandler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
|
||||
{
|
||||
Success = true,
|
||||
Action = "other-action",
|
||||
Hostname = "example.com"
|
||||
}));
|
||||
var actionResult = await CreateService(actionHandler).VerifyTurnstileAsync(
|
||||
"token", "business-access", "example.com", null, CancellationToken.None);
|
||||
Assert.Equal(BotVerificationStatus.Rejected, actionResult.Status);
|
||||
|
||||
var hostnameHandler = new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse
|
||||
{
|
||||
Success = true,
|
||||
Action = "business-access",
|
||||
Hostname = "other.example.com"
|
||||
}));
|
||||
var hostnameResult = await CreateService(hostnameHandler).VerifyTurnstileAsync(
|
||||
"token", "business-access", "example.com", null, CancellationToken.None);
|
||||
Assert.Equal(BotVerificationStatus.Rejected, hostnameResult.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyTurnstileAsync_MapsHttpFailureAndTimeoutToUnavailable()
|
||||
{
|
||||
var failureHandler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway));
|
||||
var failureResult = await CreateService(failureHandler).VerifyTurnstileAsync(
|
||||
"token", "business-access", "example.com", null, CancellationToken.None);
|
||||
Assert.Equal(BotVerificationStatus.Unavailable, failureResult.Status);
|
||||
|
||||
var timeoutHandler = new StubHandler(_ => throw new HttpRequestException("network failure"));
|
||||
var timeoutResult = await CreateService(timeoutHandler).VerifyTurnstileAsync(
|
||||
"token", "business-access", "example.com", null, CancellationToken.None);
|
||||
Assert.Equal(BotVerificationStatus.Unavailable, timeoutResult.Status);
|
||||
}
|
||||
|
||||
private static BotProtectionService CreateService(
|
||||
HttpMessageHandler? handler = null,
|
||||
BotProtectionOptions? options = null)
|
||||
{
|
||||
options ??= new BotProtectionOptions();
|
||||
options.SiteKey = "site-key";
|
||||
options.SecretKey = "server-secret";
|
||||
options.CookieSigningKey = "01234567890123456789012345678901";
|
||||
|
||||
var client = new HttpClient(handler ?? new StubHandler(_ => JsonResponse(new TurnstileVerifyResponse())))
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
return new BotProtectionService(new StubHttpClientFactory(client), Microsoft.Extensions.Options.Options.Create(options));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage JsonResponse(TurnstileVerifyResponse response)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(response, AppJsonContext.Default.TurnstileVerifyResponse)
|
||||
};
|
||||
}
|
||||
|
||||
private static string Tamper(string value)
|
||||
{
|
||||
var separator = value.IndexOf('.');
|
||||
var signature = value[(separator + 1)..];
|
||||
var replacement = signature[0] == 'A' ? 'B' : 'A';
|
||||
return value[..(separator + 1)] + replacement + signature[1..];
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => client;
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
public string LastRequestBody { get; private set; } = string.Empty;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastRequestBody = request.Content == null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return handler(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="xunit.v3" Version="3.1.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/FastGateway/FastGateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user