From 717f55d2a65dfefa07966fbedcaa83acea3419a5 Mon Sep 17 00:00:00 2001 From: r1ckstardev Date: Mon, 25 May 2026 15:06:27 +0000 Subject: [PATCH] BTCMaps v2: enforce https on BtcMap import endpoint before bearer auth Per CodeRabbit review on PR #226. A misconfigured BTCMAPS:BtcMapImportEndpoint over http:// would silently leak the scoped token to anyone on the network path between plugin-builder and btcmap. Parse the configured value as an absolute https URI before building the request, throwing InvalidOperationException with the offending value if the parse / scheme check fails. The exception fires before SendAsync, so the token never reaches a HttpRequestMessage header. Adds 3 tests: http-rejected, non-absolute-rejected, token-missing maps to BtcMapTokenMissingException (controller-ladder regression guard). 40/40 BtcMapsServiceTests passing. --- PluginBuilder.Tests/BtcMapsServiceTests.cs | 45 ++++++++++++++++++++++ PluginBuilder/Services/BtcMapsService.cs | 18 +++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/PluginBuilder.Tests/BtcMapsServiceTests.cs b/PluginBuilder.Tests/BtcMapsServiceTests.cs index 9b36e06..2fc9e20 100644 --- a/PluginBuilder.Tests/BtcMapsServiceTests.cs +++ b/PluginBuilder.Tests/BtcMapsServiceTests.cs @@ -285,6 +285,51 @@ public class BtcMapsServiceTests Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId)); } + private static BtcMapsService MakeServiceWithConfig(IDictionary config) => + new BtcMapsService( + configuration: new ConfigurationBuilder().AddInMemoryCollection(config).Build(), + httpClientFactory: new StubHttpClientFactory(), + logger: NullLogger.Instance); + + [Fact] + public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsHttpEndpoint() + { + // Bearer token must never cross the wire over plaintext http://. Misconfigured + // endpoint surfaces as InvalidOperationException before HttpClient.SendAsync, + // so the token is never built into a request header. + var service = MakeServiceWithConfig(new Dictionary + { + ["BTCMAPS:BtcMapImportToken"] = "test-token", + ["BTCMAPS:BtcMapImportEndpoint"] = "http://api.btcmap.org/rpc" + }); + var ex = await Assert.ThrowsAsync( + () => service.SubmitToBtcMapAsync(MakeValidBtcMap())); + Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsNonAbsoluteEndpoint() + { + var service = MakeServiceWithConfig(new Dictionary + { + ["BTCMAPS:BtcMapImportToken"] = "test-token", + ["BTCMAPS:BtcMapImportEndpoint"] = "/rpc" + }); + await Assert.ThrowsAsync( + () => service.SubmitToBtcMapAsync(MakeValidBtcMap())); + } + + [Fact] + public async System.Threading.Tasks.Task SubmitToBtcMapAsync_ThrowsTokenMissingWhenUnset() + { + // Token unset is the ops-deployment-pending shape; controller maps this to + // 503 btcmap-not-configured. Test the underlying exception type so the + // controller exception ladder stays wired. + var service = MakeServiceWithConfig(new Dictionary()); + await Assert.ThrowsAsync( + () => service.SubmitToBtcMapAsync(MakeValidBtcMap())); + } + [Fact] public void NormalizeUrl_LowercasesSchemeAndHostOnly() { diff --git a/PluginBuilder/Services/BtcMapsService.cs b/PluginBuilder/Services/BtcMapsService.cs index e62ae98..1e02494 100644 --- a/PluginBuilder/Services/BtcMapsService.cs +++ b/PluginBuilder/Services/BtcMapsService.cs @@ -177,7 +177,19 @@ public sealed class BtcMapsService if (string.IsNullOrWhiteSpace(token)) throw new BtcMapTokenMissingException(); - var endpoint = _configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint; + // Bearer tokens MUST NOT cross the wire over http://; an operator- + // misconfigured endpoint would silently leak the scoped token to + // anyone on the network path. Parse the configured value into an + // absolute https URI before we even create the request, so a bad + // BTCMAPS:BtcMapImportEndpoint fails loudly with the offending + // value in the message instead of producing a quiet credential leak. + var endpoint = (_configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint).Trim(); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + endpointUri.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException( + $"BTCMAPS:BtcMapImportEndpoint must be an absolute https:// URL (got '{endpoint}')."); + } var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap); @@ -222,7 +234,7 @@ public sealed class BtcMapsService ["id"] = 1 }; - using var req = new HttpRequestMessage(HttpMethod.Post, endpoint); + using var req = new HttpRequestMessage(HttpMethod.Post, endpointUri); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json"); @@ -230,7 +242,7 @@ public sealed class BtcMapsService var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) - throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpoint}: {body}"); + throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpointUri}: {body}"); using var doc = JsonDocument.Parse(body); var root = doc.RootElement;