Skip to content

Commit 446c2ef

Browse files
.Net: Improve input validation in OpenAPI plugin (#13962)
Strengthen input validation for server variable substitution and path parameter handling in RestApiOperation. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b7ae840 commit 446c2ef

2 files changed

Lines changed: 201 additions & 3 deletions

File tree

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ private string BuildPath(string pathTemplate, IDictionary<string, object?> argum
308308
pathTemplate = pathTemplate.Replace($"{{{parameter.Name}}}", HttpUtility.UrlEncode(serializer.Invoke(parameter, node)));
309309
}
310310

311+
ValidatePathSegments(pathTemplate);
312+
311313
return pathTemplate;
312314
}
313315

@@ -364,19 +366,19 @@ private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl, IDictionary<st
364366
arguments.TryGetValue(variable.Value.ArgumentName!, out object? value) &&
365367
value is string { } argStrValue && variable.Value.IsValid(argStrValue))
366368
{
367-
serverUrlString = url.Replace($"{{{variableName}}}", argStrValue);
369+
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", Uri.EscapeDataString(argStrValue));
368370
}
369371
// Try to get the variable value by the variable name.
370372
else if (arguments.TryGetValue(variableName, out value) &&
371373
value is string { } strValue &&
372374
variable.Value.IsValid(strValue))
373375
{
374-
serverUrlString = url.Replace($"{{{variableName}}}", strValue);
376+
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", Uri.EscapeDataString(strValue));
375377
}
376378
// Use the default value if no argument is provided.
377379
else if (variable.Value.Default is not null)
378380
{
379-
serverUrlString = url.Replace($"{{{variableName}}}", variable.Value.Default);
381+
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", variable.Value.Default);
380382
}
381383
// Throw an exception if there's no value for the variable.
382384
else
@@ -409,6 +411,24 @@ value is string { } strValue &&
409411
{ RestApiParameterStyle.PipeDelimited, PipeDelimitedStyleParameterSerializer.Serialize }
410412
};
411413

414+
/// <summary>
415+
/// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal.
416+
/// ".." navigates up one path segment, enabling traversal to unintended endpoints.
417+
/// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse.
418+
/// </summary>
419+
/// <param name="path">The path to validate.</param>
420+
private static void ValidatePathSegments(string path)
421+
{
422+
var segments = path.Split('/');
423+
for (int i = 0; i < segments.Length; i++)
424+
{
425+
if (segments[i] == "." || segments[i] == "..")
426+
{
427+
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
428+
}
429+
}
430+
}
431+
412432
private IDictionary<string, object?> _extensions = s_emptyDictionary;
413433
private readonly Freezable _freezable = new();
414434
private string? _description;

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

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,4 +1315,182 @@ public void ItShouldFreezeModifiableProperties()
13151315

