Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,42 @@ internal Uri BuildOperationUrl(IDictionary<string, object?> arguments, Uri? serv

var path = this.BuildPath(this.Path, arguments);

return new Uri(serverUrl, $"{path.TrimStart('/')}");
var url = new Uri(serverUrl, $"{path.TrimStart('/')}");

EnsureRequestTargetMatchesServer(serverUrl, url);

return url;
}

/// <summary>
/// Verifies that URI construction did not move the request off the configured server. A selected
/// operation path must resolve to a request on the same scheme, host, and port and within the
/// server's base path. Otherwise an absolute or authority-changing operation path (for example
/// "https://another-host/admin") could redirect a credential-bearing request to an unintended
/// target even though it carries no dot-segment. This complements <see cref="ValidatePathSegments"/>
/// so operation selection, path validation, and request construction share one canonical target.
/// </summary>
/// <param name="serverUrl">The configured server URL.</param>
/// <param name="requestUrl">The request URL produced by combining the server URL and operation path.</param>
private static void EnsureRequestTargetMatchesServer(Uri serverUrl, Uri requestUrl)
{
var serverAuthority = serverUrl.GetLeftPart(UriPartial.Authority);
var requestAuthority = requestUrl.GetLeftPart(UriPartial.Authority);

if (!string.Equals(serverAuthority, requestAuthority, StringComparison.OrdinalIgnoreCase))
{
throw new KernelException($"The operation path resolves to '{requestAuthority}', which does not match the configured server '{serverAuthority}'.");
}

// GetServerUrl guarantees a trailing slash, so the server's base path always ends with '/'.
var basePath = serverUrl.AbsolutePath;
var requestPath = requestUrl.AbsolutePath;

if (!string.Equals(requestPath, basePath.TrimEnd('/'), StringComparison.Ordinal) &&
!requestPath.StartsWith(basePath, StringComparison.Ordinal))
{
throw new KernelException($"The operation path resolves to '{requestPath}', which is outside the configured server base path '{basePath}'.");
}
}

/// <summary>
Expand Down Expand Up @@ -420,6 +455,27 @@ value is string { } strValue &&
/// <param name="path">The path to validate.</param>
private static void ValidatePathSegments(string path)
{
if (ContainsDotSegment(path))
{
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
}
}

/// <summary>
/// Determines whether the supplied path contains a dot-segment (. or ..), including percent-encoded
/// forms (e.g. "%2e%2e") that <see cref="Uri"/> canonicalizes at request time. This is used both to
/// reject such paths when building a request URL and to exclude them during operation selection so an
/// encoded dot-segment cannot bypass an include/exclude operation-selection filter.
/// </summary>
/// <param name="path">The path to inspect.</param>
/// <returns><see langword="true"/> if the path contains a dot-segment; otherwise, <see langword="false"/>.</returns>
internal static bool ContainsDotSegment(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}

// Split on the structural path separator first.
foreach (var rawSegment in path.Split('/'))
{
Expand All @@ -443,10 +499,12 @@ private static void ValidatePathSegments(string path)
{
if (segment == "." || segment == "..")
{
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
return true;
}
}
}

return false;
}

private IDictionary<string, object?> _extensions = s_emptyDictionary;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ internal static List<RestApiOperation> CreateRestApiOperations(OpenApiDocument d
var operationItem = operationPair.Value;
var operationServers = CreateRestApiOperationServers(operationItem.Servers);

// Exclude operations whose path contains an encoded or literal dot-segment before the
// selection predicate is consulted. Such a path is canonicalized into a different
// effective endpoint at request time, so offering it to an include/exclude predicate
// would let an encoded dot-segment bypass the operation-selection filter. Absolute or
// authority-changing path keys are already rejected by the OpenAPI reader, so a
// dot-segment check is sufficient here.
if (RestApiOperation.ContainsDotSegment(path))
{
logger.LogWarning("Operation '{OperationId}' at path '{Path}' contains a dot-segment and will be skipped during operation selection.", operationItem.OperationId, path);
continue;
}

// Skip the operation parsing and don't add it to the result operations list if it's explicitly excluded by the predicate.
if (!options?.OperationSelectionPredicate?.Invoke(new OperationSelectionPredicateContext(operationItem.OperationId, path, method, operationItem.Description)) ?? false)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,109 @@ public async Task ItCanFilterOutSpecifiedOperationsAsync()
Assert.Contains(restApiSpec.Operations, o => o.Id == "GetSecret");
}

