From 08d40da469962a4b4ed3e7c160afd55087aef1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2?= Date: Mon, 10 Aug 2026 19:23:34 +0300 Subject: [PATCH 1/5] Harden RTSW NOAA HTTP handling for WAF 202 and empty bodies. Treat AWS WAF challenge and empty JSON responses as failures so resilience can retry instead of returning an empty feed. Co-authored-by: Cursor --- Directory.Build.props | 2 +- Directory.Packages.props | 1 + src/NoaaClient/NoaaClient.csproj | 1 + src/NoaaClient/Rtsw/RtswClient.cs | 87 +++++++++++-- .../NoaaClient/Rtsw/RtswClientTests.cs | 119 ++++++++++++++++++ 5 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 7f7c68f..fe6b1f4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,7 +29,7 @@ - 1.2.0 + 1.2.1 diff --git a/Directory.Packages.props b/Directory.Packages.props index fff288d..0c6a4d0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj index 2f3462d..8d31b61 100644 --- a/src/NoaaClient/NoaaClient.csproj +++ b/src/NoaaClient/NoaaClient.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NoaaClient/Rtsw/RtswClient.cs b/src/NoaaClient/Rtsw/RtswClient.cs index a47fcee..c47e7b6 100644 --- a/src/NoaaClient/Rtsw/RtswClient.cs +++ b/src/NoaaClient/Rtsw/RtswClient.cs @@ -1,5 +1,10 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Framework.Json; using AuroraScienceHub.Integrations.NoaaClient.Rtsw.Responses; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace AuroraScienceHub.Integrations.NoaaClient.Rtsw; @@ -7,37 +12,101 @@ namespace AuroraScienceHub.Integrations.NoaaClient.Rtsw; /// internal sealed class RtswClient : IRtswClient { + private const string WafActionHeaderName = "x-amzn-waf-action"; + private const string WafChallengeAction = "challenge"; + + private static readonly JsonSerializerOptions s_jsonOptions = DefaultJsonSerializerOptions.Create(); + private readonly HttpClient _httpClient; private readonly Uri _baseUrl; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public RtswClient( HttpClient httpClient, - IOptions options) + IOptions options, + ILogger logger) { _httpClient = httpClient; _baseUrl = options.Value.RequiredServerUrl; + _logger = logger; } public async Task> GetMagnetometerDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "json/rtsw/rtsw_mag_1m.json"); - var records = await _httpClient - .GetFromJsonOrDefaultAsync>(url, cancellationToken) - .ConfigureAwait(false); - - return records ?? []; + return await GetRtswJsonAsync>(url, cancellationToken).ConfigureAwait(false) + ?? []; } public async Task> GetSolarWindPlasmaDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "json/rtsw/rtsw_wind_1m.json"); - var records = await _httpClient - .GetFromJsonOrDefaultAsync>(url, cancellationToken) + return await GetRtswJsonAsync>(url, cancellationToken).ConfigureAwait(false) + ?? []; + } + + private async Task GetRtswJsonAsync( + Uri requestUri, + CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false); + return await ReadRtswJsonOrThrowAsync(response, requestUri, cancellationToken).ConfigureAwait(false); + } + + private async Task ReadRtswJsonOrThrowAsync( + HttpResponseMessage response, + Uri requestUri, + CancellationToken cancellationToken) + { + var wafAction = GetWafAction(response); + var contentLength = response.Content.Headers.ContentLength; + + _logger.LogInformation( + "RTSW HTTP response Uri={RequestUri} StatusCode={StatusCode} WafAction={WafAction} ContentLength={ContentLength}", + requestUri, + (int)response.StatusCode, + wafAction, + contentLength); + + if (response.StatusCode == HttpStatusCode.Accepted + && string.Equals(wafAction, WafChallengeAction, StringComparison.OrdinalIgnoreCase)) + { + throw new HttpRequestException( + $"NOAA RTSW request blocked by AWS WAF challenge (HTTP {(int)response.StatusCode}, {WafActionHeaderName}={wafAction}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } + + await response.EnsureSuccess().ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NoContent || contentLength == 0) + { + throw new HttpRequestException( + $"NOAA RTSW returned an empty response body (HTTP {(int)response.StatusCode}, ContentLength={contentLength}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } + + return await response.Content + .ReadFromJsonAsync(s_jsonOptions, cancellationToken) .ConfigureAwait(false); + } + + private static string? GetWafAction(HttpResponseMessage response) + { + if (response.Headers.TryGetValues(WafActionHeaderName, out var values)) + { + return values.FirstOrDefault(); + } + + if (response.Content.Headers.TryGetValues(WafActionHeaderName, out values)) + { + return values.FirstOrDefault(); + } - return records ?? []; + return null; } } diff --git a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs new file mode 100644 index 0000000..95229e2 --- /dev/null +++ b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using AuroraScienceHub.Integrations.NoaaClient; +using AuroraScienceHub.Integrations.NoaaClient.Rtsw; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.Rtsw; + +/// +/// Unit tests for HTTP response hardening. +/// +public sealed class RtswClientTests +{ + private static readonly Uri BaseUrl = new("https://noaa.test/"); + + [Fact(DisplayName = "GetMagnetometerData throws on HTTP 202 WAF challenge with empty body")] + public async Task GetMagnetometerDataAsync_WhenWafChallenge202_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent(string.Empty) + }; + response.Headers.TryAddWithoutValidation("x-amzn-waf-action", "challenge"); + response.Content.Headers.ContentLength = 0; + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetMagnetometerDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("WAF"); + exception.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + [Fact(DisplayName = "GetSolarWindPlasmaData throws on successful response with empty body")] + public async Task GetSolarWindPlasmaDataAsync_WhenEmptyBody_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }; + response.Content.Headers.ContentLength = 0; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("empty"); + exception.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact(DisplayName = "GetMagnetometerData returns records for valid JSON body")] + public async Task GetMagnetometerDataAsync_WhenValidJson_ReturnsRecords() + { + // Arrange + var json = """ + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "bt": 5.0, + "bx_gsm": 1.0, + "by_gsm": 2.0, + "bz_gsm": 3.0 + } + ] + """; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + + var sut = CreateSut(response); + + // Act + var records = await sut.GetMagnetometerDataAsync(TestContext.Current.CancellationToken); + + // Assert + records.Count.ShouldBe(1); + records[0].Source.ShouldBe("SOLAR1"); + records[0].Bt.ShouldBe(5f); + } + + private static IRtswClient CreateSut(HttpResponseMessage response) + { + var handler = new TestHttpMessageHandler(response); + var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + return new RtswClient(httpClient, options, NullLogger.Instance); + } + + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + + public TestHttpMessageHandler(HttpResponseMessage response) + { + _response = response; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_response); + } + } +} From 564ab759a6654e6685769379425892529c20ce57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2?= Date: Tue, 11 Aug 2026 08:59:07 +0300 Subject: [PATCH 2/5] Sanitize NOAA RTSW JSON NaN/Infinity literals before deserialization. NOAA emits bare NaN in rtsw_wind_1m.json which breaks System.Text.Json and stops the import job. Replace non-standard literals with null in RtswClient and bump NoaaClient to 1.2.2. Co-authored-by: Cursor --- Directory.Build.props | 2 +- src/NoaaClient/Rtsw/RtswClient.cs | 17 +- src/NoaaClient/Utilities/NoaaJsonSanitizer.cs | 187 ++++++++++++++++++ .../NoaaClient/Rtsw/RtswClientTests.cs | 32 +++ .../Utilities/NoaaJsonSanitizerTests.cs | 91 +++++++++ 5 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 src/NoaaClient/Utilities/NoaaJsonSanitizer.cs create mode 100644 tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index fe6b1f4..a2419b6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -29,7 +29,7 @@ - 1.2.1 + 1.2.2 diff --git a/src/NoaaClient/Rtsw/RtswClient.cs b/src/NoaaClient/Rtsw/RtswClient.cs index c47e7b6..a69aa90 100644 --- a/src/NoaaClient/Rtsw/RtswClient.cs +++ b/src/NoaaClient/Rtsw/RtswClient.cs @@ -1,9 +1,9 @@ using System.Net; -using System.Net.Http.Json; using System.Text.Json; using AuroraScienceHub.Framework.Http; using AuroraScienceHub.Framework.Json; using AuroraScienceHub.Integrations.NoaaClient.Rtsw.Responses; +using AuroraScienceHub.Integrations.NoaaClient.Utilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -90,9 +90,20 @@ public async Task> GetSolarWindPlasmaDataAs statusCode: response.StatusCode); } - return await response.Content - .ReadFromJsonAsync(s_jsonOptions, cancellationToken) + var rawBytes = await response.Content + .ReadAsByteArrayAsync(cancellationToken) .ConfigureAwait(false); + + var sanitized = NoaaJsonSanitizer.Sanitize(rawBytes); + if (sanitized.ReplacementCount > 0) + { + _logger.LogWarning( + "NOAA RTSW JSON sanitized {ReplacementCount} non-standard numeric literals to null. Uri={RequestUri}", + sanitized.ReplacementCount, + requestUri); + } + + return JsonSerializer.Deserialize(sanitized.Bytes.Span, s_jsonOptions); } private static string? GetWafAction(HttpResponseMessage response) diff --git a/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs new file mode 100644 index 0000000..3cfac28 --- /dev/null +++ b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs @@ -0,0 +1,187 @@ +namespace AuroraScienceHub.Integrations.NoaaClient.Utilities; + +/// +/// Replaces non-standard NOAA JSON numeric literals with RFC-compliant null tokens. +/// +internal static class NoaaJsonSanitizer +{ + private static ReadOnlySpan NullLiteral => "null"u8; + private static ReadOnlySpan NanLiteral => "NaN"u8; + private static ReadOnlySpan InfinityLiteral => "Infinity"u8; + private static ReadOnlySpan NegativeInfinityLiteral => "-Infinity"u8; + + /// + /// Sanitizes NOAA JSON payload by replacing bare and quoted NaN/Infinity literals with null. + /// + public static SanitizeResult Sanitize(ReadOnlyMemory source) + { + var span = source.Span; + if (span.Length == 0) + { + return new SanitizeResult(source, 0); + } + + var output = new byte[span.Length + 64]; + var writeIndex = 0; + var replacements = 0; + var inString = false; + var i = 0; + + while (i < span.Length) + { + EnsureCapacity(ref output, writeIndex + 16); + + var current = span[i]; + + if (inString) + { + output[writeIndex++] = current; + + if (current == (byte)'\\' && i + 1 < span.Length) + { + output[writeIndex++] = span[i + 1]; + i += 2; + continue; + } + + if (current == (byte)'"') + { + inString = false; + } + + i++; + continue; + } + + if (current == (byte)'"') + { + if (TryReplaceQuotedLiteral(span, ref i, NegativeInfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceQuotedLiteral(span, ref i, InfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceQuotedLiteral(span, ref i, NanLiteral, output, ref writeIndex, ref replacements)) + { + continue; + } + + inString = true; + output[writeIndex++] = current; + i++; + continue; + } + + if (TryReplaceBareLiteral(span, ref i, NegativeInfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceBareLiteral(span, ref i, InfinityLiteral, output, ref writeIndex, ref replacements) + || TryReplaceBareLiteral(span, ref i, NanLiteral, output, ref writeIndex, ref replacements)) + { + continue; + } + + output[writeIndex++] = current; + i++; + } + + if (replacements == 0) + { + return new SanitizeResult(source, 0); + } + + return new SanitizeResult(output.AsMemory(0, writeIndex), replacements); + } + + private static bool TryReplaceQuotedLiteral( + ReadOnlySpan source, + ref int index, + ReadOnlySpan literal, + byte[] output, + ref int writeIndex, + ref int replacements) + { + var endIndex = index + 1 + literal.Length; + + if (endIndex >= source.Length || source[endIndex] != (byte)'"') + { + return false; + } + + if (!source.Slice(index + 1, literal.Length).SequenceEqual(literal)) + { + return false; + } + + WriteNullLiteral(output, ref writeIndex); + replacements++; + index = endIndex + 1; + return true; + } + + private static bool TryReplaceBareLiteral( + ReadOnlySpan source, + ref int index, + ReadOnlySpan literal, + byte[] output, + ref int writeIndex, + ref int replacements) + { + if (index + literal.Length > source.Length) + { + return false; + } + + if (!source.Slice(index, literal.Length).SequenceEqual(literal)) + { + return false; + } + + if (!IsBareLiteralStart(source, index) || !IsBareLiteralEnd(source, index + literal.Length)) + { + return false; + } + + WriteNullLiteral(output, ref writeIndex); + replacements++; + index += literal.Length; + return true; + } + + private static bool IsBareLiteralStart(ReadOnlySpan source, int index) + { + if (index == 0) + { + return true; + } + + return IsDelimiter(source[index - 1]); + } + + private static bool IsBareLiteralEnd(ReadOnlySpan source, int index) + { + if (index >= source.Length) + { + return true; + } + + return IsDelimiter(source[index]); + } + + private static bool IsDelimiter(byte value) + => value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' + or (byte)':' or (byte)',' or (byte)'[' or (byte)']' or (byte)'{' or (byte)'}'; + + private static void WriteNullLiteral(byte[] output, ref int writeIndex) + { + EnsureCapacity(ref output, writeIndex + NullLiteral.Length); + NullLiteral.CopyTo(output.AsSpan(writeIndex)); + writeIndex += NullLiteral.Length; + } + + private static void EnsureCapacity(ref byte[] output, int requiredLength) + { + if (requiredLength <= output.Length) + { + return; + } + + Array.Resize(ref output, Math.Max(requiredLength, output.Length * 2)); + } + + internal readonly record struct SanitizeResult(ReadOnlyMemory Bytes, int ReplacementCount); +} diff --git a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs index 95229e2..8d4fde4 100644 --- a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs +++ b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs @@ -58,6 +58,38 @@ public async Task GetSolarWindPlasmaDataAsync_WhenEmptyBody_ThrowsHttpRequestExc exception.StatusCode.ShouldBe(HttpStatusCode.OK); } + [Fact(DisplayName = "GetSolarWindPlasmaData deserializes bare NaN as null")] + public async Task GetSolarWindPlasmaDataAsync_WhenBareNaN_DeserializesNullSpeed() + { + // Arrange + var json = """ + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "proton_speed": NaN, + "proton_density": 5.0, + "proton_temperature": 100000.0 + } + ] + """; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + + var sut = CreateSut(response); + + // Act + var records = await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken); + + // Assert + records.Count.ShouldBe(1); + records[0].ProtonSpeed.ShouldBeNull(); + records[0].ProtonDensity.ShouldBe(5f); + } + [Fact(DisplayName = "GetMagnetometerData returns records for valid JSON body")] public async Task GetMagnetometerDataAsync_WhenValidJson_ReturnsRecords() { diff --git a/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs b/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs new file mode 100644 index 0000000..109ecc9 --- /dev/null +++ b/tests/UnitTests/NoaaClient/Utilities/NoaaJsonSanitizerTests.cs @@ -0,0 +1,91 @@ +using System.Text; +using AuroraScienceHub.Integrations.NoaaClient.Utilities; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.Utilities; + +/// +/// Unit tests for . +/// +public sealed class NoaaJsonSanitizerTests +{ + [Fact(DisplayName = "Sanitize replaces bare NaN with null")] + public void Sanitize_WhenBareNaN_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": NaN}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize replaces bare -Infinity with null")] + public void Sanitize_WhenBareNegativeInfinity_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": -Infinity}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize replaces quoted NaN with null")] + public void Sanitize_WhenQuotedNaN_ReplacesWithNull() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"proton_speed": "NaN"}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(1); + result.Bytes.Span.ToArray().ShouldBe(Encoding.UTF8.GetBytes("""{"proton_speed": null}""")); + } + + [Fact(DisplayName = "Sanitize does not modify NaN inside string values")] + public void Sanitize_WhenNaNInsideStringValue_DoesNotModify() + { + // Arrange + var source = Encoding.UTF8.GetBytes("""{"source": "NaN sensor"}"""); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(0); + result.Bytes.ShouldBe(source); + } + + [Fact(DisplayName = "Sanitize leaves valid JSON unchanged")] + public void Sanitize_WhenValidJson_ReturnsOriginalMemory() + { + // Arrange + var source = Encoding.UTF8.GetBytes(""" + [ + { + "time_tag": "2026-08-10T09:45:00Z", + "active": true, + "source": "SOLAR1", + "proton_speed": 420.0, + "proton_density": 5.0 + } + ] + """); + + // Act + var result = NoaaJsonSanitizer.Sanitize(source); + + // Assert + result.ReplacementCount.ShouldBe(0); + result.Bytes.ShouldBe(source); + } +} From d4160abc85da81c94cefa18e3a0db9d715e55726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2?= Date: Tue, 11 Aug 2026 14:05:53 +0300 Subject: [PATCH 3/5] Address review: EnsureNoaaSuccess for all NOAA clients, lazy sanitizer, empty body check. Co-authored-by: Cursor --- src/NoaaClient/Ace/AceClient.cs | 9 ++- .../Http/NoaaHttpResponseExtensions.cs | 76 +++++++++++++++++++ src/NoaaClient/KpIndex/KpIndexClient.cs | 5 +- src/NoaaClient/Rtsw/RtswClient.cs | 41 ++-------- src/NoaaClient/Utilities/NoaaJsonSanitizer.cs | 18 +++++ src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 25 ++++-- .../NoaaClient/Rtsw/RtswClientTests.cs | 20 +++++ 7 files changed, 147 insertions(+), 47 deletions(-) create mode 100644 src/NoaaClient/Http/NoaaHttpResponseExtensions.cs diff --git a/src/NoaaClient/Ace/AceClient.cs b/src/NoaaClient/Ace/AceClient.cs index d808fa9..c30f1ef 100644 --- a/src/NoaaClient/Ace/AceClient.cs +++ b/src/NoaaClient/Ace/AceClient.cs @@ -1,5 +1,6 @@ using AuroraScienceHub.Integrations.NoaaClient.Ace.Extensions; using AuroraScienceHub.Integrations.NoaaClient.Ace.Responses; +using AuroraScienceHub.Integrations.NoaaClient.Http; using Microsoft.Extensions.Options; namespace AuroraScienceHub.Integrations.NoaaClient.Ace; @@ -23,8 +24,8 @@ public AceClient( public async Task> GetMagnetometerDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "text/ace-magnetometer.txt"); - var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return MagnetometerDataParser.Parse(text); @@ -33,8 +34,8 @@ public async Task> GetMagnetometerDataAsync(Ca public async Task> GetSwepamDataAsync(CancellationToken cancellationToken) { var url = new Uri(_baseUrl, "text/ace-swepam.txt"); - var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return SolarWindPlasmaDataParser.Parse(text); diff --git a/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs b/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs new file mode 100644 index 0000000..b21e553 --- /dev/null +++ b/src/NoaaClient/Http/NoaaHttpResponseExtensions.cs @@ -0,0 +1,76 @@ +using System.Net; +using AuroraScienceHub.Framework.Http; + +namespace AuroraScienceHub.Integrations.NoaaClient.Http; + +/// +/// NOAA-specific HTTP response validation helpers. +/// +internal static class NoaaHttpResponseExtensions +{ + private const string WafActionHeaderName = "x-amzn-waf-action"; + private const string WafChallengeAction = "challenge"; + + /// + /// Ensures the NOAA response is not a WAF challenge and has a successful status code. + /// + public static async Task EnsureNoaaSuccessAsync( + this HttpResponseMessage response, + Uri? requestUri = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfWafChallenge(response, requestUri); + await response.EnsureSuccess().ConfigureAwait(false); + } + + /// + /// Throws when the NOAA response body is empty after a successful status code. + /// + public static void ThrowIfEmptyBody( + this HttpResponseMessage response, + ReadOnlySpan body, + Uri? requestUri = null) + { + if (body.Length > 0) + { + return; + } + + var contentLength = response.Content.Headers.ContentLength; + throw new HttpRequestException( + $"NOAA returned an empty response body (HTTP {(int)response.StatusCode}, ContentLength={contentLength}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } + + internal static string? GetWafAction(HttpResponseMessage response) + { + if (response.Headers.TryGetValues(WafActionHeaderName, out var values)) + { + return values.FirstOrDefault(); + } + + if (response.Content.Headers.TryGetValues(WafActionHeaderName, out values)) + { + return values.FirstOrDefault(); + } + + return null; + } + + private static void ThrowIfWafChallenge(HttpResponseMessage response, Uri? requestUri) + { + var wafAction = GetWafAction(response); + if (response.StatusCode != HttpStatusCode.Accepted + || !string.Equals(wafAction, WafChallengeAction, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new HttpRequestException( + $"NOAA request blocked by AWS WAF challenge (HTTP {(int)response.StatusCode}, {WafActionHeaderName}={wafAction}, Uri={requestUri}).", + inner: null, + statusCode: response.StatusCode); + } +} diff --git a/src/NoaaClient/KpIndex/KpIndexClient.cs b/src/NoaaClient/KpIndex/KpIndexClient.cs index 35a978d..5f50b78 100644 --- a/src/NoaaClient/KpIndex/KpIndexClient.cs +++ b/src/NoaaClient/KpIndex/KpIndexClient.cs @@ -1,3 +1,4 @@ +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.KpIndex.Extensions; using AuroraScienceHub.Integrations.NoaaClient.KpIndex.Responses; using Microsoft.Extensions.Options; @@ -41,8 +42,8 @@ public async Task> GetKpIndexNowcastAsync( private async Task GetStringOrDefaultAsync(Uri url, CancellationToken cancellationToken) { - var response = await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + using var response = await _client.GetAsync(url, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(url, cancellationToken).ConfigureAwait(false); return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/NoaaClient/Rtsw/RtswClient.cs b/src/NoaaClient/Rtsw/RtswClient.cs index a69aa90..1e4a5c6 100644 --- a/src/NoaaClient/Rtsw/RtswClient.cs +++ b/src/NoaaClient/Rtsw/RtswClient.cs @@ -2,6 +2,7 @@ using System.Text.Json; using AuroraScienceHub.Framework.Http; using AuroraScienceHub.Framework.Json; +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.Rtsw.Responses; using AuroraScienceHub.Integrations.NoaaClient.Utilities; using Microsoft.Extensions.Logging; @@ -12,9 +13,6 @@ namespace AuroraScienceHub.Integrations.NoaaClient.Rtsw; /// internal sealed class RtswClient : IRtswClient { - private const string WafActionHeaderName = "x-amzn-waf-action"; - private const string WafChallengeAction = "challenge"; - private static readonly JsonSerializerOptions s_jsonOptions = DefaultJsonSerializerOptions.Create(); private readonly HttpClient _httpClient; @@ -61,7 +59,7 @@ public async Task> GetSolarWindPlasmaDataAs Uri requestUri, CancellationToken cancellationToken) { - var wafAction = GetWafAction(response); + var wafAction = NoaaHttpResponseExtensions.GetWafAction(response); var contentLength = response.Content.Headers.ContentLength; _logger.LogInformation( @@ -71,29 +69,19 @@ public async Task> GetSolarWindPlasmaDataAs wafAction, contentLength); - if (response.StatusCode == HttpStatusCode.Accepted - && string.Equals(wafAction, WafChallengeAction, StringComparison.OrdinalIgnoreCase)) - { - throw new HttpRequestException( - $"NOAA RTSW request blocked by AWS WAF challenge (HTTP {(int)response.StatusCode}, {WafActionHeaderName}={wafAction}, Uri={requestUri}).", - inner: null, - statusCode: response.StatusCode); - } - - await response.EnsureSuccess().ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(requestUri, cancellationToken).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NoContent || contentLength == 0) + if (response.StatusCode == HttpStatusCode.NoContent) { - throw new HttpRequestException( - $"NOAA RTSW returned an empty response body (HTTP {(int)response.StatusCode}, ContentLength={contentLength}, Uri={requestUri}).", - inner: null, - statusCode: response.StatusCode); + response.ThrowIfEmptyBody(ReadOnlySpan.Empty, requestUri); } var rawBytes = await response.Content .ReadAsByteArrayAsync(cancellationToken) .ConfigureAwait(false); + response.ThrowIfEmptyBody(rawBytes, requestUri); + var sanitized = NoaaJsonSanitizer.Sanitize(rawBytes); if (sanitized.ReplacementCount > 0) { @@ -105,19 +93,4 @@ public async Task> GetSolarWindPlasmaDataAs return JsonSerializer.Deserialize(sanitized.Bytes.Span, s_jsonOptions); } - - private static string? GetWafAction(HttpResponseMessage response) - { - if (response.Headers.TryGetValues(WafActionHeaderName, out var values)) - { - return values.FirstOrDefault(); - } - - if (response.Content.Headers.TryGetValues(WafActionHeaderName, out values)) - { - return values.FirstOrDefault(); - } - - return null; - } } diff --git a/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs index 3cfac28..fff0d02 100644 --- a/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs +++ b/src/NoaaClient/Utilities/NoaaJsonSanitizer.cs @@ -21,6 +21,11 @@ public static SanitizeResult Sanitize(ReadOnlyMemory source) return new SanitizeResult(source, 0); } + if (!MayContainNonStandardNumericLiteral(span)) + { + return new SanitizeResult(source, 0); + } + var output = new byte[span.Length + 64]; var writeIndex = 0; var replacements = 0; @@ -87,6 +92,19 @@ public static SanitizeResult Sanitize(ReadOnlyMemory source) return new SanitizeResult(output.AsMemory(0, writeIndex), replacements); } + private static bool MayContainNonStandardNumericLiteral(ReadOnlySpan span) + { + for (var i = 0; i < span.Length; i++) + { + if (span[i] is (byte)'N' or (byte)'I') + { + return true; + } + } + + return false; + } + private static bool TryReplaceQuotedLiteral( ReadOnlySpan source, ref int index, diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index cf3e249..d940bb6 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -1,6 +1,8 @@ using System.Globalization; +using System.Text.Json; using System.Text.RegularExpressions; -using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Framework.Json; +using AuroraScienceHub.Integrations.NoaaClient.Http; using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; using FFMpegCore; using FFMpegCore.Pipes; @@ -10,6 +12,8 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; internal sealed partial class WsaEnlilClient : IWsaEnlilClient { + private static readonly JsonSerializerOptions s_jsonOptions = DefaultJsonSerializerOptions.Create(); + private const string ManifestPath = "products/animations/enlil.json"; private const int Fps = 20; private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst) @@ -90,9 +94,16 @@ public async Task GetEnlilAnimationAsync( CancellationToken cancellationToken) { var manifestUrl = new Uri(_baseUrl, ManifestPath); - return await _httpClient - .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) - .ConfigureAwait(false); + using var response = await _httpClient.GetAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + + var rawBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (rawBytes.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize>(rawBytes, s_jsonOptions); } private async Task DownloadFramesAsync( @@ -106,9 +117,9 @@ private async Task DownloadFramesAsync( cancellationToken.ThrowIfCancellationRequested(); var frameUrl = new Uri(_baseUrl, entry.Url); - await using var sourceStream = await _httpClient - .GetStreamAsync(frameUrl, cancellationToken) - .ConfigureAwait(false); + using var response = await _httpClient.GetAsync(frameUrl, cancellationToken).ConfigureAwait(false); + await response.EnsureNoaaSuccessAsync(frameUrl, cancellationToken).ConfigureAwait(false); + await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var framePath = Path.Combine(tempDir, string.Format(FrameFileFormat, index)); await using var fileStream = File.Create(framePath); diff --git a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs index 8d4fde4..e1dba4f 100644 --- a/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs +++ b/tests/UnitTests/NoaaClient/Rtsw/RtswClientTests.cs @@ -90,6 +90,26 @@ public async Task GetSolarWindPlasmaDataAsync_WhenBareNaN_DeserializesNullSpeed( records[0].ProtonDensity.ShouldBe(5f); } + [Fact(DisplayName = "GetSolarWindPlasmaData throws on chunked empty body without Content-Length")] + public async Task GetSolarWindPlasmaDataAsync_WhenChunkedEmptyBody_ThrowsHttpRequestException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Array.Empty()) + }; + response.Content.Headers.ContentLength = null; + + var sut = CreateSut(response); + + // Act + var exception = await Should.ThrowAsync( + async () => await sut.GetSolarWindPlasmaDataAsync(TestContext.Current.CancellationToken)); + + // Assert + exception.Message.ShouldContain("empty"); + } + [Fact(DisplayName = "GetMagnetometerData returns records for valid JSON body")] public async Task GetMagnetometerDataAsync_WhenValidJson_ReturnsRecords() { From a77ba92449a0fb67eb232ad61a84b218bbf7e6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2?= Date: Tue, 11 Aug 2026 17:55:14 +0300 Subject: [PATCH 4/5] Bump Framework packages to 10.0.7 and document 1.2.2 NoaaClient fixes. Co-authored-by: Cursor --- CHANGELOG.md | 13 +++++++++++++ Directory.Packages.props | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feddc5..8a6822c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.2] - 2026-08-11 + +### Fixed + +#### NoaaClient Package +- Treat AWS WAF `202` challenges (`x-amzn-waf-action: challenge`) and empty response bodies as failures via `EnsureNoaaSuccessAsync` for all NOAA clients (`Rtsw`, `Ace`, `KpIndex`, `WsaEnlil`) +- Sanitize bare/quoted `NaN`/`Infinity` literals in RTSW JSON before deserialization (lazy allocation when the payload is clean) + +### Changed + +#### Dependencies +- Bump `AuroraScienceHub.Framework.Http` and `AuroraScienceHub.Framework.Utilities` from 10.0.5 to 10.0.7 + ## [1.1.1] - 2026-07-07 ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index 0c6a4d0..f93c58e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,8 +5,8 @@ true - - + + all From ead2e2a78082557f1bb1961f8888729ee511064b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2?= Date: Tue, 11 Aug 2026 18:05:09 +0300 Subject: [PATCH 5/5] Map AuroraScienceHub.* to nuget.org and GitHub Packages for stable/prerelease restore. Dual packageSourceMapping lets exact stable pins (e.g. Framework 10.0.7) resolve from nuget.org while prereleases remain available from GH Packages. Also bump Microsoft.Extensions.* to 10.0.10 for Framework 10.0.7. Co-authored-by: Cursor --- CHANGELOG.md | 2 ++ Directory.Packages.props | 8 ++++---- NuGet.Config | 9 +++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6822c..e4ae874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Dependencies - Bump `AuroraScienceHub.Framework.Http` and `AuroraScienceHub.Framework.Utilities` from 10.0.5 to 10.0.7 +- Bump `Microsoft.Extensions.*` pins (`DependencyInjection.Abstractions`, `Hosting`, `Http`, `Logging.Abstractions`) from 10.0.7 to 10.0.10 (required by Framework 10.0.7) +- Dual-map `AuroraScienceHub.*` to nuget.org in `NuGet.Config` so stable Framework packages resolve from nuget.org ## [1.1.1] - 2026-07-07 diff --git a/Directory.Packages.props b/Directory.Packages.props index f93c58e..66bb572 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,10 +16,10 @@ - - - - + + + + diff --git a/NuGet.Config b/NuGet.Config index 5061ff1..e92eb30 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,18 +2,19 @@ - + - + + - + -