diff --git a/Src/Support/Google.Apis.Core/Requests/RequestBuilder.cs b/Src/Support/Google.Apis.Core/Requests/RequestBuilder.cs index eb1e484bcd0..8311dd4d832 100644 --- a/Src/Support/Google.Apis.Core/Requests/RequestBuilder.cs +++ b/Src/Support/Google.Apis.Core/Requests/RequestBuilder.cs @@ -1,4 +1,4 @@ -/* +/* Copyright 2012 Google Inc Licensed under the Apache License, Version 2.0(the "License"); @@ -259,6 +259,60 @@ private StringBuilder BuildRestPath() // Check if a path parameter equals the name which appears in the REST path. if (PathParameters.ContainsKey(parameterName)) { + var parameterValues = PathParameters[parameterName]; + foreach (var val in parameterValues) + { + if (val is null) + { + continue; + } + // Reject query (?) or fragment (#) injections in reserved expansions (+ or #). + // Since reserved expansions bypass escaping (to preserve slashes), this check acts as the primary control to prevent parameter injection. + if ((op == "+" || op == "#") && (val.IndexOf('?') != -1 || val.IndexOf('#') != -1)) + { + throw new ArgumentException($"Reserved path parameter '{parameterName}' contains invalid character '?' or '#': '{val}'"); + } + // Unescape the entire value first to prevent bypasses using URL-encoded slashes (e.g. %2f). + string unescapedVal = Uri.UnescapeDataString(val); + bool isReserved = op == "+" || op == "#"; + // Scan for '.' and '..' segments in place using char-index scanning to avoid heap allocations. + int valStart = 0; + while (valStart < unescapedVal.Length) + { + int nextSlash = unescapedVal.IndexOf('/', valStart); + int segmentLength = nextSlash == -1 ? unescapedVal.Length - valStart : nextSlash - valStart; + + if (segmentLength == 1 && unescapedVal[valStart] == '.') + { + if (!isReserved) + { + throw new ArgumentException($"Invalid value '.' for {parameterName}"); + } + else + { + throw new ArgumentException($"Value for {parameterName} must not contain segments that are exactly . or .."); + } + } + if (segmentLength == 2 && unescapedVal[valStart] == '.' && unescapedVal[valStart + 1] == '.') + { + if (!isReserved) + { + throw new ArgumentException($"Invalid value '..' for {parameterName}"); + } + else + { + throw new ArgumentException($"Value for {parameterName} must not contain segments that are exactly . or .."); + } + } + + if (nextSlash == -1) + { + break; + } + valStart = nextSlash + 1; + } + } + var value = string.Join(joiner, PathParameters[parameterName]); // Check if we need to use a substring of the value. @@ -267,6 +321,10 @@ private StringBuilder BuildRestPath() value = value.Substring(0, numOfChars); } + // Do not escape the value if the operator is a reserved (+) or fragment (#) expansion. + // The former is needed for path values (to preserve slashes), and the latter for OAuth2 callback and redirection URIs. + // Since these operators bypass URL-escaping, any query (?) or fragment (#) characters in their values + // must be explicitly rejected (handled in the validation loop above) to prevent parameter injection. if (op != "+" && op != "#" && PathParameters[parameterName].Count == 1) { value = Uri.EscapeDataString(value); diff --git a/Src/Support/Google.Apis.Tests/Apis/Requests/RequestBuilderTest.cs b/Src/Support/Google.Apis.Tests/Apis/Requests/RequestBuilderTest.cs index 29762b3489a..69f91e19348 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Requests/RequestBuilderTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Requests/RequestBuilderTest.cs @@ -1,4 +1,4 @@ -/* +/* Copyright 2012 Google Inc Licensed under the Apache License, Version 2.0 (the "License"); @@ -393,5 +393,86 @@ private void SubtestPathParameters(IDictionary> dic, Assert.Equal("http://www.example.com/" + expected, builder.BuildUri().AbsoluteUri); } + + [Theory] + // Dialogflow session (standard single-wildcard path) + [InlineData("v3/{session}:detectIntent", "projects/p/locations/l/agents/a/sessions/..")] + [InlineData("v3/{session}:detectIntent", "projects/p/locations/l/agents/a/sessions/.")] + // Firestore documents (reserved template expansion) + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/../../default")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/../../../../../../../escape-db")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/%2e%2e/escape-db")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/..%2f..%2fescape-db")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/%2e%2e%2f%2e%2e%2fescape-db")] + [InlineData("v1/{+name}", "../escape-db")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1/./child")] + [InlineData("v1/{+name}", "projects/p/databases/d/documents/doc?key=val")] + [InlineData("v1/{+name}", "projects/p/databases/d/documents/doc#frag")] + // Webhooks (multiple standard wildcards) + [InlineData("v3/projects/{project}/webhooks/{webhook}", "..")] + [InlineData("v3/projects/{project}/webhooks/{webhook}", ".")] + public void PathTraversalAndInjection_ThrowsArgumentException(string path, string paramValue) + { + var builder = new RequestBuilder() + { + BaseUri = new Uri("http://www.example.com"), + Path = path + }; + + string paramName = path.Contains("session") ? "session" : + path.Contains("webhook") ? "webhook" : "name"; + + if (path.Contains("project")) + { + builder.AddParameter(RequestParameterType.Path, "project", "p1"); + } + + builder.AddParameter(RequestParameterType.Path, paramName, paramValue); + + var exception = Assert.Throws(() => builder.BuildUri()); + string unescaped = Uri.UnescapeDataString(paramValue); + + if (unescaped.Contains("?") || unescaped.Contains("#")) + { + Assert.StartsWith($"Reserved path parameter '{paramName}' contains invalid character", exception.Message); + return; + } + + bool isReserved = path.Contains("{+") || path.Contains("{#"); + bool hasDoubleDot = false; + bool hasSingleDot = false; + foreach (var segment in unescaped.Split('/')) + { + if (segment == "..") hasDoubleDot = true; + if (segment == ".") hasSingleDot = true; + } + + if (!isReserved) + { + string matchedDot = hasDoubleDot ? ".." : (hasSingleDot ? "." : ""); + Assert.StartsWith($"Invalid value '{matchedDot}' for {paramName}", exception.Message); + } + else + { + Assert.StartsWith($"Value for {paramName} must not contain segments that are exactly . or ..", exception.Message); + } + } + + [Theory] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/doc-1", "http://www.example.com/v1/projects/sys-prod-123/databases/default/documents/doc-1")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/my-file.txt", "http://www.example.com/v1/projects/sys-prod-123/databases/default/documents/my-file.txt")] + [InlineData("v1/{+name}", "projects/sys-prod-123/databases/default/documents/my-file..txt", "http://www.example.com/v1/projects/sys-prod-123/databases/default/documents/my-file..txt")] + public void ValidRealisticPatterns_Succeed(string path, string paramValue, string expectedUri) + { + var builder = new RequestBuilder() + { + BaseUri = new Uri("http://www.example.com"), + Path = path + }; + + builder.AddParameter(RequestParameterType.Path, "name", paramValue); + + Assert.Equal(expectedUri, builder.BuildUri().AbsoluteUri); + } } }