[Fact]
public async Task ItShouldNotOfferEncodedDotSegmentOperationsToSelectionPredicateAsync()
{
// Arrange - one regular operation and one whose path contains an encoded dot-segment
// ("%2e%2e") that canonicalizes to a path-traversal at request time. The selection
// predicate must compare a normalized value, so the encoded path cannot bypass an
// allow/deny operation-selection filter.
var document =
"""
{
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"/items": {
"get": {
"operationId": "GetItems",
"summary": "Get items",
"responses": {
"200": {
"description": "Successful response"
}
}
}
},
"/resources/%2e%2e/admin": {
"get": {
"operationId": "GetAdmin",
"summary": "Get admin",
"responses": {
"200": {
"description": "Successful response"
}
}
}
}
}
}
""";

var observedPaths = new List<string>();
var options = new OpenApiDocumentParserOptions
{
OperationSelectionPredicate = (context) =>
{
observedPaths.Add(context.Path);
return true;
}
};

await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(document));

// Act
var restApiSpec = await this._sut.ParseAsync(stream, options);

// Assert - the regular operation is offered to the predicate and imported.
Assert.Contains("/items", observedPaths);
Assert.Contains(restApiSpec.Operations, o => o.Path == "/items");

// The encoded dot-segment operation is excluded before the predicate is consulted,
// so it cannot slip past an include/exclude operation-selection filter.
Assert.DoesNotContain("/resources/%2e%2e/admin", observedPaths);
Assert.DoesNotContain(restApiSpec.Operations, o => o.Path == "/resources/%2e%2e/admin");
}

[Fact]
public async Task ItShouldRejectDocumentWithNonRelativeOperationPathAsync()
{
// Arrange - an absolute / authority-changing operation path key would resolve to a request off
// the configured server. The OpenAPI reader rejects such a non-relative path key so it can
// never reach operation selection or request construction.
var document =
"""
{
"swagger": "2.0",
"info": {
"title": "Test API",
"version": "1.0.0"
},
"paths": {
"https://evil.com/admin": {
"get": {
"operationId": "GetAdmin",
"summary": "Get admin",
"responses": {
"200": {
"description": "Successful response"
}
}
}
}
}
}
""";

await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(document));

// Act & Assert - a non-relative operation path key is rejected during parsing.
await Assert.ThrowsAsync<KernelException>(() => this._sut.ParseAsync(stream));
}

[Fact]
public async Task ItCanParsePathItemPathParametersAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,86 @@ public void ItShouldAllowEncodedNonDotSegmentCharactersInPathTemplate()
Assert.Equal("https://example.com/api/resources/a%20b/details", url.OriginalString);
}

[Theory]
[InlineData("https://evil.com/admin")]
[InlineData("https://example.com:8443/admin")]
[InlineData("http://example.com/admin")]
[InlineData(@"\\evil.com\admin")]
[InlineData("https://user:pass@example.com/api/admin")]
[InlineData("https://user@example.com/api/data")]
[InlineData("https://example.com@evil.com/admin")]
public void ItShouldRejectOperationPathThatChangesRequestAuthority(string path)
{
// Arrange — an operation path that is an absolute URI (or otherwise changes the scheme,
// host, or port) resolves to a request off the configured server after URI construction.
// Such a path must be rejected so a credential-bearing request cannot be redirected to an
// unintended target even though it carries no dot-segment.
var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: path,
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>();

// Act & Assert — the resolved request target must stay on the configured server.
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
Assert.Contains("does not match the configured server", ex.Message);
}

[Fact]
public void ItShouldRejectSameAuthorityBasePathEscape()
{
// Arrange — an absolute path on the same authority but outside the configured base path must
// be rejected, so a request cannot be moved to a different route even when the scheme, host,
// and port all match the configured server.
var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: "https://example.com/other/admin",
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>();

// Act & Assert — the resolved request path must remain within the configured server base path.
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
Assert.Contains("outside the configured server base path", ex.Message);
}

