diff --git a/supervisor/Dada.Supervisor.Tests/Program.cs b/supervisor/Dada.Supervisor.Tests/Program.cs index c148984..f173b01 100644 --- a/supervisor/Dada.Supervisor.Tests/Program.cs +++ b/supervisor/Dada.Supervisor.Tests/Program.cs @@ -32,6 +32,7 @@ internal static class Program { var security = await TestCredentialBoundaryAsync(); var supervisor = await TestSupervisorLifecycleAsync(); + await TestAmapProbeSecurityAsync(); TestSecureConfigurationPersistence(); TestStructuredLogging(); WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security); @@ -46,6 +47,20 @@ internal static class Program } } + private static async Task TestAmapProbeSecurityAsync() + { + using var handler = AmapProbe.CreateHandler(); + False(handler.AllowAutoRedirect, "Amap probe redirects disabled"); + Equal(1, handler.MaxConnectionsPerServer, "Amap probe per-server connection cap"); + True(AmapProbe.IsAllowedEndpoint(new Uri("https://restapi.amap.com/v3/geocode/regeo")), "Amap fixed HTTPS endpoint accepted"); + False(AmapProbe.IsAllowedEndpoint(new Uri("http://restapi.amap.com/v3/geocode/regeo")), "Amap HTTP endpoint rejected"); + False(AmapProbe.IsAllowedEndpoint(new Uri("https://example.invalid/v3/geocode/regeo")), "Amap alternate host rejected"); + using var oversized = new ByteArrayContent(new byte[AmapProbe.MaximumResponseBytes + 1]); + await ThrowsAsync( + () => AmapProbe.ReadBoundedJsonAsync(oversized), + "Amap oversized response must be rejected before parsing"); + } + private static void TestStructuredLogging() { Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit"); diff --git a/supervisor/Dada.Supervisor/AmapProbe.cs b/supervisor/Dada.Supervisor/AmapProbe.cs index 5a40075..2c6a650 100644 --- a/supervisor/Dada.Supervisor/AmapProbe.cs +++ b/supervisor/Dada.Supervisor/AmapProbe.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Net; using System.Text.Json; @@ -5,7 +6,24 @@ namespace Dada.Supervisor; internal static class AmapProbe { - private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + internal const int MaximumResponseBytes = 65_536; + private const string ProviderHostname = "restapi.amap.com"; + private static readonly HttpClient Client = new(CreateHandler()) { Timeout = TimeSpan.FromSeconds(15) }; + + internal static SocketsHttpHandler CreateHandler() => new() + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(10), + MaxConnectionsPerServer = 1, + }; + + internal static bool IsAllowedEndpoint(Uri endpoint) => + endpoint.Scheme == Uri.UriSchemeHttps + && endpoint.Host.Equals(ProviderHostname, StringComparison.OrdinalIgnoreCase) + && (endpoint.IsDefaultPort || endpoint.Port == 443) + && string.IsNullOrEmpty(endpoint.UserInfo) + && endpoint.AbsolutePath is "/v3/geocode/regeo" or "/v3/geocode/geo"; internal static int Run(string? key) { @@ -15,41 +33,73 @@ internal static class AmapProbe return 3; } + var realCalls = 0; try { + realCalls += 1; var reverse = Call("https://restapi.amap.com/v3/geocode/regeo?location=120.6994,27.9943&extensions=base", key).GetAwaiter().GetResult(); + realCalls += 1; var geocode = Call($"https://restapi.amap.com/v3/geocode/geo?address={Uri.EscapeDataString("北京市天安门")}", key).GetAwaiter().GetResult(); var success = reverse.Status == "1" && geocode.Status == "1"; - Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, 2, reverse.Status, geocode.Status); + Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, realCalls, reverse.Status, geocode.Status); return success ? 0 : 3; } catch (TaskCanceledException) { - Write("amap_probe_timeout", false, 0, null, null); + Write("amap_probe_timeout", false, realCalls, null, null); return 3; } catch (HttpRequestException exception) { - Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, 0, null, null); + Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, realCalls, null, null); return 3; } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or InvalidDataException) { - Write("amap_probe_invalid_response", false, 0, null, null); + Write("amap_probe_invalid_response", false, realCalls, null, null); return 3; } } private static async Task Call(string endpoint, string key) { - using var response = await Client.GetAsync($"{endpoint}&key={Uri.EscapeDataString(key)}"); + var requestUri = new Uri($"{endpoint}&key={Uri.EscapeDataString(key)}", UriKind.Absolute); + if (!IsAllowedEndpoint(requestUri)) throw new InvalidDataException("amap_endpoint_rejected"); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); + using var document = await ReadBoundedJsonAsync(response.Content); var root = document.RootElement; return new ProbeResponse(root.GetProperty("status").GetString() ?? "", root.TryGetProperty("infocode", out var code) ? code.GetString() : null); } + internal static async Task ReadBoundedJsonAsync(HttpContent content) + { + if (content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large"); + await using var stream = await content.ReadAsStreamAsync(); + using var buffered = new MemoryStream(); + var buffer = ArrayPool.Shared.Rent(4_096); + try + { + var total = 0; + while (true) + { + var read = await stream.ReadAsync(buffer); + if (read == 0) break; + total += read; + if (total > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large"); + await buffered.WriteAsync(buffer.AsMemory(0, read)); + } + buffered.Position = 0; + return await JsonDocument.ParseAsync(buffered); + } + finally + { + Array.Clear(buffer); + ArrayPool.Shared.Return(buffer); + } + } + private static void Write(string code, bool success, int realCalls, string? reverseStatus, string? geocodeStatus) => Console.WriteLine(JsonSerializer.Serialize(new { code, success, real_calls = realCalls, reverse_status = reverseStatus, geocode_status = geocodeStatus }));