13161316
Assert.Throws<NotSupportedException>(() => sut.Extensions.Add("x-fake", "fake_value"));
13171317
}
1318+
1319+
[Fact]
1320+
public void ItShouldEncodeServerVariableValuesFromArguments()
1321+
{
1322+
// Arrange — variable value contains path-manipulation characters
1323+
var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]);
1324+
var sut = new RestApiOperation(
1325+
id: "fake_id",
1326+
servers: [
1327+
new RestApiServer("https://example.com/{version}", new Dictionary<string, RestApiServerVariable> { { "version", version } }),
1328+
],
1329+
path: "/items",
1330+
method: HttpMethod.Get,
1331+
description: "fake_description",
1332+
parameters: [],
1333+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1334+
securityRequirements: []
1335+
);
1336+
1337+
var arguments = new Dictionary<string, object?>() { { "version", "v2/../admin" } };
1338+
1339+
// Act
1340+
var url = sut.BuildOperationUrl(arguments);
1341+
1342+
// Assert — reserved separators (/) must be percent-encoded so they are not interpreted as path delimiters
1343+
Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString);
1344+
}
1345+
1346+
[Fact]
1347+
public void ItShouldPreventServerVariableInjectionWithSpecialCharacters()
1348+
{
1349+
// Arrange — variable value contains path traversal and query string injection
1350+
var host = new RestApiServerVariable("api.example.com");
1351+
var sut = new RestApiOperation(
1352+
id: "fake_id",
1353+
servers: [
1354+
new RestApiServer("https://{host}/api", new Dictionary<string, RestApiServerVariable> { { "host", host } }),
1355+
],
1356+
path: "/data",
1357+
method: HttpMethod.Get,
1358+
description: "fake_description",
1359+
parameters: [],
1360+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1361+
securityRequirements: []
1362+
);
1363+
1364+
var arguments = new Dictionary<string, object?>() { { "host", "evil.com/hijack?q=1#" } };
1365+
1366+
// Act & Assert — encoding turns /, ?, # into percent-encoded sequences (%2F, %3F, %23),
1367+
// which prevents them from being interpreted as structural URI delimiters.
1368+
// The Uri constructor rejects the resulting hostname, which is the desired outcome.
1369+
Assert.ThrowsAny<UriFormatException>(() => sut.BuildOperationUrl(arguments));
1370+
}
1371+
1372+
[Fact]
1373+
public void ItShouldRejectDotSegmentInPathParameter()
1374+
{
1375+
// Arrange — path parameter value is ".." (dot-segment traversal)
1376+
var parameters = new List<RestApiParameter> {
1377+
new(
1378+
name: "id",
1379+
type: "string",
1380+
isRequired: true,
1381+
expand: false,
1382+
location: RestApiParameterLocation.Path,
1383+
style: RestApiParameterStyle.Simple)
1384+
};
1385+
1386+
var sut = new RestApiOperation(
1387+
id: "fake_id",
1388+
servers: [new RestApiServer("https://example.com/api")],
1389+
path: "/resources/{id}/details",
1390+
method: HttpMethod.Get,
1391+
description: "fake_description",
1392+
parameters: parameters,
1393+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1394+
securityRequirements: []
1395+
);
1396+
1397+
var arguments = new Dictionary<string, object?> { { "id", ".." } };
1398+
1399+
// Act & Assert — dot-segments must be rejected
1400+
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
1401+
Assert.Contains("dot-segment", ex.Message);
1402+
}
1403+
1404+
[Fact]
1405+
public void ItShouldRejectSingleDotSegmentInPathParameter()
1406+
{
1407+
// Arrange — path parameter value is "." (single-dot segment)
1408+
var parameters = new List<RestApiParameter> {
1409+
new(
1410+
name: "id",
1411+
type: "string",
1412+
isRequired: true,
1413+
expand: false,
1414+
location: RestApiParameterLocation.Path,
1415+
style: RestApiParameterStyle.Simple)
1416+
};
1417+
1418+
var sut = new RestApiOperation(
1419+
id: "fake_id",
1420+
servers: [new RestApiServer("https://example.com/api")],
1421+
path: "/resources/{id}/details",
1422+
method: HttpMethod.Get,
1423+
description: "fake_description",
1424+
parameters: parameters,
1425+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1426+
securityRequirements: []
1427+
);
1428+
1429+
var arguments = new Dictionary<string, object?> { { "id", "." } };
1430+
1431+
// Act & Assert — single-dot segments must also be rejected
1432+
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
1433+
Assert.Contains("dot-segment", ex.Message);
1434+
}
1435+
1436+
[Fact]
1437+
public void ItShouldAllowDotsInNonSegmentPathParameterValues()
1438+
{
1439+
// Arrange — path parameter contains dots but is NOT a dot-segment (e.g., "file.txt")
1440+
var parameters = new List<RestApiParameter> {
1441+
new(
1442+
name: "filename",
1443+
type: "string",
1444+
isRequired: true,
1445+
expand: false,
1446+
location: RestApiParameterLocation.Path,
1447+
style: RestApiParameterStyle.Simple)
1448+
};
1449+
1450+
var sut = new RestApiOperation(
1451+
id: "fake_id",
1452+
servers: [new RestApiServer("https://example.com/api")],
1453+
path: "/files/{filename}",
1454+
method: HttpMethod.Get,
1455+
description: "fake_description",
1456+
parameters: parameters,
1457+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1458+
securityRequirements: []
1459+
);
1460+
1461+
var arguments = new Dictionary<string, object?> { { "filename", "report.v2.txt" } };
1462+
1463+
// Act
1464+
var url = sut.BuildOperationUrl(arguments);
1465+
1466+
// Assert — dots within normal filenames should work fine
1467+
Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString);
1468+
}
1469+
1470+
[Fact]
1471+
public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName()
1472+
{
1473+
// Arrange — variable uses ArgumentName and the argument contains path-manipulation characters
1474+
var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]) { ArgumentName = "alt_version" };
1475+
var sut = new RestApiOperation(
1476+
id: "fake_id",
1477+
servers: [
1478+
new RestApiServer("https://example.com/{version}", new Dictionary<string, RestApiServerVariable> { { "version", version } }),
1479+
],
1480+
path: "/items",
1481+
method: HttpMethod.Get,
1482+
description: "fake_description",
1483+
parameters: [],
1484+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1485+
securityRequirements: []
1486+
);
1487+
1488+
var arguments = new Dictionary<string, object?>() { { "alt_version", "v2/../admin" } };
1489+
1490+
// Act
1491+
var url = sut.BuildOperationUrl(arguments);
1492+
1493+
// Assert — reserved separators must be percent-encoded even when looked up via ArgumentName
1494+
Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString);
1495+
}
13181496
}

0 commit comments

Comments
 (0)