From f29889988351368778aeeed5a4869ed52344f5f9 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 16:30:30 +0100 Subject: [PATCH 1/2] Fix Graph URL tokenization for parenthesized segments. Closes #2041 - Fix out-of-range Substring when extracting text within parentheses, which made every key/function segment (e.g. users('id')) throw - Match function names containing digits, underscores and dots (e.g. getOffice365ActiveUserDetail, microsoft.graph.delta) - Keep empty parentheses as-is (delta()) - Split parameters outside OData quoted literals - Sanitize path segments positionally instead of using a global string replace, which corrupted later segments with the same value Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Utils/ProxyUtils.cs | 63 +++++++++++++------ DevProxy.Integration.Tests/GraphUtilsTests.cs | 44 +++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index 7c5af680..5323e53a 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -34,7 +34,7 @@ public static class ProxyUtils // all alpha must include 2 to allow for oauth2PermissionScopes private static readonly Regex allAlphaRegex = new("^[a-z2]+$", RegexOptions.IgnoreCase); private static readonly Regex deprecationRegex = new("^[a-z]+_v2$", RegexOptions.IgnoreCase); - private static readonly Regex functionCallRegex = new(@"^[a-z]+\(.*\)$", RegexOptions.IgnoreCase); + private static readonly Regex functionCallRegex = new(@"^[a-z][a-z0-9_.]*\(.*\)$", RegexOptions.IgnoreCase); private static Assembly? _assembly; private static string _productVersion = string.Empty; @@ -264,12 +264,12 @@ public static string SanitizeUrl(string absoluteUrl) }); // Split requestUrl into segments that can be sanitized individually var urlSegments = resourceUrl.Split('/'); + var sanitizedSegments = new string[urlSegments.Length]; for (var i = 0; i < urlSegments.Length; i++) { - var segment = urlSegments[i]; - var sanitizedSegment = SanitizePathSegment(i < 1 ? "" : urlSegments[i - 1], segment); - resourceUrl = resourceUrl.Replace(segment, sanitizedSegment, StringComparison.OrdinalIgnoreCase); + sanitizedSegments[i] = SanitizePathSegment(i < 1 ? "" : urlSegments[i - 1], urlSegments[i]); } + resourceUrl = string.Join('/', sanitizedSegments); } return $"{uri.GetLeftPart(UriPartial.Authority)}/{parsedSample.QueryVersion}/{resourceUrl}{queryString}"; } @@ -680,25 +680,25 @@ private static string SanitizePathSegment(string previousSegment, string segment } // Check if segment is in this form: users('|') and transform to users() + // or getOffice365ActiveUserDetail(period='D7') and transform to getOffice365ActiveUserDetail(period=) if (IsFunctionCall(segment)) { - var openingBracketIndex = segment.IndexOf('(', StringComparison.OrdinalIgnoreCase); - var textWithinBrackets = segment.Substring( - openingBracketIndex + 1, - segment.Length - 2 - ); - var sanitizedText = string.Join(',', textWithinBrackets - .Split(',') - .Select(text => - { - if (text.Contains('=', StringComparison.OrdinalIgnoreCase)) + var openingBracketIndex = segment.IndexOf('(', StringComparison.Ordinal); + var textWithinBrackets = segment[(openingBracketIndex + 1)..^1]; + var sanitizedText = string.IsNullOrWhiteSpace(textWithinBrackets) + ? string.Empty + : string.Join(',', SplitOutsideQuotes(textWithinBrackets, ',') + .Select(text => { - var key = text.Split('=')[0]; - key = !IsAllAlpha(key) ? "" : key; - return $"{key}="; - } - return ""; - })); + var keyValue = SplitOutsideQuotes(text, '='); + if (keyValue.Count > 1) + { + var key = keyValue[0].Trim(); + key = !IsAllAlpha(key) ? "" : key; + return $"{key}="; + } + return ""; + })); return $"{segment[..openingBracketIndex]}({sanitizedText})"; } @@ -731,6 +731,29 @@ private static string SanitizeQueryParameters(string queryString) private static bool IsFunctionCall(string value) => functionCallRegex.IsMatch(value); + // Splits on the separator, ignoring separators inside OData single-quoted + // string literals (where a quote is escaped by doubling it) + private static List SplitOutsideQuotes(string value, char separator) + { + var parts = new List(); + var inQuotes = false; + var start = 0; + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\'') + { + inQuotes = !inQuotes; + } + else if (value[i] == separator && !inQuotes) + { + parts.Add(value[start..i]); + start = i + 1; + } + } + parts.Add(value[start..]); + return parts; + } + private static bool IsPlaceHolderSegment(string segment) { return segment.StartsWith('{') && segment.EndsWith('}'); diff --git a/DevProxy.Integration.Tests/GraphUtilsTests.cs b/DevProxy.Integration.Tests/GraphUtilsTests.cs index 17d6919f..6faa5d17 100644 --- a/DevProxy.Integration.Tests/GraphUtilsTests.cs +++ b/DevProxy.Integration.Tests/GraphUtilsTests.cs @@ -61,6 +61,50 @@ public void GetTokenizedUrl_NonPathAddressedUrl_PreservesExistingTokenization() "/sites/{sites-id}/drive/items/{items-id}/content", GraphUtils.GetTokenizedUrl("https://graph.microsoft.com/v1.0/sites/site-id/drive/items/item-id/content")); + [Theory] + [InlineData( + "https://graph.microsoft.com/v1.0/users('48d31887-5fad-4d73-a9f5-3c356e68a038')", + "/users()")] + [InlineData( + "https://graph.microsoft.com/v1.0/users('john@contoso.com')/messages", + "/users()/messages")] + [InlineData( + "https://graph.microsoft.com/v1.0/applications(appId='46e6adf4-a9cf-4b60-9390-0ba6fb00bf6b')", + "/applications(appId=)")] + [InlineData( + "https://graph.microsoft.com/v1.0/oauth2PermissionGrants('grant-id')", + "/oauth2PermissionGrants()")] + public void GetTokenizedUrl_KeyInParentheses_ReturnsTokenizedKey(string url, string expected) => + Assert.Equal(expected, GraphUtils.GetTokenizedUrl(url)); + + [Theory] + [InlineData( + "https://graph.microsoft.com/v1.0/reports/getOffice365ActiveUserDetail(period='D7')", + "/reports/getOffice365ActiveUserDetail(period=)")] + [InlineData( + "https://graph.microsoft.com/v1.0/me/calendarView/delta()", + "/me/calendarView/delta()")] + [InlineData( + "https://graph.microsoft.com/v1.0/me/drive/root/search(q='budget, 2024=final')", + "/me/drive/root/search(q=)")] + [InlineData( + "https://graph.microsoft.com/v1.0/me/drive/root/search(q='O''Brien')", + "/me/drive/root/search(q=)")] + [InlineData( + "https://graph.microsoft.com/v1.0/me/drive/items/1/getActivitiesByInterval(startDateTime='2017-01-01',endDateTime='2017-01-03',interval='day')", + "/me/drive/items/{items-id}/getActivitiesByInterval(startDateTime=,endDateTime=,interval=)")] + [InlineData( + "https://graph.microsoft.com/beta/reports/microsoft.graph.getTeamsUserActivityUserDetail(date=2024-01-01)", + "/reports/microsoft.graph.getTeamsUserActivityUserDetail(date=)")] + public void GetTokenizedUrl_FunctionWithParameters_ReturnsTokenizedParameters(string url, string expected) => + Assert.Equal(expected, GraphUtils.GetTokenizedUrl(url)); + + [Fact] + public void GetTokenizedUrl_RepeatedSegmentValues_TokenizesEachSegmentIndependently() => + Assert.Equal( + "/sites/root/lists/{lists-id}/items/{items-id}", + GraphUtils.GetTokenizedUrl("https://graph.microsoft.com/v1.0/sites/root/lists/1/items/1")); + [Fact] public void GetRequestsFromBatch_PathAddressedRequest_ReturnsIdAddressedUrl() { From 85ec4d14e5fa7574f292a8ed750da5f7f6bae620 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 16:35:09 +0100 Subject: [PATCH 2/2] Apply delegated user-scope correction to parenthesized user keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Integration.Tests/GraphUtilsTests.cs | 53 +++++++++++++++++++ DevProxy.Plugins/Utils/GraphUtils.cs | 4 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/DevProxy.Integration.Tests/GraphUtilsTests.cs b/DevProxy.Integration.Tests/GraphUtilsTests.cs index 6faa5d17..69886f72 100644 --- a/DevProxy.Integration.Tests/GraphUtilsTests.cs +++ b/DevProxy.Integration.Tests/GraphUtilsTests.cs @@ -2,7 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Plugins.Models; using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Text; using Xunit; namespace DevProxy.Integration.Tests; @@ -105,6 +109,55 @@ public void GetTokenizedUrl_RepeatedSegmentValues_TokenizesEachSegmentIndependen "/sites/root/lists/{lists-id}/items/{items-id}", GraphUtils.GetTokenizedUrl("https://graph.microsoft.com/v1.0/sites/root/lists/1/items/1")); + [Theory] + [InlineData("/users/{users-id}/messages")] + [InlineData("/users()/messages")] + public async Task UpdateUserScopesAsync_OtherUserEndpoint_ReplacesMinimalScope(string url) + { + using var handler = new PermissionsHandler("""[{"value":"Mail.Read"},{"value":"Mail.Read.Shared"}]"""); + using var httpClient = new HttpClient(handler); + var graphUtils = new GraphUtils(httpClient, NullLogger.Instance); + + var scopes = await graphUtils.UpdateUserScopesAsync( + ["Mail.Read"], + [new MethodAndUrl("GET", url)], + GraphPermissionsType.Delegated); + + Assert.Equal(["Mail.Read.Shared"], scopes); + var requestUri = Assert.Single(handler.RequestUris); + Assert.Contains($"requesturl={url}", Uri.UnescapeDataString(requestUri.Query), StringComparison.Ordinal); + } + + [Fact] + public async Task UpdateUserScopesAsync_NoOtherUserEndpoint_ReturnsOriginalScopes() + { + using var handler = new PermissionsHandler("[]"); + using var httpClient = new HttpClient(handler); + var graphUtils = new GraphUtils(httpClient, NullLogger.Instance); + + var scopes = await graphUtils.UpdateUserScopesAsync( + ["Mail.Read"], + [new MethodAndUrl("GET", "/me/messages")], + GraphPermissionsType.Delegated); + + Assert.Equal(["Mail.Read"], scopes); + Assert.Empty(handler.RequestUris); + } + + private sealed class PermissionsHandler(string responseJson) : HttpMessageHandler + { + public List RequestUris { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }); + } + } + [Fact] public void GetRequestsFromBatch_PathAddressedRequest_ReturnsIdAddressedUrl() { diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index d06547c8..e92525fb 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -57,7 +57,9 @@ internal static string GetScopeTypeString(GraphPermissionsType type) internal async Task> UpdateUserScopesAsync(IEnumerable minimalScopes, IEnumerable endpoints, GraphPermissionsType permissionsType) { - var userEndpoints = endpoints.Where(e => e.Url.Contains("/users/{", StringComparison.OrdinalIgnoreCase)); + var userEndpoints = endpoints.Where(e => + e.Url.Contains("/users/{", StringComparison.OrdinalIgnoreCase) || + e.Url.Contains("/users(", StringComparison.OrdinalIgnoreCase)); if (!userEndpoints.Any()) { return minimalScopes;