Skip to content

Commit 72b9368

Browse files
Merge pull request #250 from SixLabors/js/hmac
Add HMAC authentication options.
2 parents 3626616 + 3fde177 commit 72b9368

17 files changed

Lines changed: 406 additions & 68 deletions

src/ImageSharp.Web/Caching/SHA256CacheHash.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,16 @@ public SHA256CacheHash(IOptions<ImageSharpMiddlewareOptions> options)
3333
public string Create(string value, uint length)
3434
{
3535
int byteCount = Encoding.ASCII.GetByteCount(value);
36-
37-
// Allocating a buffer from the pool is ~27% slower than stackalloc so use that for short strings
38-
if (byteCount < 257)
39-
{
40-
return HashValue(value, length, stackalloc byte[byteCount]);
41-
}
42-
4336
byte[] buffer = null;
37+
4438
try
4539
{
46-
buffer = ArrayPool<byte>.Shared.Rent(byteCount);
47-
return HashValue(value, length, buffer.AsSpan(0, byteCount));
40+
// Allocating a buffer from the pool is ~27% slower than stackalloc so use that for short strings
41+
Span<byte> bytes = byteCount <= 128
42+
? stackalloc byte[byteCount]
43+
: (buffer = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
44+
45+
return HashValue(value, length, bytes);
4846
}
4947
finally
5048
{

src/ImageSharp.Web/Caching/UriAbsoluteCacheKey.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ public class UriAbsoluteCacheKey : ICacheKey
1313
{
1414
/// <inheritdoc/>
1515
public string Create(HttpContext context, CommandCollection commands)
16-
=> CacheKeyHelper.BuildAbsoluteKey(CacheKeyHelper.CaseHandling.None, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
16+
=> CaseHandlingUriBuilder.BuildAbsolute(CaseHandlingUriBuilder.CaseHandling.None, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
1717
}
1818
}

src/ImageSharp.Web/Caching/UriAbsoluteLowerInvariantCacheKey.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ public class UriAbsoluteLowerInvariantCacheKey : ICacheKey
1313
{
1414
/// <inheritdoc/>
1515
public string Create(HttpContext context, CommandCollection commands)
16-
=> CacheKeyHelper.BuildAbsoluteKey(CacheKeyHelper.CaseHandling.LowerInvariant, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
16+
=> CaseHandlingUriBuilder.BuildAbsolute(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, context.Request.Host, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
1717
}
1818
}

src/ImageSharp.Web/Caching/UriRelativeCacheKey.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ public class UriRelativeCacheKey : ICacheKey
1313
{
1414
/// <inheritdoc/>
1515
public string Create(HttpContext context, CommandCollection commands)
16-
=> CacheKeyHelper.BuildRelativeKey(CacheKeyHelper.CaseHandling.None, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
16+
=> CaseHandlingUriBuilder.BuildRelative(CaseHandlingUriBuilder.CaseHandling.None, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
1717
}
1818
}

src/ImageSharp.Web/Caching/UriRelativeLowerInvariantCacheKey.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ public class UriRelativeLowerInvariantCacheKey : ICacheKey
1313
{
1414
/// <inheritdoc/>
1515
public string Create(HttpContext context, CommandCollection commands)
16-
=> CacheKeyHelper.BuildRelativeKey(CacheKeyHelper.CaseHandling.LowerInvariant, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
16+
=> CaseHandlingUriBuilder.BuildRelative(CaseHandlingUriBuilder.CaseHandling.LowerInvariant, context.Request.PathBase, context.Request.Path, QueryString.Create(commands));
1717
}
1818
}

src/ImageSharp.Web/Caching/CacheKeyHelper.cs renamed to src/ImageSharp.Web/CaseHandlingUriBuilder.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,29 @@
66
using System.Runtime.CompilerServices;
77
using Microsoft.AspNetCore.Http;
88

9-
namespace SixLabors.ImageSharp.Web.Caching
9+
namespace SixLabors.ImageSharp.Web
1010
{
1111
/// <summary>
12-
/// Optimized helper methods for generating cache keys from URI components. Much of this code has been adapted from the MIT licensed .NET runtime.
12+
/// Optimized helper methods for generating encoded Uris from URI components.
13+
/// Much of this code has been adapted from the MIT licensed .NET runtime.
1314
/// </summary>
14-
internal static class CacheKeyHelper
15+
public static class CaseHandlingUriBuilder
1516
{
1617
private static readonly SpanAction<char, (bool LowerInvariant, string Host, string PathBase, string Path, string Query)> InitializeAbsoluteUriStringSpanAction = new(InitializeAbsoluteUriString);
1718

19+
/// <summary>
20+
/// Provides Uri case handling options.
21+
/// </summary>
1822
public enum CaseHandling
1923
{
24+
/// <summary>
25+
/// No adjustments to casing are made.
26+
/// </summary>
2027
None,
28+
29+
/// <summary>
30+
/// All URI components are converted to lower case using the invariant culture before combining.
31+
/// </summary>
2132
LowerInvariant
2233
}
2334

@@ -29,14 +40,14 @@ public enum CaseHandling
2940
/// <param name="path">The portion of the request path that identifies the requested resource.</param>
3041
/// <param name="query">The query, if any.</param>
3142
/// <returns>The combined URI components, properly encoded for use in HTTP headers.</returns>
32-
public static string BuildRelativeKey(
43+
public static string BuildRelative(
3344
CaseHandling handling,
3445
PathString pathBase = default,
3546
PathString path = default,
3647
QueryString query = default)
3748

3849
// Take any potential performance hit vs concatination for code reading sanity.
39-
=> BuildAbsoluteKey(handling, default, pathBase, path, query);
50+
=> BuildAbsolute(handling, default, pathBase, path, query);
4051

4152
/// <summary>
4253
/// Combines the given URI components into a string that is properly encoded for use in HTTP headers.
@@ -48,7 +59,7 @@ public static string BuildRelativeKey(
4859
/// <param name="path">The portion of the request path that identifies the requested resource.</param>
4960
/// <param name="query">The query, if any.</param>
5061
/// <returns>The combined URI components, properly encoded for use in HTTP headers.</returns>
51-
public static string BuildAbsoluteKey(
62+
public static string BuildAbsolute(
5263
CaseHandling handling,
5364
HostString host,
5465
PathString pathBase = default,
@@ -113,7 +124,7 @@ private static int CopyTextToBufferLowerInvariant(Span<char> buffer, int index,
113124
=> index + text.ToLowerInvariant(buffer.Slice(index, text.Length));
114125

115126
/// <summary>
116-
/// Initializes the URI <see cref="string"/> for <see cref="BuildAbsoluteKey(CaseHandling, HostString, PathString, PathString, QueryString)"/>.
127+
/// Initializes the URI <see cref="string"/> for <see cref="BuildAbsolute(CaseHandling, HostString, PathString, PathString, QueryString)"/>.
117128
/// </summary>
118129
/// <param name="buffer">The URI <see cref="string"/>'s <see cref="char"/> buffer.</param>
119130
/// <param name="uriParts">The URI parts.</param>

src/ImageSharp.Web/Commands/Converters/SimpleCommandConverter{T}.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace SixLabors.ImageSharp.Web.Commands.Converters
1111
/// The generic converter for simple types that implement <see cref="IConvertible"/>.
1212
/// </summary>
1313
/// <typeparam name="T">The type of object to convert to.</typeparam>
14-
internal sealed class SimpleCommandConverter<T> : ICommandConverter<T>
14+
public sealed class SimpleCommandConverter<T> : ICommandConverter<T>
1515
where T : IConvertible
1616
{
1717
/// <inheritdoc/>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
using System;
5+
using System.Buffers;
6+
using System.Runtime.CompilerServices;
7+
using System.Security.Cryptography;
8+
using System.Text;
9+
using SixLabors.ImageSharp.Web.Caching;
10+
11+
namespace SixLabors.ImageSharp.Web
12+
{
13+
/// <summary>
14+
/// Provides methods to compute a Hash-based Message Authentication Code (HMAC).
15+
/// </summary>
16+
public static class HMACUtilities
17+
{
18+
/// <summary>
19+
/// The command used by image requests for transporting Hash-based Message Authentication Code (HMAC) tokens.
20+
/// </summary>
21+
public const string TokenCommand = "hmac";
22+
23+
/// <summary>
24+
/// Computes a Hash-based Message Authentication Code (HMAC) by using the SHA256 hash function.
25+
/// </summary>
26+
/// <param name="value">The value to hash</param>
27+
/// <param name="secret">
28+
/// The secret key for <see cref="HMACSHA256"/> encryption.
29+
/// The key can be any length. However, the recommended size is 64 bytes.
30+
/// </param>
31+
/// <returns>The hashed <see cref="string"/>.</returns>
32+
public static unsafe string ComputeHMACSHA256(string value, byte[] secret)
33+
{
34+
// TODO: In .NET 6 we can use single instance versions
35+
using var hmac = new HMACSHA256(secret);
36+
return CreateHMAC(value, hmac);
37+
}
38+
39+
/// <summary>
40+
/// Computes a Hash-based Message Authentication Code (HMAC) by using the SHA384 hash function.
41+
/// </summary>
42+
/// <param name="value">The value to hash</param>
43+
/// <param name="secret">
44+
/// The secret key for <see cref="HMACSHA256"/> encryption.
45+
/// The key can be any length. However, the recommended size is 128 bytes.
46+
/// </param>
47+
/// <returns>The hashed <see cref="string"/>.</returns>
48+
public static unsafe string ComputeHMACSHA384(string value, byte[] secret)
49+
{
50+
using var hmac = new HMACSHA384(secret);
51+
return CreateHMAC(value, hmac);
52+
}
53+
54+
/// <summary>
55+
/// Computes a Hash-based Message Authentication Code (HMAC) by using the SHA512 hash function.
56+
/// </summary>
57+
/// <param name="value">The value to hash</param>
58+
/// <param name="secret">
59+
/// The secret key for <see cref="HMACSHA256"/> encryption.
60+
/// The key can be any length. However, the recommended size is 128 bytes.
61+
/// </param>
62+
/// <returns>The hashed <see cref="string"/>.</returns>
63+
public static unsafe string ComputeHMACSHA512(string value, byte[] secret)
64+
{
65+
using var hmac = new HMACSHA512(secret);
66+
return CreateHMAC(value, hmac);
67+
}
68+
69+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
70+
private static unsafe string CreateHMAC(string value, HMAC hmac)
71+
{
72+
int byteCount = Encoding.ASCII.GetByteCount(value);
73+
byte[] buffer = null;
74+
75+
try
76+
{
77+
// Allocating a buffer from the pool is ~27% slower than stackalloc so use that for short strings
78+
Span<byte> bytes = byteCount <= 128
79+
? stackalloc byte[byteCount]
80+
: (buffer = ArrayPool<byte>.Shared.Rent(byteCount)).AsSpan(0, byteCount);
81+
82+
Encoding.ASCII.GetBytes(value, bytes);
83+
84+
// Safe to always stackalloc here. We max out at 64 bytes.
85+
Span<byte> hash = stackalloc byte[hmac.HashSize / 8];
86+
hmac.TryComputeHash(bytes, hash, out int _);
87+
88+
// Finally encode the hash to make it web safe.
89+
return HexEncoder.Encode(hash);
90+
}
91+
finally
92+
{
93+
if (buffer is not null)
94+
{
95+
ArrayPool<byte>.Shared.Return(buffer);
96+
}
97+
}
98+
}
99+
}
100+
}

src/ImageSharp.Web/Middleware/ImageSharpMiddleware.cs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ private static readonly ConcurrentTLruCache<string, ImageMetadata> SourceMetadat
4141
private static readonly ConcurrentTLruCache<string, (IImageCacheResolver, ImageCacheMetadata)> CacheResolverLru
4242
= new(1024, TimeSpan.FromSeconds(30));
4343

44+
/// <summary>
45+
/// Used to temporarily store cached HMAC-s to reduce the overhead of HMAC token generation.
46+
/// </summary>
47+
private static readonly ConcurrentTLruCache<string, string> HMACTokenLru
48+
= new(1024, TimeSpan.FromSeconds(30));
49+
4450
/// <summary>
4551
/// The function processing the Http request.
4652
/// </summary>
@@ -191,9 +197,18 @@ public ImageSharpMiddleware(
191197

192198
private async Task Invoke(HttpContext httpContext, bool retry)
193199
{
194-
// We expect to get concrete collection type which removes virtual dispatch concerns and enumerator allocations
195200
CommandCollection commands = this.requestParser.ParseRequestCommands(httpContext);
196201

202+
// First check for a HMAC token and capture before the command is stripped out.
203+
byte[] secret = this.options.HMACSecretKey;
204+
bool checkHMAC = false;
205+
string token = null;
206+
if (secret?.Length > 0)
207+
{
208+
checkHMAC = true;
209+
token = commands.GetValueOrDefault(HMACUtilities.TokenCommand);
210+
}
211+
197212
if (commands.Count > 0)
198213
{
199214
// Strip out any unknown commands, if needed.
@@ -203,18 +218,15 @@ private async Task Invoke(HttpContext httpContext, bool retry)
203218
if (!this.knownCommands.Contains(command))
204219
{
205220
// Need to actually remove, allocates new list to allow modifications
206-
this.StripUnknownCommands(commands, startAtIndex: index);
221+
this.StripUnknownCommands(commands, index);
207222
break;
208223
}
209224

210225
index++;
211226
}
212227
}
213228

214-
await this.options.OnParseCommandsAsync.Invoke(
215-
new ImageCommandContext(httpContext, commands, this.commandParser, this.parserCulture));
216-
217-
// Get the correct service for the request
229+
// Get the correct provider for the request
218230
IImageProvider provider = null;
219231
foreach (IImageProvider resolver in this.providers)
220232
{
@@ -225,14 +237,48 @@ await this.options.OnParseCommandsAsync.Invoke(
225237
}
226238
}
227239

228-
if ((commands.Count == 0 && provider?.ProcessingBehavior != ProcessingBehavior.All)
229-
|| provider?.IsValidRequest(httpContext) != true)
240+
if (provider?.IsValidRequest(httpContext) != true)
241+
{
242+
// Nothing to do. call the next delegate/middleware in the pipeline
243+
await this.next(httpContext);
244+
return;
245+
}
246+
247+
ImageCommandContext imageCommandContext = new(httpContext, commands, this.commandParser, this.parserCulture);
248+
249+
// At this point we know that this is an image request so should attempt to compute a validating HMAC..
250+
string hmac = null;
251+
if (checkHMAC && token != null)
252+
{
253+
// Generate and cache a HMAC to validate against based upon the current valid commands from the request.
254+
//
255+
// If the command collection differs following the stripping of invalid commands prior to this point then this will mean
256+
// the token will not match our validating HMAC, however, this would be indicative of an attack and should be treated as such.
257+
//
258+
// As a rule all image requests should contain valid commands only.
259+
hmac = await HMACTokenLru.GetOrAddAsync(token, _ => this.options.OnComputeHMACAsync(imageCommandContext, secret));
260+
}
261+
262+
await this.options.OnParseCommandsAsync.Invoke(imageCommandContext);
263+
264+
if (commands.Count == 0 && provider?.ProcessingBehavior != ProcessingBehavior.All)
230265
{
231266
// Nothing to do. call the next delegate/middleware in the pipeline
232267
await this.next(httpContext);
233268
return;
234269
}
235270

271+
// At this point we know that this is an image request designed for processing via this middleware.
272+
// Check for a token if required and reject if invalid.
273+
if (checkHMAC)
274+
{
275+
if (token == null || hmac != token)
276+
{
277+
SetBadRequest(httpContext);
278+
return;
279+
}
280+
}
281+
236282
IImageResolver sourceImageResolver = await provider.GetAsync(httpContext);
237283

238284
if (sourceImageResolver is null)
@@ -252,6 +298,14 @@ await this.ProcessRequestAsync(
252298
retry);
253299
}
254300

301+
private static void SetBadRequest(HttpContext httpContext)
302+
{
303+
// We return a 400 rather than a 401 as we do not want to prompt follow up requests.
304+
// We don't log the error to avoid attempts at log poisoning.
305+
httpContext.Response.Clear();
306+
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
307+
}
308+
255309
private void StripUnknownCommands(CommandCollection commands, int startAtIndex)
256310
{
257311
var keys = new List<string>(commands.Keys);

0 commit comments

Comments
 (0)