diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 048116fe..f5811d15 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -26,6 +26,7 @@ + diff --git a/src/ImageSharp.Web.Providers.AWS/Caching/AWSS3StorageCache.cs b/src/ImageSharp.Web.Providers.AWS/Caching/AWSS3StorageCache.cs index 42d47de0..b904c86d 100644 --- a/src/ImageSharp.Web.Providers.AWS/Caching/AWSS3StorageCache.cs +++ b/src/ImageSharp.Web.Providers.AWS/Caching/AWSS3StorageCache.cs @@ -1,8 +1,11 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. +using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Threading; using System.Threading.Tasks; using Amazon.S3; using Amazon.S3.Model; @@ -119,5 +122,53 @@ private static async Task CreateIfNotExistsAsync( return null; } + + /// + /// + /// + private static class AsyncHelper + { + private static readonly TaskFactory TaskFactory + = new( + CancellationToken.None, + TaskCreationOptions.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + + /// + /// Executes an async method synchronously. + /// + /// The task to excecute. + public static void RunSync(Func task) + { + CultureInfo cultureUi = CultureInfo.CurrentUICulture; + CultureInfo culture = CultureInfo.CurrentCulture; + TaskFactory.StartNew(() => + { + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = cultureUi; + return task(); + }).Unwrap().GetAwaiter().GetResult(); + } + + /// + /// Executes an async method which has + /// a return type synchronously. + /// + /// The type of result to return. + /// The task to excecute. + /// The . + public static TResult RunSync(Func> task) + { + CultureInfo cultureUi = CultureInfo.CurrentUICulture; + CultureInfo culture = CultureInfo.CurrentCulture; + return TaskFactory.StartNew(() => + { + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = cultureUi; + return task(); + }).Unwrap().GetAwaiter().GetResult(); + } + } } } diff --git a/src/ImageSharp.Web.Providers.AWS/AsyncHelper.cs b/src/ImageSharp.Web/AsyncHelper.cs similarity index 89% rename from src/ImageSharp.Web.Providers.AWS/AsyncHelper.cs rename to src/ImageSharp.Web/AsyncHelper.cs index 058ebcd5..342880c7 100644 --- a/src/ImageSharp.Web.Providers.AWS/AsyncHelper.cs +++ b/src/ImageSharp.Web/AsyncHelper.cs @@ -13,11 +13,12 @@ namespace SixLabors.ImageSharp.Web /// internal static class AsyncHelper { - private static readonly TaskFactory TaskFactory = new - (CancellationToken.None, - TaskCreationOptions.None, - TaskContinuationOptions.None, - TaskScheduler.Default); + private static readonly TaskFactory TaskFactory + = new( + CancellationToken.None, + TaskCreationOptions.None, + TaskContinuationOptions.None, + TaskScheduler.Default); /// /// Executes an async method synchronously. diff --git a/src/ImageSharp.Web/Caching/UriAbsoluteCacheKey.cs b/src/ImageSharp.Web/Caching/UriAbsoluteCacheKey.cs index b10178c9..941ffcfe 100644 --- a/src/ImageSharp.Web/Caching/UriAbsoluteCacheKey.cs +++ b/src/ImageSharp.Web/Caching/UriAbsoluteCacheKey.cs @@ -13,6 +13,11 @@ public class UriAbsoluteCacheKey : ICacheKey { /// public string Create(HttpContext context, CommandCollection commands) - => CaseHandlingUriBuilder.BuildAbsolute(CaseHandlingUriBuilder.CaseHandling.None, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands)); + => CaseHandlingUriBuilder.BuildAbsolute( + CaseHandlingUriBuilder.CaseHandling.None, + context.Request.Host, + context.Request.PathBase, + context.Request.Path, + QueryString.Create(commands)); } } diff --git a/src/ImageSharp.Web/Caching/UriAbsoluteLowerInvariantCacheKey.cs b/src/ImageSharp.Web/Caching/UriAbsoluteLowerInvariantCacheKey.cs index cf90a401..333acbc8 100644 --- a/src/ImageSharp.Web/Caching/UriAbsoluteLowerInvariantCacheKey.cs +++ b/src/ImageSharp.Web/Caching/UriAbsoluteLowerInvariantCacheKey.cs @@ -13,6 +13,11 @@ public class UriAbsoluteLowerInvariantCacheKey : ICacheKey { /// public string Create(HttpContext context, CommandCollection commands) - => CaseHandlingUriBuilder.BuildAbsolute(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands)); + => CaseHandlingUriBuilder.BuildAbsolute( + CaseHandlingUriBuilder.CaseHandling.LowerInvariant, + context.Request.Host, + context.Request.PathBase, + context.Request.Path, + QueryString.Create(commands)); } } diff --git a/src/ImageSharp.Web/CaseHandlingUriBuilder.cs b/src/ImageSharp.Web/CaseHandlingUriBuilder.cs index e3a0555a..ca09a4f6 100644 --- a/src/ImageSharp.Web/CaseHandlingUriBuilder.cs +++ b/src/ImageSharp.Web/CaseHandlingUriBuilder.cs @@ -14,7 +14,8 @@ namespace SixLabors.ImageSharp.Web /// public static class CaseHandlingUriBuilder { - private static readonly SpanAction InitializeAbsoluteUriStringSpanAction = new(InitializeAbsoluteUriString); + private static readonly Uri FallbackBaseUri = new("http://localhost/"); + private static readonly SpanAction InitializeAbsoluteUriStringSpanAction = new(InitializeAbsoluteUriString); /// /// Provides Uri case handling options. @@ -49,11 +50,31 @@ public static string BuildRelative( // Take any potential performance hit vs concatination for code reading sanity. => BuildAbsolute(handling, default, pathBase, path, query); + /// + /// Combines the given URI components into a string that is properly encoded for use in HTTP headers. + /// Note that unicode in the HostString will be encoded as punycode and the scheme is not included + /// in the result. + /// + /// Determines case handling for the result. is always converted to invariant lowercase. + /// The host portion of the uri normally included in the Host header. This may include the port. + /// The first portion of the request path associated with application root. + /// The portion of the request path that identifies the requested resource. + /// The query, if any. + /// The combined URI components, properly encoded for use in HTTP headers. + public static string BuildAbsolute( + CaseHandling handling, + HostString host, + PathString pathBase = default, + PathString path = default, + QueryString query = default) + => BuildAbsolute(handling, string.Empty, host, pathBase, path, query); + /// /// Combines the given URI components into a string that is properly encoded for use in HTTP headers. /// Note that unicode in the HostString will be encoded as punycode. /// /// Determines case handling for the result. is always converted to invariant lowercase. + /// http, https, etc. /// The host portion of the uri normally included in the Host header. This may include the port. /// The first portion of the request path associated with application root. /// The portion of the request path that identifies the requested resource. @@ -61,11 +82,14 @@ public static string BuildRelative( /// The combined URI components, properly encoded for use in HTTP headers. public static string BuildAbsolute( CaseHandling handling, + string scheme, HostString host, PathString pathBase = default, PathString path = default, QueryString query = default) { + Guard.NotNull(scheme, nameof(scheme)); + string hostText = host.ToUriComponent(); string pathBaseText = pathBase.ToUriComponent(); string pathText = path.ToUriComponent(); @@ -73,6 +97,7 @@ public static string BuildAbsolute( // PERF: Calculate string length to allocate correct buffer size for string.Create. int length = + (scheme.Length > 0 ? scheme.Length + Uri.SchemeDelimiter.Length : 0) + hostText.Length + pathBaseText.Length + pathText.Length + @@ -94,7 +119,10 @@ public static string BuildAbsolute( length--; } - return string.Create(length, (handling == CaseHandling.LowerInvariant, hostText, pathBaseText, pathText, queryText), InitializeAbsoluteUriStringSpanAction); + return string.Create( + length, + (handling == CaseHandling.LowerInvariant, scheme, hostText, pathBaseText, pathText, queryText), + InitializeAbsoluteUriStringSpanAction); } /// @@ -105,7 +133,10 @@ public static string BuildAbsolute( /// The Uri to encode. /// The encoded string version of . public static string Encode(CaseHandling handling, string uri) - => Encode(handling, new Uri(uri, UriKind.RelativeOrAbsolute)); + { + Guard.NotNull(uri, nameof(uri)); + return Encode(handling, new Uri(uri, UriKind.RelativeOrAbsolute)); + } /// /// Generates a string from the given absolute or relative Uri that is appropriately encoded for use in @@ -121,19 +152,18 @@ public static string Encode(CaseHandling handling, Uri uri) { return BuildAbsolute( handling, + scheme: uri.Scheme, host: HostString.FromUriComponent(uri), pathBase: PathString.FromUriComponent(uri), query: QueryString.FromUriComponent(uri)); } else { - string components = uri.GetComponents(UriComponents.SerializationInfoString, UriFormat.UriEscaped); - if (handling == CaseHandling.LowerInvariant) - { - return components.ToLowerInvariant(); - } - - return components; + Uri faux = new(FallbackBaseUri, uri); + return BuildRelative( + handling, + path: PathString.FromUriComponent(faux), + query: QueryString.FromUriComponent(faux)); } } @@ -168,7 +198,7 @@ private static int CopyTextToBufferLowerInvariant(Span buffer, int index, /// /// The URI 's buffer. /// The URI parts. - private static void InitializeAbsoluteUriString(Span buffer, (bool Lower, string Host, string PathBase, string Path, string Query) uriParts) + private static void InitializeAbsoluteUriString(Span buffer, (bool Lower, string Scheme, string Host, string PathBase, string Path, string Query) uriParts) { int index = 0; ReadOnlySpan pathBaseSpan = uriParts.PathBase.AsSpan(); @@ -181,6 +211,12 @@ private static void InitializeAbsoluteUriString(Span buffer, (bool Lower, pathBaseSpan = pathBaseSpan.Slice(0, pathBaseSpan.Length - 1); } + if (uriParts.Scheme.Length > 0) + { + index = CopyTextToBufferLowerInvariant(buffer, index, uriParts.Scheme.AsSpan()); + index = CopyTextToBuffer(buffer, index, Uri.SchemeDelimiter.AsSpan()); + } + if (uriParts.Lower) { index = CopyTextToBufferLowerInvariant(buffer, index, uriParts.Host.AsSpan()); diff --git a/src/ImageSharp.Web/CommandHandling.cs b/src/ImageSharp.Web/CommandHandling.cs new file mode 100644 index 00000000..5722c3f3 --- /dev/null +++ b/src/ImageSharp.Web/CommandHandling.cs @@ -0,0 +1,24 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using SixLabors.ImageSharp.Web.Commands; + +namespace SixLabors.ImageSharp.Web +{ + /// + /// Provides enumeration for handling instances + /// when processing a request. + /// + public enum CommandHandling + { + /// + /// The command collection will be stripped of any unknown commands. + /// + Sanitize, + + /// + /// The command collection will be processed unaltered. + /// + None + } +} diff --git a/src/ImageSharp.Web/Commands/PresetOnlyQueryCollectionRequestParser.cs b/src/ImageSharp.Web/Commands/PresetOnlyQueryCollectionRequestParser.cs index 7f6c5f45..21545848 100644 --- a/src/ImageSharp.Web/Commands/PresetOnlyQueryCollectionRequestParser.cs +++ b/src/ImageSharp.Web/Commands/PresetOnlyQueryCollectionRequestParser.cs @@ -33,13 +33,16 @@ public PresetOnlyQueryCollectionRequestParser(IOptions public CommandCollection ParseRequestCommands(HttpContext context) { - if (context.Request.Query.Count == 0 || !context.Request.Query.ContainsKey(QueryKey)) + IQueryCollection queryCollection = context.Request.Query; + if (queryCollection is null + || queryCollection.Count == 0 + || !queryCollection.ContainsKey(QueryKey)) { // We return new here and below to ensure the collection is still mutable via events. return new(); } - StringValues query = context.Request.Query[QueryKey]; + StringValues query = queryCollection[QueryKey]; string requestedPreset = query[query.Count - 1]; if (this.presets.TryGetValue(requestedPreset, out CommandCollection collection)) { diff --git a/src/ImageSharp.Web/Commands/QueryCollectionRequestParser.cs b/src/ImageSharp.Web/Commands/QueryCollectionRequestParser.cs index cf31b149..d54ba8d7 100644 --- a/src/ImageSharp.Web/Commands/QueryCollectionRequestParser.cs +++ b/src/ImageSharp.Web/Commands/QueryCollectionRequestParser.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; namespace SixLabors.ImageSharp.Web.Commands @@ -16,17 +15,15 @@ public sealed class QueryCollectionRequestParser : IRequestParser /// public CommandCollection ParseRequestCommands(HttpContext context) { - if (context.Request.Query.Count == 0) + IQueryCollection query = context.Request.Query; + if (query is null || query.Count == 0) { // We return new to ensure the collection is still mutable via events. return new(); } - // TODO: Investigate skipping the double allocation here. - // In .NET 6 we can directly use the QueryStringEnumerable type and enumerate stright to our command collection - Dictionary parsed = QueryHelpers.ParseQuery(context.Request.QueryString.ToUriComponent()); CommandCollection transformed = new(); - foreach (KeyValuePair pair in parsed) + foreach (KeyValuePair pair in query) { // Use the indexer for both set and query. This replaces any previously parsed values. transformed[pair.Key] = pair.Value[pair.Value.Count - 1]; diff --git a/src/ImageSharp.Web/DependencyInjection/ServiceCollectionExtensions.cs b/src/ImageSharp.Web/DependencyInjection/ServiceCollectionExtensions.cs index 63786683..2d42ee52 100644 --- a/src/ImageSharp.Web/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/ImageSharp.Web/DependencyInjection/ServiceCollectionExtensions.cs @@ -60,6 +60,8 @@ private static void AddDefaultServices( builder.SetRequestParser(); + builder.Services.AddSingleton(); + builder.SetCache(); builder.SetCacheKey(); diff --git a/src/ImageSharp.Web/FormatUtilities.cs b/src/ImageSharp.Web/FormatUtilities.cs index a111c51d..2def7f71 100644 --- a/src/ImageSharp.Web/FormatUtilities.cs +++ b/src/ImageSharp.Web/FormatUtilities.cs @@ -34,10 +34,7 @@ public FormatUtilities(IOptions options) { string[] extensions = imageFormat.FileExtensions.ToArray(); - foreach (string extension in extensions) - { - this.extensions.Add(extension); - } + this.extensions.AddRange(extensions); this.extensionsByMimeType[imageFormat.DefaultMimeType] = extensions[0]; } diff --git a/src/ImageSharp.Web/ImageSharp.Web.csproj b/src/ImageSharp.Web/ImageSharp.Web.csproj index b3943393..21429278 100644 --- a/src/ImageSharp.Web/ImageSharp.Web.csproj +++ b/src/ImageSharp.Web/ImageSharp.Web.csproj @@ -36,6 +36,7 @@ + diff --git a/src/ImageSharp.Web/ImageSharpRequestAuthorizationUtilities.cs b/src/ImageSharp.Web/ImageSharpRequestAuthorizationUtilities.cs new file mode 100644 index 00000000..4e3940af --- /dev/null +++ b/src/ImageSharp.Web/ImageSharpRequestAuthorizationUtilities.cs @@ -0,0 +1,277 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +#if !NETCOREAPP3_0_OR_GREATER +using Microsoft.AspNetCore.Http.Internal; +#endif + +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using SixLabors.ImageSharp.Web.Commands; +using SixLabors.ImageSharp.Web.Middleware; +using SixLabors.ImageSharp.Web.Processors; + +namespace SixLabors.ImageSharp.Web +{ + /// + /// Contains various helper methods for authorizing image requests. + /// + public sealed class ImageSharpRequestAuthorizationUtilities + { + /// + /// The command used by image requests for transporting Hash-based Message Authentication Code (HMAC) tokens. + /// + public const string TokenCommand = HMACUtilities.TokenCommand; + private static readonly Uri FallbackBaseUri = new("http://localhost/"); + private readonly HashSet knownCommands; + private readonly ImageSharpMiddlewareOptions options; + private readonly CommandParser commandParser; + private readonly CultureInfo parserCulture; + private readonly IRequestParser requestParser; + private readonly IServiceProvider serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The middleware configuration options. + /// An instance used to parse image requests for commands. + /// A collection of instances used to process images. + /// The command parser. + /// The service provider. + public ImageSharpRequestAuthorizationUtilities( + IOptions options, + IRequestParser requestParser, + IEnumerable processors, + CommandParser commandParser, + IServiceProvider serviceProvider) + { + Guard.NotNull(options, nameof(options)); + Guard.NotNull(requestParser, nameof(requestParser)); + Guard.NotNull(processors, nameof(processors)); + Guard.NotNull(commandParser, nameof(commandParser)); + Guard.NotNull(serviceProvider, nameof(serviceProvider)); + + this.options = options.Value; + this.requestParser = requestParser; + this.commandParser = commandParser; + this.parserCulture = this.options.UseInvariantParsingCulture + ? CultureInfo.InvariantCulture + : CultureInfo.CurrentCulture; + this.serviceProvider = serviceProvider; + + HashSet commands = new(StringComparer.OrdinalIgnoreCase); + foreach (IImageWebProcessor processor in processors) + { + foreach (string command in processor.Commands) + { + commands.Add(command); + } + } + + this.knownCommands = commands; + } + + /// + /// Strips any unknown commands from the command collection. + /// + /// The unsanitized command collection. + public void StripUnknownCommands(CommandCollection commands) + { + if (commands?.Count > 0) + { + // Strip out any unknown commands, if needed. + var keys = new List(commands.Keys); + for (int i = keys.Count - 1; i >= 0; i--) + { + if (!this.knownCommands.Contains(keys[i])) + { + commands.RemoveAt(i); + } + } + } + } + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The uri to compute the code from. + /// The command collection handling. + /// The computed HMAC. + public string ComputeHMAC(string uri, CommandHandling handling) + => this.ComputeHMAC(new Uri(uri, UriKind.RelativeOrAbsolute), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The uri to compute the code from. + /// The command collection handling. + /// The computed HMAC. + public Task ComputeHMACAsync(string uri, CommandHandling handling) + => this.ComputeHMACAsync(new Uri(uri, UriKind.RelativeOrAbsolute), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The uri to compute the code from. + /// The command collection handling. + /// The computed HMAC. + public string ComputeHMAC(Uri uri, CommandHandling handling) + { + ToComponents( + uri, + out HostString host, + out PathString path, + out QueryString queryString); + + return this.ComputeHMAC(host, path, queryString, handling); + } + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The uri to compute the code from. + /// The command collection handling. + /// The computed HMAC. + public Task ComputeHMACAsync(Uri uri, CommandHandling handling) + { + ToComponents( + uri, + out HostString host, + out PathString path, + out QueryString queryString); + + return this.ComputeHMACAsync(host, path, queryString, handling); + } + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The host header. + /// The path or pathbase. + /// The querystring. + /// The command collection handling. + /// The computed HMAC. + public string ComputeHMAC(HostString host, PathString path, QueryString queryString, CommandHandling handling) + => this.ComputeHMAC(host, path, queryString, new(QueryHelpers.ParseQuery(queryString.Value)), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The host header. + /// The path or pathbase. + /// The querystring. + /// The command collection handling. + /// The computed HMAC. + public Task ComputeHMACAsync(HostString host, PathString path, QueryString queryString, CommandHandling handling) + => this.ComputeHMACAsync(host, path, queryString, new(QueryHelpers.ParseQuery(queryString.Value)), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The host header. + /// The path or pathbase. + /// The querystring. + /// The query collection. + /// The command collection handling. + /// The computed HMAC. + public string ComputeHMAC(HostString host, PathString path, QueryString queryString, QueryCollection query, CommandHandling handling) + => this.ComputeHMAC(this.ToHttpContext(host, path, queryString, query), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The host header. + /// The path or pathbase. + /// The querystring. + /// The query collection. + /// The command collection handling. + /// The computed HMAC. + public Task ComputeHMACAsync(HostString host, PathString path, QueryString queryString, QueryCollection query, CommandHandling handling) + => this.ComputeHMACAsync(this.ToHttpContext(host, path, queryString, query), handling); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The request HTTP context. + /// The command collection handling. + /// The computed HMAC. + public string ComputeHMAC(HttpContext context, CommandHandling handling) + => AsyncHelper.RunSync(() => this.ComputeHMACAsync(context, handling)); + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// The request HTTP context. + /// The command collection handling. + /// The computed HMAC. + public async Task ComputeHMACAsync(HttpContext context, CommandHandling handling) + { + byte[] secret = this.options.HMACSecretKey; + if (secret is null || secret.Length == 0) + { + return null; + } + + CommandCollection commands = this.requestParser.ParseRequestCommands(context); + if (handling == CommandHandling.Sanitize) + { + this.StripUnknownCommands(commands); + } + + ImageCommandContext imageCommandContext = new(context, commands, this.commandParser, this.parserCulture); + return await this.options.OnComputeHMACAsync(imageCommandContext, secret); + } + + /// + /// Compute a Hash-based Message Authentication Code (HMAC) for request authentication. + /// + /// + /// This method is only called by the middleware and only if required. + /// As such, standard checks are avoided. + /// + /// Contains information about the current image request and parsed commands. + /// The computed HMAC. + internal Task ComputeHMACAsync(ImageCommandContext context) + => this.options.OnComputeHMACAsync(context, this.options.HMACSecretKey); + + private static void ToComponents( + Uri uri, + out HostString host, + out PathString path, + out QueryString queryString) + { + if (uri.IsAbsoluteUri) + { + host = HostString.FromUriComponent(uri); + path = PathString.FromUriComponent(uri); + queryString = QueryString.FromUriComponent(uri); + } + else + { + Uri faux = new(FallbackBaseUri, uri); + host = default; + path = PathString.FromUriComponent(faux); + queryString = QueryString.FromUriComponent(faux); + } + } + + private HttpContext ToHttpContext(HostString host, PathString path, QueryString queryString, QueryCollection query) + { + DefaultHttpContext context = new() { RequestServices = this.serviceProvider }; + HttpRequest request = context.Request; + request.Method = HttpMethods.Get; + request.Host = host; + request.Path = path; + request.QueryString = queryString; + request.Query = query; + + return context; + } + } +} diff --git a/src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs b/src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs index f22d34f7..eee45855 100644 --- a/src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs +++ b/src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs @@ -92,11 +92,6 @@ private static readonly ConcurrentTLruCache HMACTokenLru /// private readonly ICacheHash cacheHash; - /// - /// The collection of known commands gathered from the processors. - /// - private readonly HashSet knownCommands; - /// /// Contains various helper methods based on the current configuration. /// @@ -117,6 +112,11 @@ private static readonly ConcurrentTLruCache HMACTokenLru /// private readonly AsyncKeyReaderWriterLock asyncKeyLock; + /// + /// Contains helpers that allow authorization of image requests. + /// + private readonly ImageSharpRequestAuthorizationUtilities authorizationUtilities; + /// /// Initializes a new instance of the class. /// @@ -129,9 +129,10 @@ private static readonly ConcurrentTLruCache HMACTokenLru /// An instance used for caching images. /// An instance used for creating cache keys. /// An instance used for calculating cached file names. - /// The command parser + /// The command parser. /// Contains various format helper methods based on the current configuration. - /// The async key lock + /// The async key lock. + /// Contains helpers that allow authorization of image requests. public ImageSharpMiddleware( RequestDelegate next, IOptions options, @@ -144,7 +145,8 @@ public ImageSharpMiddleware( ICacheHash cacheHash, CommandParser commandParser, FormatUtilities formatUtilities, - AsyncKeyReaderWriterLock asyncKeyLock) + AsyncKeyReaderWriterLock asyncKeyLock, + ImageSharpRequestAuthorizationUtilities requestAuthorizationUtilities) { Guard.NotNull(next, nameof(next)); Guard.NotNull(options, nameof(options)); @@ -158,6 +160,7 @@ public ImageSharpMiddleware( Guard.NotNull(commandParser, nameof(commandParser)); Guard.NotNull(formatUtilities, nameof(formatUtilities)); Guard.NotNull(asyncKeyLock, nameof(asyncKeyLock)); + Guard.NotNull(requestAuthorizationUtilities, nameof(requestAuthorizationUtilities)); this.next = next; this.options = options.Value; @@ -172,20 +175,10 @@ public ImageSharpMiddleware( ? CultureInfo.InvariantCulture : CultureInfo.CurrentCulture; - var commands = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (IImageWebProcessor processor in this.processors) - { - foreach (string command in processor.Commands) - { - commands.Add(command); - } - } - - this.knownCommands = commands; - this.logger = loggerFactory.CreateLogger(); this.formatUtilities = formatUtilities; this.asyncKeyLock = asyncKeyLock; + this.authorizationUtilities = requestAuthorizationUtilities; } /// @@ -197,31 +190,6 @@ public ImageSharpMiddleware( private async Task Invoke(HttpContext httpContext, bool retry) { - CommandCollection commands = this.requestParser.ParseRequestCommands(httpContext); - - // First check for a HMAC token and capture before the command is stripped out. - byte[] secret = this.options.HMACSecretKey; - bool checkHMAC = false; - string token = null; - if (secret?.Length > 0) - { - checkHMAC = true; - token = commands.GetValueOrDefault(HMACUtilities.TokenCommand); - } - - if (commands.Count > 0) - { - // Strip out any unknown commands, if needed. - var keys = new List(commands.Keys); - for (int i = keys.Count - 1; i >= 0; i--) - { - if (!this.knownCommands.Contains(keys[i])) - { - commands.RemoveAt(i); - } - } - } - // Get the correct provider for the request IImageProvider provider = null; foreach (IImageProvider resolver in this.providers) @@ -240,6 +208,19 @@ private async Task Invoke(HttpContext httpContext, bool retry) return; } + CommandCollection commands = this.requestParser.ParseRequestCommands(httpContext); + + // First check for a HMAC token and capture before the command is stripped out. + byte[] secret = this.options.HMACSecretKey; + bool checkHMAC = false; + string token = null; + if (secret?.Length > 0) + { + checkHMAC = true; + token = commands.GetValueOrDefault(ImageSharpRequestAuthorizationUtilities.TokenCommand); + } + + this.authorizationUtilities.StripUnknownCommands(commands); ImageCommandContext imageCommandContext = new(httpContext, commands, this.commandParser, this.parserCulture); // At this point we know that this is an image request so should attempt to compute a validating HMAC. @@ -253,7 +234,9 @@ private async Task Invoke(HttpContext httpContext, bool retry) // // As a rule all image requests should contain valid commands only. // Key generation uses string.Create under the hood with very low allocation so should be good enough as a cache key. - hmac = await HMACTokenLru.GetOrAddAsync(httpContext.Request.GetEncodedUrl(), _ => this.options.OnComputeHMACAsync(imageCommandContext, secret)); + hmac = await HMACTokenLru.GetOrAddAsync( + httpContext.Request.GetEncodedUrl(), + _ => this.authorizationUtilities.ComputeHMACAsync(imageCommandContext)); } await this.options.OnParseCommandsAsync.Invoke(imageCommandContext); diff --git a/src/ImageSharp.Web/PathUtilities.cs b/src/ImageSharp.Web/PathUtilities.cs index d6a3579a..717c423a 100644 --- a/src/ImageSharp.Web/PathUtilities.cs +++ b/src/ImageSharp.Web/PathUtilities.cs @@ -1,7 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System; using System.IO; namespace SixLabors.ImageSharp.Web @@ -25,16 +24,5 @@ internal static string EnsureTrailingSlash(string path) return path; } - - /// - /// Determines whether the is located underneath the specified . - /// - /// The fully qualified path to test. - /// The root path (needs to end with a directory separator). - /// - /// true if the path is located underneath the specified root path; otherwise, false. - /// - internal static bool IsUnderneathRoot(string path, string rootPath) - => path.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase); } } diff --git a/tests/ImageSharp.Web.Tests/Helpers/CaseHandlingUriBuilderTests.cs b/tests/ImageSharp.Web.Tests/Helpers/CaseHandlingUriBuilderTests.cs new file mode 100644 index 00000000..0e13a7e6 --- /dev/null +++ b/tests/ImageSharp.Web.Tests/Helpers/CaseHandlingUriBuilderTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using Xunit; + +namespace SixLabors.ImageSharp.Web.Tests.Helpers +{ + public class CaseHandlingUriBuilderTests + { + [Theory] + [InlineData(CaseHandlingUriBuilder.CaseHandling.None, "https://sixlabors.com:443/Path/?query=1")] + [InlineData(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, "https://sixlabors.com:443/path/?query=1")] + public void CanEncodeAbsoluteUri(CaseHandlingUriBuilder.CaseHandling value, string expected) + { + const string input = "https://sixlabors.com/Path/?Query=1"; + string actual = CaseHandlingUriBuilder.Encode(value, input); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(CaseHandlingUriBuilder.CaseHandling.None, "/Path/?query=1")] + [InlineData(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, "/path/?query=1")] + public void CanEncodeRelativeUri(CaseHandlingUriBuilder.CaseHandling value, string expected) + { + const string input = "/Path/?Query=1"; + string actual = CaseHandlingUriBuilder.Encode(value, input); + Assert.Equal(expected, actual); + } + } +} diff --git a/tests/ImageSharp.Web.Tests/TestUtilities/AuthenticatedServerTestBase.cs b/tests/ImageSharp.Web.Tests/TestUtilities/AuthenticatedServerTestBase.cs index 68c75edf..2759aa94 100644 --- a/tests/ImageSharp.Web.Tests/TestUtilities/AuthenticatedServerTestBase.cs +++ b/tests/ImageSharp.Web.Tests/TestUtilities/AuthenticatedServerTestBase.cs @@ -1,10 +1,10 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System; using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Xunit; using Xunit.Abstractions; @@ -13,9 +13,16 @@ namespace SixLabors.ImageSharp.Web.Tests.TestUtilities public abstract class AuthenticatedServerTestBase : ServerTestBase where TFixture : AuthenticatedTestServerFixture { + private readonly ImageSharpRequestAuthorizationUtilities authorizationUtilities; + private readonly string relativeImageSouce; + protected AuthenticatedServerTestBase(TFixture fixture, ITestOutputHelper outputHelper, string imageSource) : base(fixture, outputHelper, imageSource) { + this.authorizationUtilities = + this.Fixture.Services.GetRequiredService(); + + this.relativeImageSouce = this.ImageSource.Replace("http://localhost", string.Empty); } [Fact] @@ -30,20 +37,26 @@ public async Task CanRejectUnauthorizedRequestAsync() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); // Now send an invalid token - response = await this.HttpClient.GetAsync(url + this.Fixture.Commands[0] + "&" + HMACUtilities.TokenCommand + "=INVALID"); + response = await this.HttpClient.GetAsync(url + this.Fixture.Commands[0] + "&" + ImageSharpRequestAuthorizationUtilities.TokenCommand + "=INVALID"); Assert.NotNull(response); Assert.False(response.IsSuccessStatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - protected override string AugmentCommand(string command) + protected override async Task AugmentCommandAsync(string command) + { + string uri = this.relativeImageSouce + command; + string token = await this.GetTokenAsync(uri); + return command + "&" + ImageSharpRequestAuthorizationUtilities.TokenCommand + "=" + token; + } + + private async Task GetTokenAsync(string uri) { - // Mimic the lowecase relative url format used by the token and default options. - string uri = (this.ImageSource + command).Replace("http://localhost", string.Empty); - uri = CaseHandlingUriBuilder.Encode(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, uri); + string tokenSync = this.authorizationUtilities.ComputeHMAC(uri, CommandHandling.Sanitize); + string tokenAsync = await this.authorizationUtilities.ComputeHMACAsync(uri, CommandHandling.Sanitize); - string token = HMACUtilities.ComputeHMACSHA256(uri, AuthenticatedTestServerFixture.HMACSecretKey); - return command + "&" + HMACUtilities.TokenCommand + "=" + token; + Assert.Equal(tokenSync, tokenAsync); + return tokenSync; } } } diff --git a/tests/ImageSharp.Web.Tests/TestUtilities/ServerTestBase.cs b/tests/ImageSharp.Web.Tests/TestUtilities/ServerTestBase.cs index 40c3a3b7..7545304f 100644 --- a/tests/ImageSharp.Web.Tests/TestUtilities/ServerTestBase.cs +++ b/tests/ImageSharp.Web.Tests/TestUtilities/ServerTestBase.cs @@ -51,7 +51,7 @@ public async Task CanProcessAndResolveImageAsync() IImageFormat format = Configuration.Default.ImageFormatsManager.FindFormatByFileExtension(ext); // First response - HttpResponseMessage response = await this.HttpClient.GetAsync(url + this.AugmentCommand(this.Fixture.Commands[0])); + HttpResponseMessage response = await this.HttpClient.GetAsync(url + await this.AugmentCommandAsync(this.Fixture.Commands[0])); Assert.NotNull(response); Assert.True(response.IsSuccessStatusCode); @@ -67,7 +67,7 @@ public async Task CanProcessAndResolveImageAsync() response.Dispose(); // Cached Response - response = await this.HttpClient.GetAsync(url + this.AugmentCommand(this.Fixture.Commands[0])); + response = await this.HttpClient.GetAsync(url + await this.AugmentCommandAsync(this.Fixture.Commands[0])); Assert.NotNull(response); Assert.True(response.IsSuccessStatusCode); @@ -85,7 +85,7 @@ public async Task CanProcessAndResolveImageAsync() // 304 response var request = new HttpRequestMessage { - RequestUri = new Uri(url + this.AugmentCommand(this.Fixture.Commands[0])), + RequestUri = new Uri(url + await this.AugmentCommandAsync(this.Fixture.Commands[0])), Method = HttpMethod.Get, }; @@ -108,7 +108,7 @@ public async Task CanProcessAndResolveImageAsync() // 412 response request = new HttpRequestMessage { - RequestUri = new Uri(url + this.AugmentCommand(this.Fixture.Commands[0])), + RequestUri = new Uri(url + await this.AugmentCommandAsync(this.Fixture.Commands[0])), Method = HttpMethod.Get, }; @@ -127,8 +127,8 @@ public async Task CanProcessAndResolveImageAsync() public async Task CanProcessMultipleIdenticalQueriesAsync() { string url = this.ImageSource; - string command1 = this.AugmentCommand(this.Fixture.Commands[0]); - string command2 = this.AugmentCommand(this.Fixture.Commands[1]); + string command1 = await this.AugmentCommandAsync(this.Fixture.Commands[0]); + string command2 = await this.AugmentCommandAsync(this.Fixture.Commands[1]); Task[] tasks = Enumerable.Range(0, 100).Select(i => Task.Run(async () => { @@ -144,6 +144,6 @@ public async Task CanProcessMultipleIdenticalQueriesAsync() Assert.True(all.IsCompletedSuccessfully); } - protected virtual string AugmentCommand(string command) => command; + protected virtual Task AugmentCommandAsync(string command) => Task.FromResult(command); } }