Skip to content

Commit 0cadcb3

Browse files
committed
Harden Microsoft parser edge cases
Malformed provider payloads and non-int enum values were still able to slip through shared deserialization paths, which could throw or allocate more than necessary during Bing and Azure response parsing.
1 parent 2da13f5 commit 0cadcb3

10 files changed

Lines changed: 301 additions & 58 deletions

File tree

.agents/skills/geocoding-library/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ dotnet build samples/Example.Web/Example.Web.csproj
6262
- `.claude/agents` and repo-owned skills must stay Geocoding.net-specific
6363
- Reference only skills that exist in `.agents/skills/`
6464
- Reference only commands, paths, and tools that exist in this workspace
65-
- Keep customization workflows aligned with AGENTS.md
65+
- Keep customization workflows aligned with AGENTS.md

src/Geocoding.Core/Serialization/TolerantStringEnumConverter.cs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,21 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer
2828

2929
internal sealed class TolerantStringEnumConverter<TEnum> : JsonConverter<TEnum> where TEnum : struct, Enum
3030
{
31+
private static readonly Type EnumType = typeof(TEnum);
32+
private static readonly Type UnderlyingType = Enum.GetUnderlyingType(EnumType);
3133
private static readonly TEnum FallbackValue = GetFallbackValue();
3234

3335
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
3436
{
3537
if (reader.TokenType == JsonTokenType.Number)
3638
{
37-
if (reader.TryGetInt32(out int intValue))
38-
return Enum.IsDefined(typeof(TEnum), intValue) ? (TEnum)(object)intValue : FallbackValue;
39-
40-
return FallbackValue;
39+
return TryReadNumericValue(ref reader, out var value) ? value : FallbackValue;
4140
}
4241

4342
if (reader.TokenType == JsonTokenType.String)
4443
{
4544
var value = reader.GetString();
46-
if (Enum.TryParse<TEnum>(value, true, out var result) && Enum.IsDefined(typeof(TEnum), result))
45+
if (Enum.TryParse<TEnum>(value, true, out var result) && Enum.IsDefined(EnumType, result))
4746
return result;
4847

4948
return FallbackValue;
@@ -59,14 +58,66 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
5958

6059
private static TEnum GetFallbackValue()
6160
{
62-
foreach (string name in Enum.GetNames(typeof(TEnum)))
61+
foreach (string name in Enum.GetNames(EnumType))
6362
{
6463
if (String.Equals(name, "Unknown", StringComparison.OrdinalIgnoreCase))
65-
return (TEnum)Enum.Parse(typeof(TEnum), name);
64+
return (TEnum)Enum.Parse(EnumType, name);
6665
}
6766

6867
return default;
6968
}
69+
70+
private static bool TryReadNumericValue(ref Utf8JsonReader reader, out TEnum value)
71+
{
72+
value = FallbackValue;
73+
74+
object? rawValue = null;
75+
switch (Type.GetTypeCode(UnderlyingType))
76+
{
77+
case TypeCode.SByte:
78+
if (reader.TryGetInt64(out var sbyteValue) && sbyteValue >= sbyte.MinValue && sbyteValue <= sbyte.MaxValue)
79+
rawValue = (sbyte)sbyteValue;
80+
break;
81+
case TypeCode.Byte:
82+
if (reader.TryGetUInt64(out var byteValue) && byteValue <= byte.MaxValue)
83+
rawValue = (byte)byteValue;
84+
break;
85+
case TypeCode.Int16:
86+
if (reader.TryGetInt64(out var int16Value) && int16Value >= short.MinValue && int16Value <= short.MaxValue)
87+
rawValue = (short)int16Value;
88+
break;
89+
case TypeCode.UInt16:
90+
if (reader.TryGetUInt64(out var uint16Value) && uint16Value <= ushort.MaxValue)
91+
rawValue = (ushort)uint16Value;
92+
break;
93+
case TypeCode.Int32:
94+
if (reader.TryGetInt32(out var int32Value))
95+
rawValue = int32Value;
96+
break;
97+
case TypeCode.UInt32:
98+
if (reader.TryGetUInt64(out var uint32Value) && uint32Value <= uint.MaxValue)
99+
rawValue = (uint)uint32Value;
100+
break;
101+
case TypeCode.Int64:
102+
if (reader.TryGetInt64(out var int64Value))
103+
rawValue = int64Value;
104+
break;
105+
case TypeCode.UInt64:
106+
if (reader.TryGetUInt64(out var uint64Value))
107+
rawValue = uint64Value;
108+
break;
109+
}
110+
111+
if (rawValue is null)
112+
return false;
113+
114+
var enumValue = (TEnum)Enum.ToObject(EnumType, rawValue);
115+
if (!Enum.IsDefined(EnumType, enumValue))
116+
return false;
117+
118+
value = enumValue;
119+
return true;
120+
}
70121
}
71122

72123
internal sealed class NullableTolerantStringEnumConverter<TEnum> : JsonConverter<TEnum?> where TEnum : struct, Enum

src/Geocoding.Microsoft/AzureMapsGeocoder.cs

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public class AzureMapsGeocoder : IGeocoder
3939

4040
/// <summary>
4141
/// Gets or sets the user IP address associated with the request.
42+
/// Retained for API compatibility only. Azure Maps Search does not accept an explicit user-IP hint when using subscription-key authentication, so the value is ignored.
4243
/// </summary>
4344
public IPAddress? UserIP { get; set; }
4445

@@ -228,13 +229,17 @@ private IEnumerable<AzureMapsAddress> ParseResponse(AzureSearchResponse response
228229
continue;
229230

230231
var address = result.Address ?? new AzureAddressPayload();
232+
var formattedAddress = FirstNonEmpty(address.FreeformAddress, address.StreetNameAndNumber, BuildStreetLine(address.StreetNumber, address.StreetName), result.Poi?.Name, result.Type, FirstNonEmpty(address.LocalName, address.Municipality, address.CountryTertiarySubdivision), address.Country);
233+
if (String.IsNullOrWhiteSpace(formattedAddress))
234+
continue;
235+
231236
var locality = FirstNonEmpty(address.LocalName, address.Municipality, address.CountryTertiarySubdivision);
232237
var neighborhood = IncludeNeighborhood
233238
? FirstNonEmpty(address.Neighbourhood, address.MunicipalitySubdivision)
234239
: String.Empty;
235240

236241
yield return new AzureMapsAddress(
237-
FirstNonEmpty(address.FreeformAddress, address.StreetNameAndNumber, BuildStreetLine(address.StreetNumber, address.StreetName), result.Poi?.Name, result.Type, locality, address.Country),
242+
formattedAddress,
238243
new Location(result.Position.Lat, result.Position.Lon),
239244
BuildStreetLine(address.StreetNumber, address.StreetName),
240245
FirstNonEmpty(address.CountrySubdivisionName, address.CountrySubdivision),
@@ -249,38 +254,46 @@ private IEnumerable<AzureMapsAddress> ParseResponse(AzureSearchResponse response
249254
yield break;
250255
}
251256

252-
if (response.Addresses is not null)
253-
{
254-
foreach (var reverseResult in response.Addresses)
255-
{
256-
if (reverseResult?.Address is null || reverseResult.Position is null || String.IsNullOrWhiteSpace(reverseResult.Position))
257-
continue;
258-
259-
var address = reverseResult.Address;
260-
if (!TryParsePosition(reverseResult.Position!, out var lat, out var lon))
261-
continue;
262-
263-
var locality = FirstNonEmpty(address.LocalName, address.Municipality, address.CountryTertiarySubdivision);
264-
var neighborhood = IncludeNeighborhood
265-
? FirstNonEmpty(address.Neighbourhood, address.MunicipalitySubdivision)
266-
: String.Empty;
257+
if (response.Addresses is null)
258+
yield break;
267259

268-
yield return new AzureMapsAddress(
269-
FirstNonEmpty(address.FreeformAddress, address.StreetNameAndNumber, BuildStreetLine(address.StreetNumber, address.StreetName), locality, address.Country),
270-
new Location(lat, lon),
271-
BuildStreetLine(address.StreetNumber, address.StreetName),
272-
FirstNonEmpty(address.CountrySubdivisionName, address.CountrySubdivision),
273-
address.CountrySecondarySubdivision,
274-
address.Country,
275-
locality,
276-
neighborhood,
277-
address.PostalCode,
278-
EntityType.Address,
279-
ConfidenceLevel.High);
280-
}
260+
foreach (var reverseResult in response.Addresses.Where(result => result?.Address is not null && !String.IsNullOrWhiteSpace(result.Position)))
261+
{
262+
var reverseAddress = CreateReverseAddress(reverseResult);
263+
if (reverseAddress is not null)
264+
yield return reverseAddress;
281265
}
282266
}
283267

268+
private AzureMapsAddress? CreateReverseAddress(AzureReverseResult? reverseResult)
269+
{
270+
if (reverseResult?.Address is null || !TryParsePosition(reverseResult.Position!, out var lat, out var lon))
271+
return null;
272+
273+
var address = reverseResult.Address;
274+
var formattedAddress = FirstNonEmpty(address.FreeformAddress, address.StreetNameAndNumber, BuildStreetLine(address.StreetNumber, address.StreetName), FirstNonEmpty(address.LocalName, address.Municipality, address.CountryTertiarySubdivision), address.Country);
275+
if (String.IsNullOrWhiteSpace(formattedAddress))
276+
return null;
277+
278+
var locality = FirstNonEmpty(address.LocalName, address.Municipality, address.CountryTertiarySubdivision);
279+
var neighborhood = IncludeNeighborhood
280+
? FirstNonEmpty(address.Neighbourhood, address.MunicipalitySubdivision)
281+
: String.Empty;
282+
283+
return new AzureMapsAddress(
284+
formattedAddress,
285+
new Location(lat, lon),
286+
BuildStreetLine(address.StreetNumber, address.StreetName),
287+
FirstNonEmpty(address.CountrySubdivisionName, address.CountrySubdivision),
288+
address.CountrySecondarySubdivision,
289+
address.Country,
290+
locality,
291+
neighborhood,
292+
address.PostalCode,
293+
EntityType.Address,
294+
ConfidenceLevel.High);
295+
}
296+
284297
private static bool TryParsePosition(string position, out double latitude, out double longitude)
285298
{
286299
latitude = 0;

src/Geocoding.Microsoft/BingMapsGeocoder.cs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -245,20 +245,28 @@ protected virtual IEnumerable<BingAddress> ParseResponse(Json.Response response)
245245

246246
foreach (var resourceSet in response.ResourceSets)
247247
{
248-
if (resourceSet is null || resourceSet.Locations.IsNullOrEmpty())
248+
if (resourceSet is null)
249249
continue;
250250

251-
foreach (var location in resourceSet.Locations)
251+
var locations = resourceSet.Locations;
252+
if (locations.IsNullOrEmpty())
253+
continue;
254+
255+
foreach (var location in locations)
252256
{
253-
if (location.Point is null || location.Address is null || location.Point.Coordinates.Length < 2)
257+
if (location.Point is null || location.Address is null)
258+
continue;
259+
260+
var coordinates = location.Point.Coordinates;
261+
if (coordinates is null || coordinates.Length < 2 || String.IsNullOrWhiteSpace(location.Address.FormattedAddress))
254262
continue;
255263

256264
if (!Enum.TryParse(location.EntityType, out EntityType entityType))
257265
entityType = EntityType.Unknown;
258266

259267
list.Add(new BingAddress(
260268
location.Address.FormattedAddress!,
261-
new Location(location.Point.Coordinates[0], location.Point.Coordinates[1]),
269+
new Location(coordinates[0], coordinates[1]),
262270
location.Address.AddressLine,
263271
location.Address.AdminDistrict,
264272
location.Address.AdminDistrict2,
@@ -312,17 +320,16 @@ private HttpClient BuildClient()
312320

313321
private ConfidenceLevel EvaluateConfidence(string? confidence)
314322
{
315-
switch (confidence?.ToLower())
316-
{
317-
case "low":
318-
return ConfidenceLevel.Low;
319-
case "medium":
320-
return ConfidenceLevel.Medium;
321-
case "high":
322-
return ConfidenceLevel.High;
323-
default:
324-
return ConfidenceLevel.Unknown;
325-
}
323+
if (String.Equals(confidence, "low", StringComparison.OrdinalIgnoreCase))
324+
return ConfidenceLevel.Low;
325+
326+
if (String.Equals(confidence, "medium", StringComparison.OrdinalIgnoreCase))
327+
return ConfidenceLevel.Medium;
328+
329+
if (String.Equals(confidence, "high", StringComparison.OrdinalIgnoreCase))
330+
return ConfidenceLevel.High;
331+
332+
return ConfidenceLevel.Unknown;
326333
}
327334

328335
private string BingUrlEncode(string toEncode)

src/Geocoding.Microsoft/Json.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ public override Resource[] Read(ref Utf8JsonReader reader, Type typeToConvert, J
507507
foreach (var element in document.RootElement.EnumerateArray())
508508
{
509509
var resourceType = ResolveResourceType(element);
510-
var resource = (Resource?)JsonSerializer.Deserialize(element.GetRawText(), resourceType, options);
510+
var resource = (Resource?)element.Deserialize(resourceType, options);
511511
if (resource is not null)
512512
resources.Add(resource);
513513
}

test/Geocoding.Tests/AzureMapsAsyncTest.cs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System.Collections;
2+
using System.Reflection;
3+
using System.Text.Json;
14
using Geocoding.Microsoft;
25
using Xunit;
36

@@ -38,4 +41,70 @@ public void Constructor_EmptyApiKey_ThrowsArgumentException()
3841
// Act & Assert
3942
Assert.Throws<ArgumentException>(() => new AzureMapsGeocoder(String.Empty));
4043
}
41-
}
44+
45+
[Fact]
46+
public void ParseResponse_SearchResultWithoutUsableFormattedAddress_SkipsEntry()
47+
{
48+
// Arrange
49+
var geocoder = new AzureMapsGeocoder("azure-key");
50+
51+
const string json = """
52+
{
53+
"results": [
54+
{
55+
"position": { "lat": 38.8976777, "lon": -77.036517 },
56+
"address": {
57+
"freeformAddress": " ",
58+
"municipality": " ",
59+
"country": " "
60+
}
61+
}
62+
]
63+
}
64+
""";
65+
66+
// Act
67+
var results = ParseResponse(geocoder, json);
68+
69+
// Assert
70+
Assert.Empty(results);
71+
}
72+
73+
[Fact]
74+
public void ParseResponse_ReverseResultWithoutUsableFormattedAddress_SkipsEntry()
75+
{
76+
// Arrange
77+
var geocoder = new AzureMapsGeocoder("azure-key");
78+
79+
const string json = """
80+
{
81+
"addresses": [
82+
{
83+
"position": "38.8976777,-77.036517",
84+
"address": {
85+
"freeformAddress": " ",
86+
"municipality": " ",
87+
"country": " "
88+
}
89+
}
90+
]
91+
}
92+
""";
93+
94+
// Act
95+
var results = ParseResponse(geocoder, json);
96+
97+
// Assert
98+
Assert.Empty(results);
99+
}
100+
101+
private static AzureMapsAddress[] ParseResponse(AzureMapsGeocoder geocoder, string json)
102+
{
103+
var responseType = typeof(AzureMapsGeocoder).GetNestedType("AzureSearchResponse", BindingFlags.NonPublic)!;
104+
var response = JsonSerializer.Deserialize(json, responseType);
105+
var parseMethod = typeof(AzureMapsGeocoder).GetMethod("ParseResponse", BindingFlags.Instance | BindingFlags.NonPublic)!;
106+
107+
var results = (IEnumerable)parseMethod.Invoke(geocoder, [response!])!;
108+
return results.Cast<AzureMapsAddress>().ToArray();
109+
}
110+
}

0 commit comments

Comments
 (0)