|
1 | 1 | // Copyright (c) Microsoft Corporation.
|
2 | 2 | // Licensed under the MIT License.
|
3 | 3 |
|
| 4 | +using System.Text.RegularExpressions; |
4 | 5 | using Titanium.Web.Proxy.Http;
|
5 | 6 | using Titanium.Web.Proxy.Models;
|
6 | 7 |
|
7 | 8 | namespace Microsoft.Graph.DeveloperProxy.Abstractions;
|
8 | 9 |
|
| 10 | +class ParsedSample { |
| 11 | + public string QueryVersion { get; set; } = string.Empty; |
| 12 | + public string RequestUrl { get; set; } = string.Empty; |
| 13 | + public string SampleUrl { get; set; } = string.Empty; |
| 14 | + public string Search { get; set; } = string.Empty; |
| 15 | +} |
| 16 | + |
9 | 17 | public static class ProxyUtils {
|
10 |
| - public static bool IsGraphRequest(Request request) => |
11 |
| - request.RequestUri.Host.Contains("graph.microsoft.", StringComparison.OrdinalIgnoreCase) || |
12 |
| - request.RequestUri.Host.Contains("microsoftgraph.", StringComparison.OrdinalIgnoreCase); |
| 18 | + private static readonly Regex itemPathRegex = new Regex(@"(?:\/)[\w]+:[\w\/.]+(:(?=\/)|$)"); |
| 19 | + private static readonly Regex sanitizedItemPathRegex = new Regex("^[a-z]+:<value>$", RegexOptions.IgnoreCase); |
| 20 | + private static readonly Regex entityNameRegex = new Regex("^((microsoft.graph(.[a-z]+)+)|[a-z]+)$", RegexOptions.IgnoreCase); |
| 21 | + private static readonly Regex allAlphaRegex = new Regex("^[a-z]+$", RegexOptions.IgnoreCase); |
| 22 | + private static readonly Regex deprecationRegex = new Regex("^[a-z]+_v2$", RegexOptions.IgnoreCase); |
| 23 | + private static readonly Regex functionCallRegex = new Regex(@"^[a-z]+\(.*\)$", RegexOptions.IgnoreCase); |
| 24 | + |
| 25 | + public static bool IsGraphRequest(Request request) => IsGraphUrl(request.RequestUri); |
| 26 | + |
| 27 | + public static bool IsGraphUrl(Uri uri) => |
| 28 | + uri.Host.StartsWith("graph.microsoft.", StringComparison.OrdinalIgnoreCase) || |
| 29 | + uri.Host.StartsWith("microsoftgraph.", StringComparison.OrdinalIgnoreCase); |
13 | 30 |
|
14 | 31 | public static bool IsSdkRequest(Request request) => request.Headers.HeaderExists("SdkVersion");
|
15 | 32 |
|
@@ -50,4 +67,156 @@ public static string ReplacePathTokens(string? path) {
|
50 | 67 | var appFolder = Path.GetDirectoryName(AppContext.BaseDirectory);
|
51 | 68 | return path.Replace("~appFolder", appFolder, StringComparison.OrdinalIgnoreCase);
|
52 | 69 | }
|
| 70 | + |
| 71 | + // from: https://github.com/microsoftgraph/microsoft-graph-explorer-v4/blob/db86b903f36ef1b882996d46aee52cd49ed4444b/src/app/utils/query-url-sanitization.ts |
| 72 | + public static string SanitizeUrl(string absoluteUrl) { |
| 73 | + absoluteUrl = Uri.UnescapeDataString(absoluteUrl); |
| 74 | + var uri = new Uri(absoluteUrl); |
| 75 | + |
| 76 | + var parsedSample = ParseSampleUrl(absoluteUrl); |
| 77 | + var queryString = !String.IsNullOrEmpty(parsedSample.Search) ? $"?{SanitizeQueryParameters(parsedSample.Search)}" : ""; |
| 78 | + |
| 79 | + // Sanitize item path specified in query url |
| 80 | + var resourceUrl = parsedSample.RequestUrl; |
| 81 | + if (!String.IsNullOrEmpty(resourceUrl)) { |
| 82 | + resourceUrl = itemPathRegex.Replace(parsedSample.RequestUrl, match => { |
| 83 | + return $"{match.Value.Substring(0, match.Value.IndexOf(':'))}:<value>"; |
| 84 | + }); |
| 85 | + // Split requestUrl into segments that can be sanitized individually |
| 86 | + var urlSegments = resourceUrl.Split('/'); |
| 87 | + for (var i = 0; i < urlSegments.Length; i++) { |
| 88 | + var segment = urlSegments[i]; |
| 89 | + var sanitizedSegment = SanitizePathSegment(i < 1 ? "" : urlSegments[i - 1], segment); |
| 90 | + resourceUrl = resourceUrl.Replace(segment, sanitizedSegment); |
| 91 | + } |
| 92 | + } |
| 93 | + return $"{uri.GetLeftPart(UriPartial.Authority)}/{parsedSample.QueryVersion}/{resourceUrl}{queryString}"; |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * Skipped segments: |
| 98 | + * - Entities, entity sets and navigation properties, expected to contain alphabetic letters only |
| 99 | + * - Deprecated entities in the form <entity>_v2 |
| 100 | + * The remaining URL segments are assumed to be variables that need to be sanitized |
| 101 | + * @param segment |
| 102 | + */ |
| 103 | + private static string SanitizePathSegment(string previousSegment, string segment) { |
| 104 | + var segmentsToIgnore = new[] { "$value", "$count", "$ref", "$batch" }; |
| 105 | + |
| 106 | + if (IsAllAlpha(segment) || |
| 107 | + IsDeprecation(segment) || |
| 108 | + sanitizedItemPathRegex.IsMatch(segment) || |
| 109 | + segmentsToIgnore.Contains(segment.ToLowerInvariant()) || |
| 110 | + entityNameRegex.IsMatch(segment)) { |
| 111 | + return segment; |
| 112 | + } |
| 113 | + |
| 114 | + // Check if segment is in this form: users('<some-id>|<UPN>') and transform to users(<value>) |
| 115 | + if (IsFunctionCall(segment)) { |
| 116 | + var openingBracketIndex = segment.IndexOf("("); |
| 117 | + var textWithinBrackets = segment.Substring( |
| 118 | + openingBracketIndex + 1, |
| 119 | + segment.Length - 2 |
| 120 | + ); |
| 121 | + var sanitizedText = String.Join(',', textWithinBrackets |
| 122 | + .Split(',') |
| 123 | + .Select(text => { |
| 124 | + if (text.Contains('=')) { |
| 125 | + var key = text.Split('=')[0]; |
| 126 | + key = !IsAllAlpha(key) ? "<key>" : key; |
| 127 | + return $"{key}=<value>"; |
| 128 | + } |
| 129 | + return "<value>"; |
| 130 | + })); |
| 131 | + |
| 132 | + return $"{segment.Substring(0, openingBracketIndex)}({sanitizedText})"; |
| 133 | + } |
| 134 | + |
| 135 | + if (IsPlaceHolderSegment(segment)) { |
| 136 | + return segment; |
| 137 | + } |
| 138 | + |
| 139 | + if (!IsAllAlpha(previousSegment) && !IsDeprecation(previousSegment)) { |
| 140 | + previousSegment = "unknown"; |
| 141 | + } |
| 142 | + |
| 143 | + return $"{{{previousSegment}-id}}"; |
| 144 | + } |
| 145 | + |
| 146 | + private static string SanitizeQueryParameters(string queryString) { |
| 147 | + // remove leading ? from query string and decode |
| 148 | + queryString = Uri.UnescapeDataString( |
| 149 | + new Regex(@"\+").Replace(queryString.Substring(1), " ") |
| 150 | + ); |
| 151 | + return String.Join('&', queryString.Split('&').Select(s => s)); |
| 152 | + } |
| 153 | + |
| 154 | + private static bool IsAllAlpha(string value) => allAlphaRegex.IsMatch(value); |
| 155 | + |
| 156 | + private static bool IsDeprecation(string value) => deprecationRegex.IsMatch(value); |
| 157 | + |
| 158 | + private static bool IsFunctionCall(string value) => functionCallRegex.IsMatch(value); |
| 159 | + |
| 160 | + private static bool IsPlaceHolderSegment(string segment) { |
| 161 | + return segment.StartsWith('{') && segment.EndsWith('}'); |
| 162 | + } |
| 163 | + |
| 164 | + private static ParsedSample ParseSampleUrl(string url, string? version = null) { |
| 165 | + var parsedSample = new ParsedSample(); |
| 166 | + |
| 167 | + if (url != "") { |
| 168 | + try { |
| 169 | + url = RemoveExtraSlashesFromUrl(url); |
| 170 | + parsedSample.QueryVersion = version ?? GetGraphVersion(url); |
| 171 | + parsedSample.RequestUrl = GetRequestUrl(url, parsedSample.QueryVersion); |
| 172 | + parsedSample.Search = GenerateSearchParameters(url, ""); |
| 173 | + parsedSample.SampleUrl = GenerateSampleUrl(url, parsedSample.QueryVersion, parsedSample.RequestUrl, parsedSample.Search); |
| 174 | + } catch (Exception) { } |
| 175 | + } |
| 176 | + |
| 177 | + return parsedSample; |
| 178 | + } |
| 179 | + |
| 180 | + private static string RemoveExtraSlashesFromUrl(string url) { |
| 181 | + return new Regex(@"([^:]\/)\/+").Replace(url, "$1"); |
| 182 | + } |
| 183 | + |
| 184 | + private static string GetGraphVersion(string url) { |
| 185 | + var uri = new Uri(url); |
| 186 | + return uri.Segments[1].Replace("/", ""); |
| 187 | + } |
| 188 | + |
| 189 | + private static string GetRequestUrl(string url, string version) { |
| 190 | + var uri = new Uri(url); |
| 191 | + var versionToReplace = uri.AbsolutePath.StartsWith($"/{version}") |
| 192 | + ? version |
| 193 | + : GetGraphVersion(url); |
| 194 | + var requestContent = uri.AbsolutePath.Split(versionToReplace).LastOrDefault() ?? ""; |
| 195 | + return Uri.UnescapeDataString(requestContent.TrimEnd('/')).TrimStart('/'); |
| 196 | + } |
| 197 | + |
| 198 | + private static string GenerateSearchParameters(string url, string search) { |
| 199 | + var uri = new Uri(url); |
| 200 | + |
| 201 | + if (uri.Query != "") { |
| 202 | + try { |
| 203 | + search = Uri.UnescapeDataString(uri.Query); |
| 204 | + } catch (Exception) { |
| 205 | + search = uri.Query; |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + return new Regex(@"\s").Replace(search, "+"); |
| 210 | + } |
| 211 | + |
| 212 | + private static string GenerateSampleUrl( |
| 213 | + string url, |
| 214 | + string queryVersion, |
| 215 | + string requestUrl, |
| 216 | + string search |
| 217 | + ) { |
| 218 | + var uri = new Uri(url); |
| 219 | + var origin = uri.GetLeftPart(UriPartial.Authority); |
| 220 | + return RemoveExtraSlashesFromUrl($"{origin}/{queryVersion}/{requestUrl + search}"); |
| 221 | + } |
53 | 222 | }
|
0 commit comments