fix: tighten pairing endpoint validation

This commit is contained in:
sladro 2026-03-27 11:47:23 +08:00
parent 1948823bbc
commit 4e4c90e675
4 changed files with 151 additions and 8 deletions

View File

@ -19,26 +19,35 @@ public static class PairingEndpoints
{
var code = pairingService.CreateCode(PairingCodeTtl);
await auditLog.AppendAsync(
new AuditRecord("pairing_code_created", clock.UtcNow, code.Value, $"expires_at={code.ExpiresAtUtc:O}"),
new AuditRecord("pairing_code_created", clock.UtcNow, "pairing", $"expires_at={code.ExpiresAtUtc:O}"),
cancellationToken);
return Results.Ok(code);
});
group.MapPost("/redeem", async (
[FromBody] RedeemPairingRequest request,
[FromBody] RedeemPairingRequest? request,
PairingService pairingService,
AuditLog auditLog,
IClock clock,
CancellationToken cancellationToken) =>
{
var result = pairingService.Redeem(request.Code, request.DeviceName);
var response = new RedeemPairingResponse(result.Success, result.ErrorCode);
var validationError = ValidateRedeemRequest(request);
if (validationError is not null)
{
return Results.BadRequest(validationError);
}
var validatedRequest = request!;
var result = pairingService.Redeem(validatedRequest.Code!, validatedRequest.DeviceName!);
var response = result.Success
? new RedeemPairingResponse(true, string.Empty, "accepted")
: new RedeemPairingResponse(false, result.ErrorCode, "rejected");
var eventType = result.Success ? "pairing_redeemed" : "pairing_redeem_failed";
var details = result.Success ? request.DeviceName : result.ErrorCode;
var details = result.Success ? "accepted" : result.ErrorCode;
await auditLog.AppendAsync(
new AuditRecord(eventType, clock.UtcNow, request.DeviceName, details),
new AuditRecord(eventType, clock.UtcNow, validatedRequest.DeviceName!, details),
cancellationToken);
return result.Success ? Results.Ok(response) : Results.BadRequest(response);
@ -46,8 +55,23 @@ public static class PairingEndpoints
return endpoints;
}
private static RedeemPairingResponse? ValidateRedeemRequest(RedeemPairingRequest? request)
{
if (request is null)
{
return new RedeemPairingResponse(false, "invalid_request", "rejected");
}
if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.DeviceName))
{
return new RedeemPairingResponse(false, "invalid_request", "rejected");
}
return null;
}
}
public sealed record RedeemPairingRequest(string Code, string DeviceName);
public sealed record RedeemPairingRequest(string? Code, string? DeviceName);
public sealed record RedeemPairingResponse(bool Success, string ErrorCode);
public sealed record RedeemPairingResponse(bool Success, string ErrorCode, string Status);

View File

@ -25,3 +25,5 @@ app.MapGet("/health", () => Results.Json(new { status = "ok" }));
app.MapPairingEndpoints();
app.Run();
public partial class Program;

View File

@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
namespace TermRemoteCtl.Agent.IntegrationTests.Api;
public sealed class PairingEndpointsTests
{
[Fact]
public async Task Redeem_Returns_BadRequest_When_Request_Is_Incomplete()
{
await using var fixture = new PairingApiFixture();
using var client = fixture.CreateClient();
using var response = await client.PostAsJsonAsync(
"/api/pairing/redeem",
new
{
code = "123456"
});
var payload = await response.Content.ReadFromJsonAsync<RedeemResponse>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(payload);
Assert.False(payload.Success);
Assert.Equal("invalid_request", payload.ErrorCode);
Assert.Equal("rejected", payload.Status);
}
[Fact]
public async Task Redeem_Returns_BadRequest_When_Json_Is_Malformed()
{
await using var fixture = new PairingApiFixture();
using var client = fixture.CreateClient();
using var content = new StringContent("{\"code\":", Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/api/pairing/redeem", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreateCode_Does_Not_Write_Secret_Code_To_Audit_Log()
{
await using var fixture = new PairingApiFixture();
using var client = fixture.CreateClient();
var code = await client.PostFromJsonAsync<PairingCodeResponse>("/api/pairing/code");
var auditLog = await File.ReadAllTextAsync(fixture.GetAuditLogPath(), Encoding.UTF8);
Assert.NotNull(code);
Assert.DoesNotContain(code!.Value, auditLog, StringComparison.Ordinal);
}
private sealed class PairingApiFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
}
public string GetAuditLogPath()
{
var options = Services.GetRequiredService<IOptions<AgentOptions>>().Value;
return Path.Combine(options.DataRoot, "audit.log.jsonl");
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed record RedeemResponse(bool Success, string ErrorCode, string Status);
private sealed record PairingCodeResponse(string Value, DateTimeOffset ExpiresAtUtc);
}
internal static class HttpClientJsonExtensions
{
public static async Task<T?> PostFromJsonAsync<T>(this HttpClient client, string requestUri)
{
using var response = await client.PostAsync(requestUri, JsonContent.Create(new { }));
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>();
}
}

View File

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
@ -20,4 +21,8 @@
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\src\\TermRemoteCtl.Agent\\TermRemoteCtl.Agent.csproj" />
</ItemGroup>
</Project>