Address Hermes review: test ctor, abuse controls, race, info leak
Blocking fixes:
* Test constructor: drop stale httpClientFactory arg so
PluginBuilder.Tests builds (ctor is (IConfiguration, ILogger)).
* Anonymous abuse controls: replace PublicApiRateLimit on /submit with
a dedicated Policies.BtcMapsSubmitRateLimit policy (5 requests /
24h per IP) so the shared GitHub PAT and OSM bearer can't be used
as an open-write proxy at the default public-API rate.
* GitHub race / idempotency: before opening a PR, search open PRs
in the target repo for an embedded normalized-URL marker and skip
with Skipped="duplicate-open-pr" when one is already in flight.
Replace the per-second branch name with a Guid-derived suffix so
two simultaneous submissions with the same slug can't collide on
branch creation.
* Info disclosure: upstream failures no longer echo ex.Message to
callers. Response carries a correlationId; full detail stays in
LogError.
Non-blocking follow-ups from the review:
* Narrow both catch blocks with `when (ex is not OperationCanceledException)`
so request disconnects bubble as cancellations instead of being
misreported as 502 upstream failures.
* Decouple the OSM changeset-close call from the request cancellation
token: use an independent 10s timeout so client disconnects don't
leave changesets open.
This commit is contained in:
parent
1c7e04a37f
commit
c1ce1a4e12
@ -11,15 +11,9 @@ public class BtcMapsServiceTests
|
||||
{
|
||||
private static BtcMapsService MakeService() =>
|
||||
new BtcMapsService(
|
||||
httpClientFactory: new DummyHttpClientFactory(),
|
||||
configuration: new ConfigurationBuilder().Build(),
|
||||
logger: NullLogger<BtcMapsService>.Instance);
|
||||
|
||||
private sealed class DummyHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new HttpClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresAtLeastOneAction_NotEnforcedHere()
|
||||
{
|
||||
|
||||
@ -18,7 +18,7 @@ public sealed class BtcMapsController(
|
||||
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
|
||||
|
||||
[HttpPost("submit")]
|
||||
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
||||
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
|
||||
public async Task<IActionResult> Submit(
|
||||
[FromBody] BtcMapsSubmitRequest? request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -33,6 +33,7 @@ public sealed class BtcMapsController(
|
||||
if (errors.Count > 0)
|
||||
return BadRequest(new { errors });
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
var response = new BtcMapsSubmitResponse();
|
||||
|
||||
if (request.SubmitToDirectory)
|
||||
@ -41,13 +42,14 @@ public sealed class BtcMapsController(
|
||||
{
|
||||
response.Directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps directory submission failed for {Name} ({Url})", request.Name, request.Url);
|
||||
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "directory-upstream-failed",
|
||||
detail = ex.Message
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -58,14 +60,14 @@ public sealed class BtcMapsController(
|
||||
{
|
||||
response.Osm = await btcMapsService.TagOnOsmAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps OSM tagging failed for {Name} node {NodeType}/{NodeId}",
|
||||
request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
logger.LogError(ex, "BTCMaps OSM tagging failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
|
||||
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "osm-upstream-failed",
|
||||
detail = ex.Message,
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,4 +4,5 @@ public class Policies
|
||||
{
|
||||
public const string OwnPlugin = "OwnPlugin";
|
||||
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
||||
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
|
||||
}
|
||||
|
||||
@ -263,6 +263,17 @@ public class Program
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
|
||||
{
|
||||
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromHours(24),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
services.AddOutputCache(options =>
|
||||
|
||||
@ -159,6 +159,22 @@ public sealed class BtcMapsService
|
||||
}
|
||||
}
|
||||
|
||||
var marker = BuildUrlMarker(normalizedUrl);
|
||||
var openPrSearch = await GetJsonAsync(
|
||||
client,
|
||||
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
|
||||
cancellationToken);
|
||||
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
|
||||
{
|
||||
var firstItem = openPrSearch.GetProperty("items")[0];
|
||||
return new BtcMapsDirectoryResult
|
||||
{
|
||||
Skipped = "duplicate-open-pr",
|
||||
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
|
||||
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
|
||||
};
|
||||
}
|
||||
|
||||
var newEntry = BuildMerchantEntry(request);
|
||||
var updated = merchants
|
||||
.Select(e => (JsonElement?)e)
|
||||
@ -177,7 +193,8 @@ public sealed class BtcMapsService
|
||||
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
|
||||
?? throw new InvalidOperationException("base sha missing");
|
||||
|
||||
var branchName = $"btcmaps/{Slugify(request.Name!)}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var branchSuffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var branchName = $"btcmaps/{Slugify(request.Name!)}-{branchSuffix}";
|
||||
await PostJsonAsync(client, $"repos/{repo}/git/refs",
|
||||
new { @ref = $"refs/heads/{branchName}", sha = baseSha }, cancellationToken);
|
||||
|
||||
@ -190,7 +207,7 @@ public sealed class BtcMapsService
|
||||
branch = branchName
|
||||
}, cancellationToken);
|
||||
|
||||
var prBody = BuildPrBody(request);
|
||||
var prBody = BuildPrBody(request, marker);
|
||||
var prResponse = await PostJsonAsync(client, $"repos/{repo}/pulls",
|
||||
new
|
||||
{
|
||||
@ -267,10 +284,11 @@ public sealed class BtcMapsService
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await client.PutAsync($"changeset/{changesetId}/close",
|
||||
new StringContent(string.Empty), cancellationToken);
|
||||
new StringContent(string.Empty), closeCts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -317,7 +335,7 @@ public sealed class BtcMapsService
|
||||
return JsonDocument.Parse(ms).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string BuildPrBody(BtcMapsSubmitRequest request)
|
||||
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
|
||||
@ -333,9 +351,14 @@ public sealed class BtcMapsService
|
||||
sb.AppendLine(request.Description);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("_Please review before merge — this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"<!-- {urlMarker} -->");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildUrlMarker(string normalizedUrl) =>
|
||||
$"btcmaps-submit:url={normalizedUrl}";
|
||||
|
||||
public static string NormalizeUrl(string url) =>
|
||||
url.Trim().TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user