diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d2335..f5834ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,2 @@ v1.0.0 -- Inital Version +- Inital Version diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs index e7ef93a..a5085f8 100644 --- a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Xunit; namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests @@ -41,5 +42,350 @@ public async Task ValidateConfiguration_SucceedsWhenApiTokenPresent() await validator.ValidateConfiguration(config); } + + [Fact] + public async Task CleanupValidation_DeletesTheStagedValue_NotJustTheFirstSameNameRecord() + { + // Simulates an apex + wildcard SAN pair: both authorizations challenge at the identical + // _acme-challenge FQDN with different values, staged in order [value-A, value-B]. The + // DigitalOcean records list is returned in the OPPOSITE order on purpose — a name-only + // match (the pre-fix behavior) would delete value-B's record on the first cleanup call + // even though value-A's authorization is the one being cleaned up. + var deletedIds = new List(); + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":1,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"ignored\",\"ttl\":300}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[" + + "{\"id\":11,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-B\",\"ttl\":300}," + + "{\"id\":10,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-A\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deletedIds.Add(req.RequestUri.PathAndQuery.Split('/').Last()); + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + const string key = "_acme-challenge.example.com"; + var stageA = await validator.StageValidation(key, "value-A", CancellationToken.None); + var stageB = await validator.StageValidation(key, "value-B", CancellationToken.None); + Assert.True(stageA.Success); + Assert.True(stageB.Success); + + var cleanupA = await validator.CleanupValidation(key, CancellationToken.None); + var cleanupB = await validator.CleanupValidation(key, CancellationToken.None); + + Assert.True(cleanupA.Success); + Assert.True(cleanupB.Success); + Assert.Equal(new[] { "10", "11" }, deletedIds); + } + + [Fact] + public async Task CleanupValidation_RetainsStagedValueForRetry_WhenDeleteFails() + { + // The staged value must only be removed from tracking once DeleteRecordAsync actually + // succeeds -- popping it up front would lose it on a failed attempt, breaking a retry. + var recordsCallCount = 0; + var deletePaths = new List(); + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":1,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"ignored\",\"ttl\":300}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + recordsCallCount++; + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[{\"id\":10,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-A\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deletePaths.Add(req.RequestUri.PathAndQuery); + if (recordsCallCount == 1) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("{\"message\":\"boom\"}") + }; + } + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + const string key = "_acme-challenge.example.com"; + var stage = await validator.StageValidation(key, "value-A", CancellationToken.None); + Assert.True(stage.Success); + + var firstCleanup = await validator.CleanupValidation(key, CancellationToken.None); + Assert.False(firstCleanup.Success); + + var secondCleanup = await validator.CleanupValidation(key, CancellationToken.None); + Assert.True(secondCleanup.Success); + + Assert.Equal(2, deletePaths.Count); + Assert.All(deletePaths, p => Assert.EndsWith("/10", p)); + } + + [Fact] + public async Task StageValidation_SanitizesKeyInErrorMessageAndLog() + { + var handler = new FakeHttpMessageHandler(_ => + FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, "{\"message\":\"nope\"}")); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + var result = await validator.StageValidation( + "_acme-challenge.example.com\r\nFORGED LOG LINE", "value", CancellationToken.None); + + Assert.False(result.Success); + Assert.DoesNotContain("\r", result.ErrorMessage); + Assert.DoesNotContain("\n", result.ErrorMessage); + } + + [Fact] + public async Task CleanupValidation_SerializesConcurrentCallsForTheSameKey() + { + // Regression test: two concurrent CleanupValidation calls for the same key must be + // fully serialized (including the network round-trip), not just around the queue + // peek/dequeue -- otherwise both could peek the same staged value before either + // dequeues it, letting one call's delete silently mask the loss of the other's. + var deletedIds = new List(); + var recordsInFlight = 0; + var maxRecordsInFlight = 0; + var gate = new object(); + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":1,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"ignored\",\"ttl\":300}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + lock (gate) + { + recordsInFlight++; + maxRecordsInFlight = Math.Max(maxRecordsInFlight, recordsInFlight); + } + Thread.Sleep(50); // widens the window so an unserialized race would be observed + lock (gate) + { + recordsInFlight--; + } + + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[" + + "{\"id\":10,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-A\",\"ttl\":300}," + + "{\"id\":11,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-B\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + lock (gate) + { + deletedIds.Add(req.RequestUri.PathAndQuery.Split('/').Last()); + } + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + const string key = "_acme-challenge.example.com"; + await validator.StageValidation(key, "value-A", CancellationToken.None); + await validator.StageValidation(key, "value-B", CancellationToken.None); + + var cleanup1 = validator.CleanupValidation(key, CancellationToken.None); + var cleanup2 = validator.CleanupValidation(key, CancellationToken.None); + var results = await Task.WhenAll(cleanup1, cleanup2); + + Assert.All(results, r => Assert.True(r.Success)); + Assert.Equal(1, maxRecordsInFlight); + Assert.Equal(new[] { "10", "11" }, deletedIds.OrderBy(x => x)); + } + + [Fact] + public async Task CleanupValidation_ReturnsFailureInsteadOfThrowing_WhenCanceledWhileWaitingForKeyLock() + { + // Regression test: cancellation while queued behind another same-key operation must be + // caught and returned as Success=false, per this class's documented contract that no + // exception may escape into the gateway -- not just cancellation during the HTTP call. + var firstCallGate = new ManualResetEventSlim(false); + var releaseFirstCall = new ManualResetEventSlim(false); + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + firstCallGate.Set(); + releaseFirstCall.Wait(TimeSpan.FromSeconds(5)); + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{\"domain_records\":[]}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + const string key = "_acme-challenge.example.com"; + var firstCall = validator.CleanupValidation(key, CancellationToken.None); + + Assert.True(firstCallGate.Wait(TimeSpan.FromSeconds(5)), "first call did not reach the records lookup in time"); + + using var cts = new CancellationTokenSource(); + var secondCall = validator.CleanupValidation(key, cts.Token); + await Task.Delay(50); + cts.Cancel(); + + var secondResult = await secondCall; + Assert.False(secondResult.Success); + Assert.NotNull(secondResult.ErrorMessage); + + releaseFirstCall.Set(); + var firstResult = await firstCall; + Assert.True(firstResult.Success); + } + + [Fact] + public async Task StageValidation_ReturnsFailureInsteadOfThrowing_WhenKeyIsNull() + { + // Regression test: GetKeyLock's dictionary lookup throws ArgumentNullException on a + // null key -- that call must be inside the try block so it comes back as a caught, + // logged Success=false result rather than an escaping exception. + var handler = new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("Should not make HTTP calls")); + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + var result = await validator.StageValidation(null, "value", CancellationToken.None); + + Assert.False(result.Success); + Assert.NotNull(result.ErrorMessage); + } + + [Fact] + public async Task CleanupValidation_ReturnsFailureInsteadOfThrowing_WhenKeyIsNull() + { + var handler = new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("Should not make HTTP calls")); + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + var result = await validator.CleanupValidation(null, CancellationToken.None); + + Assert.False(result.Success); + Assert.NotNull(result.ErrorMessage); + } + + [Fact] + public async Task StagedValueTracking_CorrelatesKeysCaseInsensitively() + { + // DNS names are inherently case-insensitive, and DigitalOceanProvider already matches + // record names with OrdinalIgnoreCase -- the staged-value tracking used to disambiguate + // cleanup must not silently fail to correlate a Stage/Cleanup pair that differ only in + // casing. Two same-name records are returned in the OPPOSITE order from staging, so a + // name-only fallback (what would happen if the case difference broke correlation and + // lost the staged value) would delete the wrong one. + HttpRequestMessage deleteRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":1,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"ignored\",\"ttl\":300}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[" + + "{\"id\":11,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-B\",\"ttl\":300}," + + "{\"id\":10,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"value-A\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deleteRequest = req; + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + var validator = new DigitalOceanDomainValidator(provider); + + var stage = await validator.StageValidation("_acme-challenge.Example.com", "value-A", CancellationToken.None); + Assert.True(stage.Success); + + var cleanup = await validator.CleanupValidation("_acme-challenge.example.com", CancellationToken.None); + + Assert.True(cleanup.Success); + Assert.NotNull(deleteRequest); + Assert.EndsWith("domains/example.com/records/10", deleteRequest.RequestUri.PathAndQuery); + } } } diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs index 480e6ba..dfa54c9 100644 --- a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs @@ -154,18 +154,24 @@ public async Task CreateRecordAsync_ThrowsAuthErrorNamingLikelyCauseOn401() [Fact] public async Task CreateRecordAsync_FollowsPaginationToFindZone() { + // Distinguishes page 1 vs page 2 by call order rather than a "page=2" substring match — + // "per_page=200" itself contains that substring, which previously made the first request + // match the "page 2" branch and let a doubled "/v2/v2/..." request URI go undetected. + var domainsRequests = new List(); + var handler = new FakeHttpMessageHandler(req => { - if (req.RequestUri.PathAndQuery.Contains("page=2")) + if (req.Method == HttpMethod.Get && req.RequestUri.AbsolutePath.Equals("/v2/domains", StringComparison.OrdinalIgnoreCase)) { - return FakeHttpMessageHandler.Json(HttpStatusCode.OK, - "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); - } + domainsRequests.Add(req); + if (domainsRequests.Count == 1) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"other.com\"}],\"links\":{\"pages\":{\"next\":\"https://api.digitalocean.com/v2/domains?page=2&per_page=200\"}}}"); + } - if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) - { return FakeHttpMessageHandler.Json(HttpStatusCode.OK, - "{\"domains\":[{\"name\":\"other.com\"}],\"links\":{\"pages\":{\"next\":\"https://api.digitalocean.com/v2/domains?page=2&per_page=200\"}}}"); + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); } if (req.Method == HttpMethod.Post) @@ -182,6 +188,8 @@ public async Task CreateRecordAsync_FollowsPaginationToFindZone() var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"); Assert.True(result); + Assert.Equal(2, domainsRequests.Count); + Assert.Equal("https://api.digitalocean.com/v2/domains?page=2&per_page=200", domainsRequests[1].RequestUri.ToString()); } [Fact] @@ -221,6 +229,56 @@ public async Task DeleteRecordAsync_DeletesMatchingRecord() Assert.EndsWith("domains/example.com/records/7", deleteRequest.RequestUri.PathAndQuery); } + [Fact] + public async Task DeleteRecordAsync_FollowsPaginationToFindRecordOnLaterPage() + { + // A zone can have more than one page of records (other TXT records, concurrent SAN + // challenges, etc.) -- the target record living on page 2+ must not be treated as + // "not found" just because the first page didn't contain it. + var recordsRequests = new List(); + HttpRequestMessage deleteRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.AbsolutePath.Equals("/v2/domains/example.com/records", StringComparison.OrdinalIgnoreCase)) + { + recordsRequests.Add(req); + if (recordsRequests.Count == 1) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[{\"id\":1,\"type\":\"TXT\",\"name\":\"unrelated\",\"data\":\"x\",\"ttl\":300}]," + + "\"links\":{\"pages\":{\"next\":\"https://api.digitalocean.com/v2/domains/example.com/records?type=TXT&page=2&per_page=200\"}}}"); + } + + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[{\"id\":7,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deleteRequest = req; + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT"); + + Assert.True(result); + Assert.Equal(2, recordsRequests.Count); + Assert.NotNull(deleteRequest); + Assert.EndsWith("domains/example.com/records/7", deleteRequest.RequestUri.PathAndQuery); + } + [Fact] public async Task DeleteRecordAsync_IsIdempotentWhenRecordAlreadyGone() { @@ -255,5 +313,113 @@ public void Constructor_ThrowsOnMissingApiToken(string apiToken) Assert.Throws(() => new DigitalOceanProvider(apiToken, new FakeHttpMessageHandler(_ => throw new InvalidOperationException("Should not make HTTP calls")))); } + + [Fact] + public async Task DeleteRecordAsync_WithExpectedValue_DeletesOnlyTheMatchingRecord() + { + // Simulates two TXT records sharing the same name (e.g. an apex + wildcard SAN both + // challenging at the same _acme-challenge FQDN with different values) — the delete must + // target the record whose value matches, not just the first record with that name. + HttpRequestMessage deleteRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[" + + "{\"id\":7,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"first-value\",\"ttl\":300}," + + "{\"id\":8,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"second-value\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deleteRequest = req; + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT", "second-value"); + + Assert.True(result); + Assert.NotNull(deleteRequest); + Assert.EndsWith("domains/example.com/records/8", deleteRequest.RequestUri.PathAndQuery); + } + + [Fact] + public async Task DeleteRecordAsync_WithExpectedValue_IsIdempotentWhenNoRecordMatchesTheValue() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[{\"id\":7,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"other-value\",\"ttl\":300}]}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT", "expected-value"); + + Assert.True(result); + } + + [Fact] + public async Task CreateRecordAsync_ObservesCancellationToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("HTTP call should not be made when the token is already canceled")); + + var provider = new DigitalOceanProvider("token", handler); + + await Assert.ThrowsAnyAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT", cts.Token)); + } + + [Fact] + public async Task DeleteRecordAsync_ObservesCancellationToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("HTTP call should not be made when the token is already canceled")); + + var provider = new DigitalOceanProvider("token", handler); + + await Assert.ThrowsAnyAsync( + () => provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT", cancellationToken: cts.Token)); + } + + [Theory] + [InlineData("_acme-challenge.example.com\r\nFORGED LOG LINE", "_acme-challenge.example.comFORGED LOG LINE")] + [InlineData("plain.example.com", "plain.example.com")] + [InlineData(null, null)] + public void StripControlCharacters_RemovesControlCharactersOnly(string input, string expected) + { + Assert.Equal(expected, DigitalOceanProvider.StripControlCharacters(input)); + } } } diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs index 8d0c8e3..adf5b1e 100644 --- a/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs @@ -20,8 +20,19 @@ public FakeHttpMessageHandler(Func resp protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + // Real HttpMessageHandlers honor the token before doing any work; matching that here + // lets tests verify a caller's CancellationToken actually reaches the HTTP layer. + cancellationToken.ThrowIfCancellationRequested(); Requests.Add(request); - return Task.FromResult(_responder(request)); + + // Run the responder on a background thread rather than returning an already-completed + // Task.FromResult(...). A synchronously-completed task lets `await` continue inline on + // the calling thread with no real suspension -- so a responder that blocks (simulating + // slow I/O) blocks the CALLER's thread too, and a "fire without awaiting" call in a test + // doesn't actually run concurrently with the rest of that test method. Task.Run gives + // tests genuine interleaving to exercise real concurrency (e.g. two calls contending for + // the same lock). + return Task.Run(() => _responder(request), cancellationToken); } public static HttpResponseMessage Json(HttpStatusCode status, string body) diff --git a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs index fdb6a3f..06b79ce 100644 --- a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs +++ b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs @@ -20,6 +20,50 @@ public class DigitalOceanDomainValidator : IDomainValidator private DigitalOceanProvider _provider; private Dictionary _configuration; + // Tracks the value staged for each key so CleanupValidation can disambiguate between + // multiple TXT records sharing the same name (e.g. an apex + wildcard SAN both challenging + // at the same _acme-challenge FQDN). CleanupValidation's own signature (key only, no value) + // can't tell us which record to delete, so this queue records staging order per key on a + // best-effort FIFO basis: cleanup for a key removes the oldest still-pending value staged + // for it. Keyed case-insensitively: DNS names are inherently case-insensitive, and + // DigitalOceanProvider already matches record names with StringComparison.OrdinalIgnoreCase + // -- an ordinal-cased dictionary here could silently fail to correlate a Stage/Cleanup pair + // that differ only in casing, defeating this exact disambiguation mechanism. + private readonly Dictionary> _stagedValues = new(StringComparer.OrdinalIgnoreCase); + + // Serializes ALL Stage/Cleanup calls for the SAME key end-to-end, including the network + // round-trip -- not just the queue peek/dequeue. A lock held only around the queue + // read/write (and released across the `await DeleteRecordAsync(...)` call) is not enough: + // two concurrent CleanupValidation calls for the same key could both peek the same head + // value before either dequeues it, so one call's delete succeeds while the other's + // redundant delete finds nothing (idempotent no-op) and ALSO reports success -- silently + // leaking the second call's real, distinct record and leaving a stale queue entry that + // nothing ever dequeues. Different keys still run fully in parallel; only same-key + // operations are serialized. Guarded by `_locksLock`, a separate, short-lived lock used + // only to get-or-create a key's semaphore -- never held across an await. + // + // Neither this dictionary nor `_stagedValues` ever evicts an entry, so both grow for the + // life of the validator instance, one entry per distinct FQDN ever staged/cleaned up. + // Accepted: per-entry cost is small, a validator instance's lifetime is bounded by the + // hosting gateway process (restarted periodically for patching), and `key` values are + // domain names from certificates this account is actually enrolling, not attacker-supplied + // input from an untrusted boundary. + // Same case-insensitivity rationale as `_stagedValues` above. + private readonly Dictionary _keyLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly object _locksLock = new(); + + public DigitalOceanDomainValidator() + { + } + + // Internal constructor to allow unit tests to inject a fake provider without going through + // Initialize (which requires a real IDomainValidatorConfigProvider and constructs its own + // DigitalOceanProvider from a config-supplied API token). + internal DigitalOceanDomainValidator(DigitalOceanProvider provider) + { + _provider = provider; + } + public Dictionary GetDomainValidatorAnnotations() { return new Dictionary() @@ -44,6 +88,7 @@ public void Initialize(IDomainValidatorConfigProvider configProvider) if (string.IsNullOrWhiteSpace(apiToken)) { + _logger.LogWarning("DigitalOcean_ApiToken is missing or empty; plugin initialization cannot proceed"); throw new ArgumentException("DigitalOcean_ApiToken is required"); } @@ -52,48 +97,121 @@ public void Initialize(IDomainValidatorConfigProvider configProvider) public async Task StageValidation(string key, string value, CancellationToken cancellationToken) { + SemaphoreSlim keyLock = null; + var lockAcquired = false; try { - var success = await _provider.CreateRecordAsync(key, value, RecordTypeName); + // GetKeyLock (a Dictionary lookup) and the lock wait itself must both be inside + // this try block: a null key would make GetKeyLock's TryGetValue throw + // ArgumentNullException, and a cancellation while queued behind another same-key + // operation makes WaitAsync throw -- either way, this class's documented contract + // is that NO exception may escape StageValidation, only a caught, logged + // Success=false result. `lockAcquired` staying false in either case correctly + // tells the finally below there is nothing to release. + keyLock = GetKeyLock(key); + await keyLock.WaitAsync(cancellationToken); + lockAcquired = true; + + var success = await _provider.CreateRecordAsync(key, value, RecordTypeName, cancellationToken); + + if (success) + { + GetQueue(key, createIfMissing: true).Enqueue(value); + } return new DomainValidationResult { Success = success, - ErrorMessage = success ? null : $"Failed to create DNS {RecordTypeName} record for {key}" + ErrorMessage = success ? null : $"Failed to create DNS {RecordTypeName} record for {SafeForLog(key)}" }; } catch (Exception ex) { - _logger.LogError(ex, "DigitalOcean StageValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + _logger.LogError(ex, "DigitalOcean StageValidation failed for {RecordType} record '{Key}'", RecordTypeName, SafeForLog(key)); return new DomainValidationResult { Success = false, - ErrorMessage = $"Failed to create {RecordTypeName} record for {key}: {ex.Message}" + ErrorMessage = $"Failed to create {RecordTypeName} record for {SafeForLog(key)}: {ex.Message}" }; } + finally + { + if (lockAcquired) + { + keyLock.Release(); + } + } } public async Task CleanupValidation(string key, CancellationToken cancellationToken) { + SemaphoreSlim keyLock = null; + var lockAcquired = false; try { - var success = await _provider.DeleteRecordAsync(key, RecordTypeName); + // See the identical comment in StageValidation: GetKeyLock and the lock wait must + // both be inside this try block so a null key or a cancellation while queued behind + // another same-key operation is caught and converted to a logged Success=false, + // not an escaping exception. + keyLock = GetKeyLock(key); + await keyLock.WaitAsync(cancellationToken); + lockAcquired = true; + + // Peek (don't remove) the oldest still-pending staged value. It is only actually + // dequeued below once DeleteRecordAsync confirms success — removing it up front + // would permanently lose it if the delete failed/was retried, corrupting the queue + // for any later retry or sibling cleanup call on this key. Holding `keyLock` for + // the entire method (including the network round-trip) guarantees no other + // Stage/Cleanup call for this SAME key can observe or mutate the queue in between, + // so this peek-then-conditionally-dequeue is race-free for a given key. + var queue = GetQueue(key, createIfMissing: false); + var outstandingCount = queue?.Count ?? 0; + var expectedValue = outstandingCount > 0 ? queue.Peek() : null; + + if (outstandingCount > 1) + { + // CleanupValidation's own contract gives us no challenge value to match against + // (only `key`), so when more than one value is outstanding for the same key + // (e.g. an apex + wildcard SAN sharing one _acme-challenge FQDN) we cannot know + // FOR CERTAIN which one this specific cleanup call is for. We fall back to a + // best-effort FIFO match (oldest staged, oldest cleaned up) rather than refusing + // to clean up at all, but that assumption can be wrong if completion order + // doesn't match staging order -- surfacing it here so it's operationally visible + // rather than a silent, unverifiable guess. + _logger.LogWarning( + "{Count} {RecordType} values are still staged for '{Key}'; cleanup will match the oldest staged value on a best-effort basis, since CleanupValidation does not receive the specific challenge value", + outstandingCount, RecordTypeName, SafeForLog(key)); + } + + var success = await _provider.DeleteRecordAsync(key, RecordTypeName, expectedValue, cancellationToken); + + if (success && expectedValue != null) + { + queue.Dequeue(); + } return new DomainValidationResult { Success = success, - ErrorMessage = success ? null : $"Failed to delete DNS {RecordTypeName} record for {key}" + ErrorMessage = success ? null : $"Failed to delete DNS {RecordTypeName} record for {SafeForLog(key)}" }; } catch (Exception ex) { - _logger.LogError(ex, "DigitalOcean CleanupValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + _logger.LogError(ex, "DigitalOcean CleanupValidation failed for {RecordType} record '{Key}'", RecordTypeName, SafeForLog(key)); return new DomainValidationResult { Success = false, - ErrorMessage = $"Failed to delete {RecordTypeName} record for {key}: {ex.Message}" + ErrorMessage = $"Failed to delete {RecordTypeName} record for {SafeForLog(key)}: {ex.Message}" }; } + finally + { + if (lockAcquired) + { + keyLock.Release(); + } + } } public async Task ValidateConfiguration(Dictionary configuration) @@ -103,6 +221,7 @@ public async Task ValidateConfiguration(Dictionary configuration var apiToken = GetConfigValue("DigitalOcean_ApiToken"); if (string.IsNullOrWhiteSpace(apiToken)) { + _logger.LogWarning("DigitalOcean_ApiToken is missing or empty; configuration validation failed"); throw new ArgumentException("DigitalOcean_ApiToken is required"); } @@ -117,5 +236,48 @@ private string GetConfigValue(string key) } return string.Empty; } + + // DigitalOceanProvider sanitizes recordName before using it in ITS OWN log/exception + // messages, but that sanitized copy never crosses back into this class's `key` parameter + // (strings are immutable/passed by value) -- this class has its own independent log and + // ErrorMessage call sites that log/embed the raw `key`, so it needs its own sanitization + // pass to close the same CWE-117 CRLF log-forging gap at this layer. + private static string SafeForLog(string key) => DigitalOceanProvider.StripControlCharacters(key); + + private SemaphoreSlim GetKeyLock(string key) + { + lock (_locksLock) + { + if (!_keyLocks.TryGetValue(key, out var keyLock)) + { + keyLock = new SemaphoreSlim(1, 1); + _keyLocks[key] = keyLock; + } + return keyLock; + } + } + + // Only ever called while holding that key's semaphore (see GetKeyLock), so the returned + // Queue is never accessed by more than one caller at a time and needs no further + // locking around Enqueue/Peek/Dequeue -- only the lookup/creation in the shared dictionary + // itself needs the brief `_locksLock` (reused here rather than adding a third lock, since + // it is already the lock guarding shared-dictionary structural changes for this class). + private Queue GetQueue(string key, bool createIfMissing) + { + lock (_locksLock) + { + if (_stagedValues.TryGetValue(key, out var queue)) + { + return queue; + } + if (!createIfMissing) + { + return null; + } + queue = new Queue(); + _stagedValues[key] = queue; + return queue; + } + } } } diff --git a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs index a0d5d06..fdad29e 100644 --- a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs +++ b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs @@ -13,6 +13,12 @@ internal class DigitalOceanProvider { private static readonly ILogger _logger = LogHandler.GetClassLogger(); + // Bounds any DigitalOcean list-endpoint pagination loop against a malformed/cyclic `next` + // link (API bug or future change) so a broken pagination response can't hang + // StageValidation/CleanupValidation forever. 5,000 pages at 200/page is 1,000,000 items -- + // far beyond any realistic account's domain or per-zone record count. + private const int MaxPaginationPages = 5000; + private readonly HttpClient _httpClient; private class DomainData @@ -64,6 +70,9 @@ private class RecordsResponse { [JsonPropertyName("domain_records")] public RecordData[] DomainRecords { get; set; } + + [JsonPropertyName("links")] + public LinksData Links { get; set; } } private class CreateRecordResponse @@ -87,26 +96,39 @@ internal DigitalOceanProvider(string apiToken, HttpMessageHandler handler) _httpClient = new HttpClient(handler) { - BaseAddress = new Uri("https://api.digitalocean.com/v2/") + BaseAddress = new Uri("https://api.digitalocean.com/v2/"), + // DigitalOceanDomainValidator holds a per-key lock across the entire Stage/Cleanup + // call, including every HTTP request this class makes -- without an explicit bound, + // a single stalled DigitalOcean connection falls back to HttpClient's 100-second + // default, and a Create/Delete can make 2-3 sequential requests, so an unrelated + // legitimate operation for the SAME key could queue behind a hung one for several + // minutes. 30 seconds is generous for a DNS record CRUD call under normal conditions. + Timeout = TimeSpan.FromSeconds(30) }; _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } - public async Task CreateRecordAsync(string recordName, string value, string recordType) + public async Task CreateRecordAsync(string recordName, string value, string recordType, CancellationToken cancellationToken = default) { + recordName = StripControlCharacters(recordName); _logger.LogDebug("Creating {RecordType} record for {RecordName}", recordType, recordName); - var zone = await FindZoneForRecordAsync(recordName); + var zone = await FindZoneForRecordAsync(recordName, cancellationToken); var relativeName = RelativeRecordName(zone, recordName); var payload = new { type = recordType, name = relativeName, data = value, ttl = 300 }; var json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync($"domains/{zone}/records", content); - var result = await response.Content.ReadAsStringAsync(); + var response = await _httpClient.PostAsync($"domains/{zone}/records", content, cancellationToken); + // Sanitized immediately: this is DigitalOcean-controlled response content, not just + // plugin-derived values, but it still reaches log/exception sinks below, so it's + // subject to the same CWE-117 CRLF log-forging risk as any other logged value. Valid + // JSON never contains raw (unescaped) control characters, so this is a no-op on the + // success/deserialization path. + var result = StripControlCharacters(await response.Content.ReadAsStringAsync(cancellationToken)); if (!response.IsSuccessStatusCode) { @@ -118,35 +140,70 @@ public async Task CreateRecordAsync(string recordName, string value, strin $"DigitalOcean API returned {(int)response.StatusCode} ({response.StatusCode}) creating {recordType} record '{relativeName}' in zone '{zone}': {result}"); } + var createdId = JsonSerializer.Deserialize(result)?.DomainRecord?.Id; _logger.LogInformation( - "Created {RecordType} record '{RelativeName}' in DigitalOcean zone '{Zone}'", - recordType, relativeName, zone); + "Created {RecordType} record '{RelativeName}' ({RecordId}) in DigitalOcean zone '{Zone}'", + recordType, relativeName, createdId, zone); return true; } - public async Task DeleteRecordAsync(string recordName, string recordType) + public async Task DeleteRecordAsync(string recordName, string recordType, string expectedValue = null, CancellationToken cancellationToken = default) { + recordName = StripControlCharacters(recordName); _logger.LogDebug("Deleting {RecordType} record for {RecordName}", recordType, recordName); - var zone = await FindZoneForRecordAsync(recordName); + var zone = await FindZoneForRecordAsync(recordName, cancellationToken); var relativeName = RelativeRecordName(zone, recordName); - var recordsResp = await _httpClient.GetAsync($"domains/{zone}/records?type={recordType}"); - var recordsBody = await recordsResp.Content.ReadAsStringAsync(); - if (!recordsResp.IsSuccessStatusCode) + // Paged the same way as FindZoneForRecordAsync's domain listing: a zone can accumulate + // more than one page of records (other TXT records, multiple concurrent SAN + // challenges, or previously-stranded records), and an un-paginated fetch would make + // records on page 2+ invisible to the match below -- silently treating a genuinely + // still-live record as "not found" and reporting cleanup as complete without deleting + // it, leaving it in DNS indefinitely. + var records = new List(); + var nextRecordsUri = $"domains/{zone}/records?type={recordType}&per_page=200"; + var recordsPageCount = 0; + + while (!string.IsNullOrEmpty(nextRecordsUri)) { - _logger.LogError( - "DigitalOcean API failed to list records for zone '{Zone}'. Status: {StatusCode}. Response: {Response}", - zone, (int)recordsResp.StatusCode, recordsBody); + recordsPageCount++; + if (recordsPageCount > MaxPaginationPages) + { + throw new InvalidOperationException( + $"DigitalOcean record list pagination for zone '{zone}' exceeded {MaxPaginationPages} pages without terminating; aborting."); + } - throw new InvalidOperationException( - $"DigitalOcean API returned {(int)recordsResp.StatusCode} ({recordsResp.StatusCode}) listing records in zone '{zone}': {recordsBody}"); + var recordsResp = await _httpClient.GetAsync(nextRecordsUri, cancellationToken); + var recordsBody = StripControlCharacters(await recordsResp.Content.ReadAsStringAsync(cancellationToken)); + if (!recordsResp.IsSuccessStatusCode) + { + _logger.LogError( + "DigitalOcean API failed to list records for zone '{Zone}'. Status: {StatusCode}. Response: {Response}", + zone, (int)recordsResp.StatusCode, recordsBody); + + throw new InvalidOperationException( + $"DigitalOcean API returned {(int)recordsResp.StatusCode} ({recordsResp.StatusCode}) listing records in zone '{zone}': {recordsBody}"); + } + + var recordsPage = JsonSerializer.Deserialize(recordsBody); + records.AddRange(recordsPage?.DomainRecords ?? Array.Empty()); + nextRecordsUri = recordsPage?.Links?.Pages?.Next; } - var records = JsonSerializer.Deserialize(recordsBody)?.DomainRecords ?? Array.Empty(); - var match = records.FirstOrDefault(r => - string.Equals(r.Type, recordType, StringComparison.OrdinalIgnoreCase) && - string.Equals(r.Name, relativeName, StringComparison.OrdinalIgnoreCase)); + // When multiple records share the same name/type (e.g. an apex + wildcard SAN both + // challenging at the same _acme-challenge FQDN with different values), matching by + // value as well prevents deleting a sibling authorization's still-pending record. + // expectedValue is only known when the caller staged it itself; without it we fall + // back to the original name/type-only match to preserve existing single-record behavior. + var match = expectedValue != null + ? records.FirstOrDefault(r => + string.Equals(r.Type, recordType, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Name, relativeName, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Data, expectedValue, StringComparison.Ordinal)) + : records.FirstOrDefault(r => + string.Equals(r.Type, recordType, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Name, relativeName, StringComparison.OrdinalIgnoreCase)); if (match == null) { @@ -157,11 +214,11 @@ public async Task DeleteRecordAsync(string recordName, string recordType) return true; } - var deleteResp = await _httpClient.DeleteAsync($"domains/{zone}/records/{match.Id}"); + var deleteResp = await _httpClient.DeleteAsync($"domains/{zone}/records/{match.Id}", cancellationToken); if (!deleteResp.IsSuccessStatusCode) { - var deleteBody = await deleteResp.Content.ReadAsStringAsync(); + var deleteBody = StripControlCharacters(await deleteResp.Content.ReadAsStringAsync(cancellationToken)); _logger.LogError( "DigitalOcean API rejected deletion of {RecordType} record '{RelativeName}' ({RecordId}) in zone '{Zone}'. Status: {StatusCode}. Response: {Response}", recordType, relativeName, match.Id, zone, (int)deleteResp.StatusCode, deleteBody); @@ -171,8 +228,8 @@ public async Task DeleteRecordAsync(string recordName, string recordType) } _logger.LogInformation( - "Deleted {RecordType} record '{RelativeName}' in DigitalOcean zone '{Zone}'", - recordType, relativeName, zone); + "Deleted {RecordType} record '{RelativeName}' ({RecordId}) in DigitalOcean zone '{Zone}', matched by {MatchMode}", + recordType, relativeName, match.Id, zone, expectedValue != null ? "value" : "name"); return true; } @@ -181,7 +238,7 @@ public async Task DeleteRecordAsync(string recordName, string recordType) /// resolves the zone that owns the given record by longest matching name suffix, e.g. /// for "_acme-challenge.www.example.com" it tries "www.example.com", then "example.com". /// - private async Task FindZoneForRecordAsync(string recordName) + private async Task FindZoneForRecordAsync(string recordName, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(recordName)) { @@ -190,11 +247,26 @@ private async Task FindZoneForRecordAsync(string recordName) var zoneNames = new List(); var nextUri = "domains?per_page=200"; + var pageCount = 0; while (!string.IsNullOrEmpty(nextUri)) { - var response = await _httpClient.GetAsync(nextUri); - var body = await response.Content.ReadAsStringAsync(); + pageCount++; + if (pageCount > MaxPaginationPages) + { + throw new InvalidOperationException( + $"DigitalOcean domain list pagination exceeded {MaxPaginationPages} pages without terminating; aborting."); + } + + // `next` (when present) is an ABSOLUTE URL from DigitalOcean's HATEOAS-style + // pagination, e.g. "https://api.digitalocean.com/v2/domains?page=2&per_page=200". + // HttpClient.GetAsync uses an absolute URI as-is, ignoring BaseAddress, so passing + // it straight through resolves correctly; re-deriving a relative path from it + // (previously done via Uri.PathAndQuery.TrimStart('/')) reintroduces the "/v2" + // segment and, combined with BaseAddress already ending in "/v2/", doubles it into + // "/v2/v2/...", which the real API 404s. + var response = await _httpClient.GetAsync(nextUri, cancellationToken); + var body = StripControlCharacters(await response.Content.ReadAsStringAsync(cancellationToken)); if (!response.IsSuccessStatusCode) { @@ -212,8 +284,7 @@ private async Task FindZoneForRecordAsync(string recordName) var domains = page?.Domains ?? Array.Empty(); zoneNames.AddRange(domains.Where(d => d.Name != null).Select(d => d.Name)); - var next = page?.Links?.Pages?.Next; - nextUri = string.IsNullOrEmpty(next) ? null : new Uri(next).PathAndQuery.TrimStart('/'); + nextUri = page?.Links?.Pages?.Next; } if (zoneNames.Count == 0) @@ -250,6 +321,22 @@ internal static string RelativeRecordName(string zone, string recordName) return trimmedRecord.Substring(0, trimmedRecord.Length - trimmedZone.Length - 1); } + /// + /// Strips control characters (including CR/LF) from a gateway-supplied record name before + /// it can reach any log message or exception text. A legitimate hostname never contains + /// these characters, so this only affects malformed/malicious input — it prevents an + /// unvalidated record name from forging log lines (CWE-117) via embedded CRLF. + /// + internal static string StripControlCharacters(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return new string(value.Where(c => !char.IsControl(c)).ToArray()); + } + /// /// Finds the zone whose name is the longest suffix match of the target domain, /// e.g. for domain "_acme-challenge.www.example.com" and zones {"example.com", "com"},