Skip to content

Commit 6ba6096

Browse files
authored
Python: Reject encoded dot-segment paths in OpenAPI plugin (.NET and Python) (#14086)
## Motivation and Context Closes #14085. The .NET and Python OpenAPI plugins select operations using the raw OpenAPI path, but build the runtime request URL from a canonicalized path. Encoded dot-segments such as `/resources/%2e%2e/admin` pass an `OperationSelectionPredicate` path check, yet `System.Uri` (.NET) and `urljoin` (Python) collapse them to a different route at request time. The selected path and the actual request target can therefore diverge. ## Description - **.NET** (`RestApiOperation.ValidatePathSegments`): decodes each path segment until stable (handling double encoding like `%252e` and encoded separators `%2f`/`%5c`) before rejecting canonical `.` or `..` segments. - **Python** (`RestApiOperation.build_path`): adds equivalent decode-then-reject validation before the URL is built. Regression tests on both stacks cover literal and encoded dot-segments, mixed case, encoded slashes, and double encoding, plus negative tests confirming legitimate encoded characters still work. Test results: - .NET: 482/482 `Functions.UnitTests` OpenApi tests pass. - Python: 109/109 `openapi_plugin` unit tests pass. ## Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the SK Contribution Guidelines - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent 7fd75c3 commit 6ba6096

4 files changed

Lines changed: 172 additions & 9 deletions

File tree

dotnet/src/Functions/Functions.OpenApi/Model/RestApiOperation.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,19 +412,39 @@ value is string { } strValue &&
412412
};
413413

414414
/// <summary>
415-
/// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal.
415+
/// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal,
416+
/// including percent-encoded forms (e.g. "%2e%2e") that <see cref="Uri"/> canonicalizes at request time.
416417
/// ".." navigates up one path segment, enabling traversal to unintended endpoints.
417418
/// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse.
418419
/// </summary>
419420
/// <param name="path">The path to validate.</param>
420421
private static void ValidatePathSegments(string path)
421422
{
422-
var segments = path.Split('/');
423-
for (int i = 0; i < segments.Length; i++)
423+
// Split on the structural path separator first.
424+
foreach (var rawSegment in path.Split('/'))
424425
{
425-
if (segments[i] == "." || segments[i] == "..")
426+
// Decode percent-encoding until stable to catch encoded ("%2e") and
427+
// double-encoded ("%252e") dot-segments before URI canonicalization.
428+
var decoded = rawSegment;
429+
for (int i = 0; i < 5; i++)
426430
{
427-
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
431+
var unescaped = Uri.UnescapeDataString(decoded);
432+
if (string.Equals(unescaped, decoded, StringComparison.Ordinal))
433+
{
434+
break;
435+
}
436+
437+
decoded = unescaped;
438+
}
439+
440+
// A decoded segment may itself contain encoded separators ("%2f"/"%5c"),
441+
// so re-split on both '/' and '\' and reject any resulting dot-segment.
442+
foreach (var segment in decoded.Split('/', '\\'))
443+
{
444+
if (segment == "." || segment == "..")
445+
{
446+
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
447+
}
428448
}
429449
}
430450
}

dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,92 @@ public void ItShouldAllowDotsInNonSegmentPathParameterValues()
14671467
Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString);
14681468
}
14691469

1470+
[Theory]
1471+
[InlineData("/resources/%2e%2e/admin")]
1472+
[InlineData("/resources/%2E%2E/admin")]
1473+
[InlineData("/resources/%2e./admin")]
1474+
[InlineData("/resources/.%2e/admin")]
1475+
[InlineData("/resources/%2e%2e%2fadmin")]
1476+
[InlineData("/resources/%252e%252e/admin")]
1477+
[InlineData("/resources/%2e/admin")]
1478+
public void ItShouldRejectEncodedDotSegmentInPathTemplate(string path)
1479+
{
1480+
// Arrange — operation path template contains an encoded dot-segment that
1481+
// System.Uri would canonicalize into a path-traversal at request time.
1482+
var sut = new RestApiOperation(
1483+
id: "fake_id",
1484+
servers: [new RestApiServer("https://example.com/api")],
1485+
path: path,
1486+
method: HttpMethod.Get,
1487+
description: "fake_description",
1488+
parameters: [],
1489+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1490+
securityRequirements: []
1491+
);
1492+
1493+
var arguments = new Dictionary<string, object?>();
1494+
1495+
// Act & Assert — encoded dot-segments must be rejected before URL is built
1496+
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
1497+
Assert.Contains("dot-segment", ex.Message);
1498+
}
1499+
1500+
[Fact]
1501+
public void ItShouldRejectEncodedDotSegmentInPathParameter()
1502+
{
1503+
// Arrange — path parameter value is an encoded ".." (%2e%2e)
1504+
var parameters = new List<RestApiParameter> {
1505+
new(
1506+
name: "id",
1507+
type: "string",
1508+
isRequired: true,
1509+
expand: false,
1510+
location: RestApiParameterLocation.Path,
1511+
style: RestApiParameterStyle.Simple)
1512+
};
1513+
1514+
var sut = new RestApiOperation(
1515+
id: "fake_id",
1516+
servers: [new RestApiServer("https://example.com/api")],
1517+
path: "/resources/{id}/details",
1518+
method: HttpMethod.Get,
1519+
description: "fake_description",
1520+
parameters: parameters,
1521+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1522+
securityRequirements: []
1523+
);
1524+
1525+
var arguments = new Dictionary<string, object?> { { "id", "%2e%2e" } };
1526+
1527+
// Act & Assert — encoded dot-segments in parameter values must be rejected
1528+
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
1529+
Assert.Contains("dot-segment", ex.Message);
1530+
}
1531+
1532+
[Fact]
1533+
public void ItShouldAllowEncodedNonDotSegmentCharactersInPathTemplate()
1534+
{
1535+
// Arrange — path contains encoded characters that are NOT dot-segments
1536+
var sut = new RestApiOperation(
1537+
id: "fake_id",
1538+
servers: [new RestApiServer("https://example.com/api")],
1539+
path: "/resources/a%20b/details",
1540+
method: HttpMethod.Get,
1541+
description: "fake_description",
1542+
parameters: [],
1543+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1544+
securityRequirements: []
1545+
);
1546+
1547+
var arguments = new Dictionary<string, object?>();
1548+
1549+
// Act
1550+
var url = sut.BuildOperationUrl(arguments);
1551+
1552+
// Assert — legitimate encoded characters must not be rejected
1553+
Assert.Equal("https://example.com/api/resources/a%20b/details", url.OriginalString);
1554+
}
1555+
14701556
[Fact]
14711557
public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName()
14721558
{

python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import re
44
from typing import Any, Final
5-
from urllib.parse import ParseResult, ParseResultBytes, quote, urlencode, urljoin, urlparse, urlunparse
5+
from urllib.parse import ParseResult, ParseResultBytes, quote, unquote, urlencode, urljoin, urlparse, urlunparse
66

77
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
88
RestApiExpectedResponse,
@@ -289,8 +289,31 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str:
289289
)
290290
continue
291291
path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe=""))
292+
self._validate_path_segments(path_template)
292293
return path_template
293294

