diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index c912e58265..b23d890abe 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -190,17 +190,17 @@ namespace Tgstation.Server.Api /// If a missing should be ignored. /// Thrown if the constitue invalid . #pragma warning disable CA1502 // TODO: Decomplexify - public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false) + public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); - var badHeaders = HeaderTypes.None; + var badHeaders = HeaderErrorTypes.None; var errorBuilder = new StringBuilder(); var multipleErrors = false; - void AddError(HeaderTypes headerType, string message) + void AddError(HeaderErrorTypes headerType, string message) { - if (badHeaders != HeaderTypes.None) + if (badHeaders != HeaderErrorTypes.None) { multipleErrors = true; errorBuilder.AppendLine(); @@ -212,28 +212,28 @@ namespace Tgstation.Server.Api var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJsonMime); if (!requestHeaders.Accept.Any(x => jsonAccept.IsSubsetOf(x))) - AddError(HeaderTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); + AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgentValues) || userAgentValues.Count == 0) - AddError(HeaderTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); + AddError(HeaderErrorTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); else { RawUserAgent = userAgentValues.First(); if (String.IsNullOrWhiteSpace(RawUserAgent)) - AddError(HeaderTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!"); + AddError(HeaderErrorTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!"); } // make sure the api header matches ours Version? apiVersion = null; if (!requestHeaders.Headers.TryGetValue(ApiVersionHeader, out var apiUserAgentHeaderValues) || !ProductInfoHeaderValue.TryParse(apiUserAgentHeaderValues.FirstOrDefault(), out var apiUserAgent) || apiUserAgent.Product.Name != AssemblyName.Name) - AddError(HeaderTypes.Api, $"Missing {ApiVersionHeader} header!"); + AddError(HeaderErrorTypes.Api, $"Missing {ApiVersionHeader} header!"); else if (!Version.TryParse(apiUserAgent.Product.Version, out apiVersion)) - AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!"); + AddError(HeaderErrorTypes.Api, $"Malformed {ApiVersionHeader} header!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) { if (!ignoreMissingAuth) - AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!"); + AddError(HeaderErrorTypes.AuthorizationMissing, $"Missing {HeaderNames.Authorization} header!"); } else { @@ -241,13 +241,13 @@ namespace Tgstation.Server.Api var splits = new List(auth.Split(' ')); var scheme = splits.First(); if (String.IsNullOrWhiteSpace(scheme)) - AddError(HeaderTypes.Authorization, "Missing authentication scheme!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication scheme!"); else { splits.RemoveAt(0); var parameter = String.Concat(splits); if (String.IsNullOrEmpty(parameter)) - AddError(HeaderTypes.Authorization, "Missing authentication parameter!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication parameter!"); else { if (requestHeaders.Headers.TryGetValue(InstanceIdHeader, out var instanceIdValues)) @@ -266,10 +266,10 @@ namespace Tgstation.Server.Api if (Enum.TryParse(oauthProviderString, out var oauthProvider)) OAuthProvider = oauthProvider; else - AddError(HeaderTypes.OAuthProvider, "Invalid OAuth provider!"); + AddError(HeaderErrorTypes.OAuthProvider, "Invalid OAuth provider!"); } else - AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); + AddError(HeaderErrorTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); OAuthCode = parameter; break; @@ -277,7 +277,7 @@ namespace Tgstation.Server.Api var tokenSplits = parameter.Split('.'); DateTimeOffset? expiresAt = null; if (tokenSplits.Length != 3) - AddError(HeaderTypes.Authorization, "Invalid JWT!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT!"); else try { @@ -290,13 +290,13 @@ namespace Tgstation.Server.Api if (Int64.TryParse(nbf, out var unixTimeSeconds)) expiresAt = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds); else - AddError(HeaderTypes.Authorization, "'nbf' in JWT could not be parsed!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "'nbf' in JWT could not be parsed!"); else - AddError(HeaderTypes.Authorization, "Missing 'nbf' in JWT payload!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing 'nbf' in JWT payload!"); } catch { - AddError(HeaderTypes.Authorization, "Invalid JWT payload!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT payload!"); } Token = new TokenResponse @@ -315,14 +315,14 @@ namespace Tgstation.Server.Api } catch { - AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage); + AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage); break; } var basicAuthSplits = joinedString.Split(ColonSeparator, StringSplitOptions.RemoveEmptyEntries); if (basicAuthSplits.Length < 2) { - AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage); + AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage); break; } @@ -330,14 +330,14 @@ namespace Tgstation.Server.Api Password = String.Concat(basicAuthSplits.Skip(1)); break; default: - AddError(HeaderTypes.Authorization, "Invalid authentication scheme!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid authentication scheme!"); break; } } } } - if (badHeaders != HeaderTypes.None) + if (badHeaders != HeaderErrorTypes.None) { if (multipleErrors) errorBuilder.Insert(0, $"Multiple header validation errors occurred:{Environment.NewLine}"); diff --git a/src/Tgstation.Server.Api/HeaderErrorTypes.cs b/src/Tgstation.Server.Api/HeaderErrorTypes.cs new file mode 100644 index 0000000000..414b1e22d3 --- /dev/null +++ b/src/Tgstation.Server.Api/HeaderErrorTypes.cs @@ -0,0 +1,46 @@ +using System; + +namespace Tgstation.Server.Api +{ + /// + /// Types of individual errors. + /// + [Flags] + public enum HeaderErrorTypes + { + /// + /// No header errors. + /// + None = 0, + + /// + /// The header is missing or invalid. + /// + UserAgent = 1 << 0, + + /// + /// The header is missing or invalid. + /// + Accept = 1 << 1, + + /// + /// The header is missing or invalid. + /// + Api = 1 << 2, + + /// + /// The header is invalid. + /// + AuthorizationInvalid = 1 << 3, + + /// + /// The header is missing or invalid. + /// + OAuthProvider = 1 << 4, + + /// + /// The header is missing. + /// + AuthorizationMissing = 1 << 5, + } +} diff --git a/src/Tgstation.Server.Api/HeaderTypes.cs b/src/Tgstation.Server.Api/HeaderTypes.cs deleted file mode 100644 index a5fdca325d..0000000000 --- a/src/Tgstation.Server.Api/HeaderTypes.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; - -namespace Tgstation.Server.Api -{ - /// - /// Types of individual . - /// - [Flags] - public enum HeaderTypes - { - /// - /// No headers. - /// - None = 0, - - /// - /// header. - /// - UserAgent = 1 << 0, - - /// - /// header. - /// - Accept = 1 << 1, - - /// - /// . - /// - Api = 1 << 2, - - /// - /// - /// - Authorization = 1 << 3, - - /// - /// . - /// - OAuthProvider = 1 << 4, - } -} diff --git a/src/Tgstation.Server.Api/HeadersException.cs b/src/Tgstation.Server.Api/HeadersException.cs index 6287ee1c88..352d2ce9d8 100644 --- a/src/Tgstation.Server.Api/HeadersException.cs +++ b/src/Tgstation.Server.Api/HeadersException.cs @@ -8,19 +8,19 @@ namespace Tgstation.Server.Api public sealed class HeadersException : Exception { /// - /// The s that are missing or malformed. + /// The s that are missing or malformed. /// - public HeaderTypes MissingOrMalformedHeaders { get; } + public HeaderErrorTypes ParseErrors { get; } /// /// Initializes a new instance of the class. /// - /// The value of . + /// The value of . /// The error message. - public HeadersException(HeaderTypes missingOrMalformedHeaders, string message) + public HeadersException(HeaderErrorTypes parseErrors, string message) : base(message) { - MissingOrMalformedHeaders = missingOrMalformedHeaders; + ParseErrors = parseErrors; } /// diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9fd4b6154a..d0b7213427 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -48,7 +48,12 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation. /// - protected ApiHeaders ApiHeaders { get; } + protected ApiHeaders ApiHeaders => ApiHeadersProvider.ApiHeaders; + + /// + /// The containing value of . + /// + protected IApiHeadersProvider ApiHeadersProvider { get; } /// /// The for the operation. @@ -81,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of . /// The for the . /// The value of . - /// The containing value of . + /// The value of .. /// The value of . protected ApiController( IDatabaseContext databaseContext, @@ -92,11 +97,10 @@ namespace Tgstation.Server.Host.Controllers { DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); - ArgumentNullException.ThrowIfNull(apiHeadersProvider); + ApiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); Instance = AuthenticationContext?.InstancePermissionSet?.Instance; - ApiHeaders = apiHeadersProvider.ApiHeaders; this.requireHeaders = requireHeaders; } @@ -110,7 +114,7 @@ namespace Tgstation.Server.Host.Controllers if (ApiHeaders == null) { if (requireHeaders) - return HeadersIssue(false); + return HeadersIssue(ApiHeadersProvider.HeadersException); } else if (!ApiHeaders.Compatible()) return this.StatusCode( @@ -239,28 +243,19 @@ namespace Tgstation.Server.Host.Controllers /// /// Response for missing/Invalid headers. /// - /// Whether or not errors due to missing should be thrown. + /// The that occurred while trying to parse the . /// The appropriate . - protected IActionResult HeadersIssue(bool ignoreMissingAuth) + protected IActionResult HeadersIssue(HeadersException headersException) { - HeadersException headersException; - try - { - // TODO: Move this somewhere saner? - _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth); + if (headersException == null) throw new InvalidOperationException("Expected a header parse exception!"); - } - catch (HeadersException ex) - { - headersException = ex; - } var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders) { AdditionalData = headersException.Message, }; - if (headersException.MissingOrMalformedHeaders.HasFlag(HeaderTypes.Accept)) + if (headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept)) return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage); return BadRequest(errorMessage); diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 76598ef4f1..290049ca52 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -180,15 +180,15 @@ namespace Tgstation.Server.Host.Controllers try { // we only allow authorization header issues - var headers = new ApiHeaders(Request.GetTypedHeaders(), true); + var headers = ApiHeadersProvider.CreateAuthlessHeaders(); if (!headers.Compatible()) return this.StatusCode( HttpStatusCode.UpgradeRequired, new ErrorMessageResponse(ErrorCode.ApiMismatch)); } - catch (HeadersException) + catch (HeadersException ex) { - return HeadersIssue(true); + return HeadersIssue(ex); } } @@ -228,7 +228,7 @@ namespace Tgstation.Server.Host.Controllers if (ApiHeaders == null) { Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); - return HeadersIssue(false); + return HeadersIssue(ApiHeadersProvider.HeadersException); } if (ApiHeaders.IsTokenAuthentication) diff --git a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs index c59d2d67d0..868a7683c7 100644 --- a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; + using Tgstation.Server.Api; namespace Tgstation.Server.Host.Utils @@ -10,29 +11,76 @@ namespace Tgstation.Server.Host.Utils sealed class ApiHeadersProvider : IApiHeadersProvider { /// - public ApiHeaders ApiHeaders { get; } + public ApiHeaders ApiHeaders => attemptedApiHeadersCreation + ? apiHeaders + : CreateApiHeaders(true); + + /// + public HeadersException HeadersException { get; private set; } + + /// + /// The for the . + /// + readonly IHttpContextAccessor httpContextAccessor; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Backing field for . + /// + ApiHeaders apiHeaders; + + /// + /// If populating was previously attempted. + /// + bool attemptedApiHeadersCreation; /// /// Initializes a new instance of the class. /// - /// The for accessing the . - /// The to write to. + /// The value of . + /// The value of . public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor, ILogger logger) { - ArgumentNullException.ThrowIfNull(httpContextAccessor); + this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// + public ApiHeaders CreateAuthlessHeaders() => CreateApiHeaders(false); + + /// + /// Attempt to parse from the , optionally populating the properties. + /// + /// If the error should be ignored and / should be populated. + /// A newly parsed or if was set and the parse failed. + ApiHeaders CreateApiHeaders(bool includeAuthAndSetProperties) + { if (httpContextAccessor.HttpContext == null) throw new InvalidOperationException("httpContextAccessor has no HttpContext!"); var request = httpContextAccessor.HttpContext.Request; + var ignoreMissingAuth = !includeAuthAndSetProperties; + + if (includeAuthAndSetProperties) + attemptedApiHeadersCreation = true; + try { - ApiHeaders = new ApiHeaders(request.GetTypedHeaders()); + var headers = new ApiHeaders(request.GetTypedHeaders(), ignoreMissingAuth); + if (includeAuthAndSetProperties) + apiHeaders = headers; + + return headers; } - catch (HeadersException ex) + catch (HeadersException ex) when (includeAuthAndSetProperties) { - // we are not responsible for handling header validation issues - logger.LogTrace(ex, "Failed to validated API request headers!"); + logger.LogTrace(ex, "Failed to parse API headers!"); + HeadersException = ex; + return null; } } } diff --git a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs index 31747862be..7de65834d3 100644 --- a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs @@ -11,5 +11,18 @@ namespace Tgstation.Server.Host.Utils /// The created , if any. /// ApiHeaders ApiHeaders { get; } + + /// + /// The thrown when attempting to parse the if any. + /// + HeadersException HeadersException { get; } + + /// + /// Attempt to create without checking for the presence of an header. + /// + /// A new . + /// This does not populate the property. + /// Thrown if the requested contain errors other than . + ApiHeaders CreateAuthlessHeaders(); } } diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 5261d8719a..27329a1af4 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -43,7 +43,7 @@ namespace Tgstation.Server.Api.Tests { "User-Agent", userAgent } }; - return new ApiHeaders(new RequestHeaders(headers)); + return new ApiHeaders(new RequestHeaders(headers), false); }; var header = TestHeader(BrowserHeader);