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 @@ -308,6 +308,8 @@ private string BuildPath(string pathTemplate, IDictionary<string, object?> argum
pathTemplate = pathTemplate.Replace($"{{{parameter.Name}}}", HttpUtility.UrlEncode(serializer.Invoke(parameter, node)));
}

ValidatePathSegments(pathTemplate);

return pathTemplate;
}

Expand Down Expand Up @@ -364,19 +366,19 @@ private Uri GetServerUrl(Uri? serverUrlOverride, Uri? apiHostUrl, IDictionary<st
arguments.TryGetValue(variable.Value.ArgumentName!, out object? value) &&
value is string { } argStrValue && variable.Value.IsValid(argStrValue))
{
serverUrlString = url.Replace($"{{{variableName}}}", argStrValue);
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", Uri.EscapeDataString(argStrValue));
}
// Try to get the variable value by the variable name.
else if (arguments.TryGetValue(variableName, out value) &&
value is string { } strValue &&
variable.Value.IsValid(strValue))
{
serverUrlString = url.Replace($"{{{variableName}}}", strValue);
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", Uri.EscapeDataString(strValue));
}
// Use the default value if no argument is provided.
else if (variable.Value.Default is not null)
{
serverUrlString = url.Replace($"{{{variableName}}}", variable.Value.Default);
serverUrlString = serverUrlString.Replace($"{{{variableName}}}", variable.Value.Default);
}
// Throw an exception if there's no value for the variable.
else
Expand Down Expand Up @@ -409,6 +411,24 @@ value is string { } strValue &&
{ RestApiParameterStyle.PipeDelimited, PipeDelimitedStyleParameterSerializer.Serialize }
};

/// <summary>
/// Validates that the path does not contain dot-segments (. or ..) that could enable path traversal.
/// ".." navigates up one path segment, enabling traversal to unintended endpoints.
/// "." refers to the current directory — harmless but unexpected, so rejected to prevent misuse.
/// </summary>
/// <param name="path">The path to validate.</param>
private static void ValidatePathSegments(string path)
{
var segments = path.Split('/');
for (int i = 0; i < segments.Length; i++)
{
if (segments[i] == "." || segments[i] == "..")
{
throw new KernelException($"Path '{path}' contains a dot-segment, which could lead to path traversal.");
}
}
}

private IDictionary<string, object?> _extensions = s_emptyDictionary;
private readonly Freezable _freezable = new();
private string? _description;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1315,4 +1315,182 @@ public void ItShouldFreezeModifiableProperties()

Assert.Throws<NotSupportedException>(() => sut.Extensions.Add("x-fake", "fake_value"));
}

[Fact]
public void ItShouldEncodeServerVariableValuesFromArguments()
{
// Arrange — variable value contains path-manipulation characters
var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]);
var sut = new RestApiOperation(
id: "fake_id",
servers: [
new RestApiServer("https://example.com/{version}", new Dictionary<string, RestApiServerVariable> { { "version", version } }),
],
path: "/items",
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>() { { "version", "v2/../admin" } };

// Act
var url = sut.BuildOperationUrl(arguments);
Comment thread
SergeyMenshykh marked this conversation as resolved.

// Assert — reserved separators (/) must be percent-encoded so they are not interpreted as path delimiters
Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString);
}

[Fact]
public void ItShouldPreventServerVariableInjectionWithSpecialCharacters()
{
// Arrange — variable value contains path traversal and query string injection
var host = new RestApiServerVariable("api.example.com");
var sut = new RestApiOperation(
id: "fake_id",
servers: [
new RestApiServer("https://{host}/api", new Dictionary<string, RestApiServerVariable> { { "host", host } }),
],
path: "/data",
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>() { { "host", "evil.com/hijack?q=1#" } };

// Act & Assert — encoding turns /, ?, # into percent-encoded sequences (%2F, %3F, %23),
// which prevents them from being interpreted as structural URI delimiters.
// The Uri constructor rejects the resulting hostname, which is the desired outcome.
Assert.ThrowsAny<UriFormatException>(() => sut.BuildOperationUrl(arguments));
}

[Fact]
public void ItShouldRejectDotSegmentInPathParameter()
{
// Arrange — path parameter value is ".." (dot-segment traversal)
var parameters = new List<RestApiParameter> {
new(
name: "id",
type: "string",
isRequired: true,
expand: false,
location: RestApiParameterLocation.Path,
style: RestApiParameterStyle.Simple)
};

var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: "/resources/{id}/details",
method: HttpMethod.Get,
description: "fake_description",
parameters: parameters,
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?> { { "id", ".." } };

// Act & Assert — dot-segments must be rejected
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
Assert.Contains("dot-segment", ex.Message);
}

[Fact]
public void ItShouldRejectSingleDotSegmentInPathParameter()
{
// Arrange — path parameter value is "." (single-dot segment)
var parameters = new List<RestApiParameter> {
new(
name: "id",
type: "string",
isRequired: true,
expand: false,
location: RestApiParameterLocation.Path,
style: RestApiParameterStyle.Simple)
};

var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: "/resources/{id}/details",
method: HttpMethod.Get,
description: "fake_description",
parameters: parameters,
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?> { { "id", "." } };

// Act & Assert — single-dot segments must also be rejected
var ex = Assert.Throws<KernelException>(() => sut.BuildOperationUrl(arguments));
Assert.Contains("dot-segment", ex.Message);
}

[Fact]
public void ItShouldAllowDotsInNonSegmentPathParameterValues()
{
// Arrange — path parameter contains dots but is NOT a dot-segment (e.g., "file.txt")
var parameters = new List<RestApiParameter> {
new(
name: "filename",
type: "string",
isRequired: true,
expand: false,
location: RestApiParameterLocation.Path,
style: RestApiParameterStyle.Simple)
};

var sut = new RestApiOperation(
id: "fake_id",
servers: [new RestApiServer("https://example.com/api")],
path: "/files/{filename}",
method: HttpMethod.Get,
description: "fake_description",
parameters: parameters,
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?> { { "filename", "report.v2.txt" } };

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

// Assert — dots within normal filenames should work fine
Assert.Equal("https://example.com/api/files/report.v2.txt", url.OriginalString);
}

[Fact]
public void ItShouldEncodeServerVariableValuesLookedUpByArgumentName()
{
// Arrange — variable uses ArgumentName and the argument contains path-manipulation characters
var version = new RestApiServerVariable("v1", null, ["v1", "v2/../admin"]) { ArgumentName = "alt_version" };
var sut = new RestApiOperation(
id: "fake_id",
servers: [
new RestApiServer("https://example.com/{version}", new Dictionary<string, RestApiServerVariable> { { "version", version } }),
],
path: "/items",
method: HttpMethod.Get,
description: "fake_description",
parameters: [],
responses: new Dictionary<string, RestApiExpectedResponse>(),
securityRequirements: []
);

var arguments = new Dictionary<string, object?>() { { "alt_version", "v2/../admin" } };

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

// Assert — reserved separators must be percent-encoded even when looked up via ArgumentName
Assert.Equal("https://example.com/v2%2F..%2Fadmin/items", url.OriginalString);
}
}
Loading