[Fact]
public void ItShouldBuildUrlForOperationPathOnTheConfiguredServer()
{
// Arrange — a normal relative operation path on the configured server.
var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: "/resources/item",
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>();

// Act
var url = sut.BuildOperationUrl(arguments);

// Assert — the reconciliation must not reject a legitimate same-server path.
Assert.Equal("https://example.com/api/resources/item", url.OriginalString);
}

[Fact]
public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,45 @@ def build_operation_url(self, arguments, server_url_override=None, api_host_url=
server_url = self.get_server_url(server_url_override, api_host_url, arguments)
path = self.build_path(self.path, arguments)
try:
return urljoin(server_url, path.lstrip("/"))
request_url = urljoin(server_url, path.lstrip("/"))
except Exception as e:
raise FunctionExecutionException(f"Error building the URL for the operation {self.id}: {e!s}") from e
self._ensure_request_target_matches_server(server_url, request_url)
return request_url

@staticmethod
def _ensure_request_target_matches_server(server_url: str, request_url: str) -> None:
"""Verify URL construction did not move the request off the configured server.

A selected operation path must resolve to a request on the same scheme, host, and port and
within the server's base path. Otherwise an absolute or authority-changing operation path
(for example "https://another-host/admin") could redirect a credential-bearing request to an
unintended target even though it carries no dot-segment. This complements
`_validate_path_segments` so operation selection, path validation, and request construction
share one canonical target.
"""
server = urlparse(server_url)
request = urlparse(request_url)

if (request.scheme, request.username, request.password, request.hostname, request.port) != (
server.scheme,
server.username,
server.password,
server.hostname,
server.port,
):
raise FunctionExecutionException(
f"The operation path resolves to '{request.scheme}://{request.netloc}', which does not match "
f"the configured server '{server.scheme}://{server.netloc}'."
)

# get_server_url guarantees a trailing slash, so the server base path always ends with "/".
base_path = server.path
if request.path != base_path.rstrip("/") and not request.path.startswith(base_path):
raise FunctionExecutionException(
f"The operation path resolves to '{request.path}', which is outside the configured server "
f"base path '{base_path}'."
)

def get_server_url(self, server_url_override=None, api_host_url=None, arguments=None):
"""Get the server URL for the operation."""
Expand Down Expand Up @@ -299,6 +335,20 @@ def _validate_path_segments(path: str) -> None:
The operation is selected using the raw path but the request URL is built from a canonicalized
path, so encoded dot-segments such as "%2e%2e" must be rejected before the URL is constructed.
"""
if RestApiOperation._contains_dot_segment(path):
raise FunctionExecutionException(
f"Path '{path}' contains a dot-segment, which could lead to path traversal."
)

@staticmethod
def _contains_dot_segment(path: str) -> bool:
"""Return True if the path contains a dot-segment (. or ..), including percent-encoded forms.

Used both to reject such paths when building a request URL and to exclude them during operation
selection so an encoded dot-segment cannot bypass an include/exclude operation-selection filter.
"""
if not path:
return False
for segment in path.split("/"):
decoded = segment
for _ in range(5):
Expand All @@ -310,9 +360,24 @@ def _validate_path_segments(path: str) -> None:
# both "/" and "\" and reject any resulting dot-segment.
for part in decoded.replace("\\", "/").split("/"):
if part in (".", ".."):
raise FunctionExecutionException(
f"Path '{path}' contains a dot-segment, which could lead to path traversal."
)
return True
return False

@staticmethod
def _is_non_relative_path(path: str) -> bool:
"""Return True if the path is non-relative (absolute or authority-changing).

A conformant OpenAPI operation path is relative to the server URL. An absolute URI, a
protocol-relative reference ("//host"), or a backslash-authority reference changes the request
target once combined with the server URL, so such a path is excluded during operation selection
to keep selection and request construction on one canonical target.
"""
if not path:
return False
if path.startswith(("//", "\\\\", "/\\", "\\/")):
return True
parsed = urlparse(path)
return bool(parsed.scheme or parsed.netloc)

def build_query_string(self, arguments: dict[str, Any]) -> str:
"""Build the query string for the operation."""
Expand Down
Loading
Loading