fix: tighten pairing endpoint validation
This commit is contained in:
parent
1948823bbc
commit
4e4c90e675
@ -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);
|
||||
|
||||
@ -25,3 +25,5 @@ app.MapGet("/health", () => Results.Json(new { status = "ok" }));
|
||||
app.MapPairingEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@ -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>();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user