Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion Src/Support/Google.Apis.Core/Requests/RequestBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
Copyright 2012 Google Inc

Licensed under the Apache License, Version 2.0(the "License");
Expand Down Expand Up @@ -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 == "#";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the ruby implementation implementation here there's a check on non-reserved parameters for slashes, do we need that here?

// 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.
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
Copyright 2012 Google Inc

Licensed under the Apache License, Version 2.0 (the "License");
Expand Down Expand Up @@ -393,5 +393,86 @@ private void SubtestPathParameters(IDictionary<string, IEnumerable<string>> 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<ArgumentException>(() => 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);
}
}
}
Loading