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
17 changes: 17 additions & 0 deletions src/Common/src/Common/Extensions/ExceptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,21 @@ public static bool IsCancellation(this Exception? exception)

return false;
}

/// <summary>
/// Determines whether the thrown exception results from an HTTP request timeout.
/// </summary>
/// <param name="exception">
/// The caught exception to inspect.
/// </param>
public static bool IsHttpClientTimeout(this Exception? exception)
{
// See note in remarks at https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync.
if (exception is OperationCanceledException && exception.InnerException?.GetType() == typeof(TimeoutException))
{
return true;
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Steeltoe.Common;
using Steeltoe.Management.Configuration;
using Steeltoe.Management.Endpoint.Actuators.Hypermedia;
Expand All @@ -19,6 +20,7 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry;

internal sealed class CloudFoundrySecurityMiddleware
{
private const string BearerTokenPrefix = "Bearer ";
private readonly IOptionsMonitor<ManagementOptions> _managementOptionsMonitor;
private readonly IOptionsMonitor<CloudFoundryEndpointOptions> _endpointOptionsMonitor;
private readonly IEndpointOptionsMonitorProvider[] _endpointOptionsMonitorProviderArray;
Expand Down Expand Up @@ -60,16 +62,16 @@ public async Task InvokeAsync(HttpContext context)
{
if (string.IsNullOrEmpty(endpointOptions.ApplicationId))
{
_logger.LogCritical(
_logger.LogError(
"The Application Id could not be found. Make sure the Cloud Foundry Configuration Provider has been added to the application configuration.");

await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.ApplicationIdMissingMessage));
await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.ApplicationIdMissing));
return;
}

if (string.IsNullOrEmpty(endpointOptions.Api))
{
await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryApiMissingMessage));
await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryApiMissing));
return;
}

Expand All @@ -87,7 +89,7 @@ public async Task InvokeAsync(HttpContext context)

if (targetEndpointOptions.RequiredPermissions > givenPermissions.Permissions)
{
await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage));
await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied));
return;
}
}
Expand All @@ -101,13 +103,13 @@ public async Task InvokeAsync(HttpContext context)

internal string GetAccessToken(HttpRequest request)
{
if (request.Headers.TryGetValue(PermissionsProvider.AuthorizationHeaderName, out StringValues headerValue))
if (request.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorizationHeaderValue))
{
string header = headerValue.ToString();
string authorizationValue = authorizationHeaderValue.ToString();

if (header.StartsWith(PermissionsProvider.BearerHeaderNamePrefix, StringComparison.OrdinalIgnoreCase))
if (authorizationValue.StartsWith(BearerTokenPrefix, StringComparison.OrdinalIgnoreCase))
{
return header[PermissionsProvider.BearerHeaderNamePrefix.Length..];
return authorizationValue[BearerTokenPrefix.Length..];
}
}

Expand Down Expand Up @@ -162,7 +164,9 @@ private async Task ReturnErrorAsync(HttpContext context, SecurityResult error)
_logger.LogError("Actuator Security Error: {Code} - {Message}", error.Code, error.Message);
context.Response.Headers.Append("Content-Type", "application/json;charset=UTF-8");

// allowing override of 400-level errors is more likely to cause confusion than to be useful
// UseStatusCodeFromResponse was added to prevent IIS/HWC from blocking the response body on 500-level errors.
// Blocking 400-level error responses would be more likely to cause confusion than to be useful.
// See https://github.com/SteeltoeOSS/Steeltoe/issues/418 for more information.
if (_managementOptionsMonitor.CurrentValue.UseStatusCodeFromResponse || (int)error.Code < 500)
{
context.Response.StatusCode = (int)error.Code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,9 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry;

internal sealed class PermissionsProvider
{
private const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid";
private const string CloudfoundryNotReachableMessage = "Cloud controller not reachable";
private const string ReadSensitiveDataJsonPropertyName = "read_sensitive_data";
public const string HttpClientName = "CloudFoundrySecurity";
public const string ApplicationIdMissingMessage = "Application ID is not available";
public const string CloudfoundryApiMissingMessage = "Cloud controller URL is not available";
public const string AccessDeniedMessage = "Access denied";
public const string AuthorizationHeaderName = "Authorization";
public const string BearerHeaderNamePrefix = "Bearer ";
private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5000);
private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5_000);