295+
@staticmethod
296+
def _validate_path_segments(path: str) -> None:
297+
"""Reject dot-segments (. or ..), including percent-encoded forms, that enable path traversal.
298+
299+
The operation is selected using the raw path but the request URL is built from a canonicalized
300+
path, so encoded dot-segments such as "%2e%2e" must be rejected before the URL is constructed.
301+
"""
302+
for segment in path.split("/"):
303+
decoded = segment
304+
for _ in range(5):
305+
unescaped = unquote(decoded)
306+
if unescaped == decoded:
307+
break
308+
decoded = unescaped
309+
# A decoded segment may contain encoded separators ("%2f"/"%5c"), so re-split on
310+
# both "/" and "\" and reject any resulting dot-segment.
311+
for part in decoded.replace("\\", "/").split("/"):
312+
if part in (".", ".."):
313+
raise FunctionExecutionException(
314+
f"Path '{path}' contains a dot-segment, which could lead to path traversal."
315+
)
316+
294317
def build_query_string(self, arguments: dict[str, Any]) -> str:
295318
"""Build the query string for the operation."""
296319
segments = []

python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,9 @@ def test_build_path_prevents_path_traversal():
435435
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters
436436
)
437437
arguments = {"id": "../../admin"}
438-
result = operation.build_path(operation.path, arguments)
439-
# The slashes must be encoded so ../../admin becomes a single path segment, not a traversal
440-
assert result == "/resource/..%2F..%2Fadmin"
438+
# Encoded separators that decode into dot-segments must be rejected, not silently encoded
439+
with pytest.raises(FunctionExecutionException, match="dot-segment"):
440+
operation.build_path(operation.path, arguments)
441441

442442

443443
def test_build_path_double_encodes_pre_encoded_values():
@@ -462,6 +462,40 @@ def test_build_path_encodes_unicode_characters():
462462
assert result == "/resource/caf%C3%A9%20r%C3%A9sum%C3%A9"
463463

464464

465+
@pytest.mark.parametrize(
466+
"path",
467+
[
468+
"/resources/../admin",
469+
"/resources/./admin",
470+
"/resources/%2e%2e/admin",
471+
"/resources/%2E%2E/admin",
472+
"/resources/%2e/admin",
473+
"/resources/%2e%2e%2fadmin",
474+
"/resources/%252e%252e/admin",
475+
],
476+
)
477+
def test_build_path_rejects_dot_segment_in_template(path):
478+
operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path=path, params=[])
479+
with pytest.raises(FunctionExecutionException, match="dot-segment"):
480+
operation.build_path(operation.path, {})
481+
482+
483+
def test_build_path_rejects_dot_segment_via_parameter():
484+
parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)]
485+
operation = RestApiOperation(
486+
id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}/details", params=parameters
487+
)
488+
with pytest.raises(FunctionExecutionException, match="dot-segment"):
489+
operation.build_path(operation.path, {"id": ".."})
490+
491+
492+
def test_build_path_allows_encoded_non_dot_segment_characters():
493+
operation = RestApiOperation(
494+
id="test", method="GET", servers=["https://example.com/"], path="/resources/a%20b/details", params=[]
495+
)
496+
assert operation.build_path(operation.path, {}) == "/resources/a%20b/details"
497+
498+
465499
def test_build_query_string_with_required_parameter():
466500
parameters = [
467501
RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True)

0 commit comments

Comments
 (0)