Skip to content

Commit b8b3775

Browse files
Harden AllowedBaseUrls validation in RestApiOperationRunner
Strengthened base URL comparison in ValidateUrl to enforce proper path-boundary matching when checking request URLs against configured AllowedBaseUrls, and added unit tests for the stricter validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 84ab822 commit b8b3775

2 files changed

Lines changed: 156 additions & 3 deletions

File tree

dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,19 @@ private void ValidateUrl(Uri url)
247247
if (this._serverUrlValidationOptions.AllowedBaseUrls is { Count: > 0 } allowedBaseUrls)
248248
{
249249
bool baseUrlAllowed = false;
250-
var urlString = url.AbsoluteUri;
251250

252251
foreach (var baseUrl in allowedBaseUrls)
253252
{
254-
var baseUrlString = baseUrl.AbsoluteUri;
255-
if (urlString.StartsWith(baseUrlString, StringComparison.OrdinalIgnoreCase))
253+
// Use only scheme + authority + path for comparison, ignoring any query or fragment.
254+
var baseUrlPath = baseUrl.GetLeftPart(UriPartial.Path);
255+
var urlPath = url.GetLeftPart(UriPartial.Path);
256+
var baseUrlWithSlash = baseUrlPath;
257+
if (!baseUrlWithSlash.EndsWith("/", StringComparison.Ordinal))
258+
{
259+
baseUrlWithSlash += "/";
260+
}
261+
if (string.Equals(urlPath, baseUrlPath, StringComparison.OrdinalIgnoreCase) ||
262+
urlPath.StartsWith(baseUrlWithSlash, StringComparison.OrdinalIgnoreCase))
256263
{
257264
baseUrlAllowed = true;
258265
break;

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2085,6 +2085,152 @@ public async Task ItShouldAllowCustomSchemesWhenConfiguredAsync()
20852085
await sut.RunAsync(operation, []);
20862086
}
20872087

2088+
[Fact]
2089+
public async Task ItShouldBlockRequestWithPrefixCollisionOnAllowedBaseUrlAsync()
2090+
{
2091+
// Arrange - attacker URL shares prefix with allowed base URL but diverges at path boundary
2092+
var operation = new RestApiOperation(
2093+
id: "test",
2094+
servers: [new RestApiServer("https://api.example.com/v1-evil")],
2095+
path: "/steal-data",
2096+
method: HttpMethod.Get,
2097+
description: "test operation",
2098+
parameters: [],
2099+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2100+
securityRequirements: []
2101+
);
2102+
2103+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2104+
{
2105+
AllowedBaseUrls = [new Uri("https://api.example.com/v1")]
2106+
};
2107+
2108+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2109+
2110+
// Act & Assert - should be blocked because /v1-evil is not under /v1/
2111+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(operation, []));
2112+
Assert.Contains("not allowed", exception.Message);
2113+
Assert.Contains("does not match", exception.Message);
2114+
}
2115+
2116+
[Fact]
2117+
public async Task ItShouldAllowRequestUnderAllowedBaseUrlWithPathAsync()
2118+
{
2119+
// Arrange - legitimate sub-path under allowed base URL
2120+
var operation = new RestApiOperation(
2121+
id: "test",
2122+
servers: [new RestApiServer("https://api.example.com/v1")],
2123+
path: "/users",
2124+
method: HttpMethod.Get,
2125+
description: "test operation",
2126+
parameters: [],
2127+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2128+
securityRequirements: []
2129+
);
2130+
2131+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2132+
{
2133+
AllowedBaseUrls = [new Uri("https://api.example.com/v1")]
2134+
};
2135+
2136+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2137+
2138+
// Act & Assert - should not throw; /v1/users is under /v1/
2139+
await sut.RunAsync(operation, []);
2140+
}
2141+
2142+
[Fact]
2143+
public async Task ItShouldAllowRequestWhenAllowedBaseUrlContainsQueryOrFragmentAsync()
2144+
{
2145+
// Arrange - base URL misconfigured with query string; validation should ignore it
2146+
var operation = new RestApiOperation(
2147+
id: "test",
2148+
servers: [new RestApiServer("https://api.example.com")],
2149+
path: "/users",
2150+
method: HttpMethod.Get,
2151+
description: "test operation",
2152+
parameters: [],
2153+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2154+
securityRequirements: []
2155+
);
2156+
2157+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2158+
{
2159+
AllowedBaseUrls = [new Uri("https://api.example.com?x=1")]
2160+
};
2161+
2162+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2163+
2164+
// Act & Assert - should not throw; query/fragment in base URL is stripped for comparison
2165+
await sut.RunAsync(operation, []);
2166+
}
2167+
2168+
[Fact]
2169+
public async Task ItShouldBlockHostLevelPrefixCollisionAsync()
2170+
{
2171+
// Arrange - malicious host shares textual prefix with allowed host
2172+
var operation = new RestApiOperation(
2173+
id: "test",
2174+
servers: [new RestApiServer("https://api.example.com.evil.com")],
2175+
path: "/steal",
2176+
method: HttpMethod.Get,
2177+
description: "test operation",
2178+
parameters: [],
2179+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2180+
securityRequirements: []
2181+
);
2182+
2183+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2184+
{
2185+
AllowedBaseUrls = [new Uri("https://api.example.com")]
2186+
};
2187+
2188+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2189+
2190+
// Act & Assert - should be blocked; api.example.com.evil.com is not api.example.com
2191+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(operation, []));
2192+
Assert.Contains("not allowed", exception.Message);
2193+
}
2194+
2195+
[Fact]
2196+
public async Task ItShouldAllowRequestToBaseUrlPathWithQueryParametersAsync()
2197+
{
2198+
// Arrange - request to exact base URL path but with query parameters
2199+
var queryParameter = new RestApiParameter(
2200+
"user",
2201+
"string",
2202+
isRequired: true,
2203+
false,
2204+
RestApiParameterLocation.Query,
2205+
RestApiParameterStyle.Form);
2206+
2207+
var operation = new RestApiOperation(
2208+
id: "test",
2209+
servers: [new RestApiServer("https://api.example.com/v1")],
2210+
path: "/",
2211+
method: HttpMethod.Get,
2212+
description: "test operation",
2213+
parameters: [queryParameter],
2214+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2215+
securityRequirements: []
2216+
);
2217+
2218+
var arguments = new KernelArguments
2219+
{
2220+
{ "user", "1" },
2221+
};
2222+
2223+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2224+
{
2225+
AllowedBaseUrls = [new Uri("https://api.example.com/v1")]
2226+
};
2227+
2228+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2229+
2230+
// Act & Assert - should not throw; the path portion matches the allowed base URL
2231+
await sut.RunAsync(operation, arguments);
2232+
}
2233+
20882234
/// <summary>
20892235
/// Disposes resources used by this class.
20902236
/// </summary>

0 commit comments

Comments
 (0)