private readonly IOptionsMonitor<CloudFoundryEndpointOptions> _optionsMonitor;
private readonly IHttpClientFactory _httpClientFactory;
Expand All @@ -55,7 +48,7 @@ public async Task<SecurityResult> GetPermissionsAsync(string accessToken, Cancel
{
if (string.IsNullOrEmpty(accessToken))
{
return new SecurityResult(HttpStatusCode.Unauthorized, AuthorizationHeaderInvalid);
return new SecurityResult(HttpStatusCode.Unauthorized, Messages.AuthorizationHeaderInvalid);
}

CloudFoundryEndpointOptions options = _optionsMonitor.CurrentValue;
Expand All @@ -75,23 +68,31 @@ public async Task<SecurityResult> GetPermissionsAsync(string accessToken, Cancel
_logger.LogInformation("Cloud Foundry returned status: {HttpStatus} while obtaining permissions from: {PermissionsUri}", response.StatusCode,
checkPermissionsUri);

return response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized
? new SecurityResult(HttpStatusCode.Forbidden, AccessDeniedMessage)
: new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage);
if (response.StatusCode is HttpStatusCode.Forbidden)
{
return new SecurityResult(HttpStatusCode.Forbidden, Messages.AccessDenied);
}

return (int)response.StatusCode is > 399 and < 500
? new SecurityResult(HttpStatusCode.Unauthorized, Messages.InvalidToken)
: new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable);
}

EndpointPermissions permissions = await GetPermissionsAsync(response, cancellationToken);
EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken);
return new SecurityResult(permissions);
}
catch (Exception exception) when (!exception.IsCancellation())
catch (HttpRequestException exception)
{
_logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri);

return new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage);
return new SecurityResult(HttpStatusCode.ServiceUnavailable,
$"Exception of type '{typeof(HttpRequestException)}' with error '{exception.HttpRequestError}' was thrown");
}
catch (Exception exception) when (exception.IsHttpClientTimeout())
{
return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout);
}
}

public async Task<EndpointPermissions> GetPermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
public async Task<EndpointPermissions> ParsePermissionsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(response);

Expand Down Expand Up @@ -127,4 +128,15 @@ private HttpClient CreateHttpClient()
httpClient.ConfigureForSteeltoe(GetPermissionsTimeout);
return httpClient;
}

internal static class Messages
{
public const string AccessDenied = "Access denied";
public const string ApplicationIdMissing = "Application ID is not available";
public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid";
public const string CloudFoundryApiMissing = "Cloud controller URL is not available";
public const string CloudFoundryNotReachable = "Cloud controller not reachable";
public const string CloudFoundryTimeout = "Cloud controller request timed out";
public const string InvalidToken = "Invalid token";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Net;
using RichardSzalay.MockHttp;
using Steeltoe.Common.TestResources;

namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry;

internal static class CloudControllerPermissionsMock
{
internal static DelegateToMockHttpClientHandler GetHttpMessageHandler()
{
var httpClientHandler = new DelegateToMockHttpClientHandler();

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions")
.Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}");

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions")
.Respond(HttpStatusCode.NotFound, "application/json", "{}");

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions")
.Respond(HttpStatusCode.Unauthorized, "application/json", "{}");

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions")
.Respond(HttpStatusCode.Forbidden, "application/json", "{}");

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions")
.Throw(new OperationCanceledException(null, new TimeoutException()));

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions")
.Throw(new HttpRequestException(HttpRequestError.NameResolutionError));

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK,
"application/json", """{"read_sensitive_data": false, "read_basic_data": true}""");

httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json",
"""{"read_sensitive_data": true, "read_basic_data": true}""");

return httpClientHandler;
}
}
Loading