Compare commits

...
Author SHA1 Message Date
Andy Butland 102a422aac Code tidy. 2026-05-22 18:49:16 +02:00
Andy ButlandandClaude Opus 4.7 c8c9026f12 Imaging: Cap the RTE image token cache with size limit and sliding expiration
Replaces the unbounded ConcurrentDictionary with a dedicated MemoryCache
instance (SizeLimit = 10k entries, 24h sliding expiration). Caps the in-process
token cache at around 5 MB and sheds entries for media that's been deleted or
crop variants no longer in use. LRU evicts on overflow.

Addresses a review comment on #22962.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:47:02 +02:00
Andy Butland b5015bd14f Re-sign rich text image URLs on render to survive HMAC key rotation 2026-05-22 18:07:45 +02:00
12 changed files with 630 additions and 44 deletions
@@ -0,0 +1,174 @@
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Commands;
using SixLabors.ImageSharp.Web.Middleware;
using Umbraco.Cms.Core.Media;
namespace Umbraco.Cms.Imaging.ImageSharp.Media;
/// <summary>
/// ImageSharp-backed <see cref="IImageUrlTokenGenerator"/> that re-signs image URLs using the
/// configured HMAC secret key.
/// </summary>
/// <remarks>
/// <para>
/// Computed tokens are cached in-process keyed by the canonical, sanitised URL (path + only the
/// query parameters that contribute to the HMAC). Cache-buster parameters such as <c>v</c> or
/// <c>rnd</c> are stripped from the cache key so they do not bloat memory when used per-request.
/// The cache is bounded with an entry cap and sliding expiration so stale entries (e.g. for
/// deleted media or unused crop variants) cannot accumulate indefinitely.
/// </para>
/// <para>
/// The secret key is captured at construction via <see cref="IOptions{TOptions}"/>, mirroring
/// ImageSharp.Web's own <c>RequestAuthorizationUtilities</c>. Changing the key at runtime
/// requires an application restart.
/// </para>
/// </remarks>
public sealed class ImageSharpImageUrlTokenGenerator : IImageUrlTokenGenerator, IDisposable
{
private readonly RequestAuthorizationUtilities? _requestAuthorizationUtilities;
private readonly ImageSharpMiddlewareOptions _options;
private readonly MemoryCache _tokenCache;
/// <summary>
/// Initializes a new instance of the <see cref="ImageSharpImageUrlTokenGenerator"/> class.
/// </summary>
/// <param name="requestAuthorizationUtilities">The ImageSharp request authorization utilities used to compute HMAC tokens.</param>
/// <param name="options">The ImageSharp middleware options containing the HMAC secret key.</param>
public ImageSharpImageUrlTokenGenerator(
RequestAuthorizationUtilities? requestAuthorizationUtilities,
IOptions<ImageSharpMiddlewareOptions> options)
{
_requestAuthorizationUtilities = requestAuthorizationUtilities;
_options = options.Value;
// 10k entries × ~500 bytes per CacheEntry (canonical URL ~250 chars, 64-char token,
// dictionary overhead) caps the in-process token cache around 5 MB.
_tokenCache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 10_000,
});
}
/// <inheritdoc />
public string RefreshSignature(string url)
{
if (string.IsNullOrEmpty(url) || _requestAuthorizationUtilities is null || _options.HMACSecretKey.Length == 0)
{
return url;
}
SplitUrl split = SplitAndDecodeQuery(url);
Dictionary<string, StringValues> outputParams = split.DecodedQuery.Length == 0
? []
: QueryHelpers.ParseQuery(split.DecodedQuery);
// Strip any previously persisted HMAC token; we are about to compute a fresh one.
outputParams.Remove(RequestAuthorizationUtilities.TokenCommand);
// Build a sanitised command collection - only parameters recognised by an
// IImageWebProcessor - and use that as the cache key. This collapses different cache-buster
// values (which don't affect the HMAC) onto the same entry.
var canonicalCommands = new CommandCollection();
foreach (KeyValuePair<string, StringValues> kvp in outputParams)
{
canonicalCommands.Add(kvp.Key, kvp.Value.ToString());
}
_requestAuthorizationUtilities.StripUnknownCommands(canonicalCommands);
var canonicalUrl = BuildUrl(split.Path, canonicalCommands);
// Sign over the already-sanitised URL; no need for ImageSharp.Web to sanitise again.
var token = _tokenCache.GetOrCreate(canonicalUrl, entry =>
{
entry.Size = 1;
entry.SlidingExpiration = TimeSpan.FromHours(24);
return ComputeToken(canonicalUrl);
}) ?? string.Empty;
if (string.IsNullOrEmpty(token) is false)
{
outputParams[RequestAuthorizationUtilities.TokenCommand] = token;
}
return BuildOutputUrl(split.Path, outputParams, split.HadEntityEncoding);
}
/// <inheritdoc />
public void Dispose() => _tokenCache.Dispose();
/// <summary>
/// Splits a URL into its path and a parsable query, normalising HTML-entity-encoded
/// ampersands in the query portion.
/// </summary>
/// <remarks>
/// Rich text editors store URLs with <c>&amp;amp;</c> as the query separator so the
/// surrounding markup is valid HTML. The query must be decoded before it can be parsed
/// as a standard query string; the returned <see cref="SplitUrl.HadEntityEncoding"/>
/// flag tells the caller to re-encode the output the same way. The path is returned
/// unchanged.
/// </remarks>
private static SplitUrl SplitAndDecodeQuery(string url)
{
var questionIndex = url.IndexOf('?');
var pathPart = questionIndex < 0 ? url : url[..questionIndex];
var rawQuery = questionIndex < 0 ? string.Empty : url[(questionIndex + 1)..];
var hadEntityEncoding = rawQuery.Contains("&amp;", StringComparison.Ordinal);
if (hadEntityEncoding)
{
rawQuery = rawQuery.Replace("&amp;", "&");
}
return new SplitUrl(pathPart, rawQuery, hadEntityEncoding);
}
private string ComputeToken(string canonicalUrl)
=> _requestAuthorizationUtilities!.ComputeHMAC(canonicalUrl, CommandHandling.None) ?? string.Empty;
private sealed record SplitUrl(string Path, string DecodedQuery, bool HadEntityEncoding);
private static string BuildUrl(string pathPart, CommandCollection commands)
{
if (commands.Keys.Any() is false)
{
return pathPart;
}
var dict = new Dictionary<string, string?>();
foreach (var key in commands.Keys)
{
dict[key] = commands[key];
}
return QueryHelpers.AddQueryString(pathPart, dict);
}
private static string BuildOutputUrl(string pathPart, Dictionary<string, StringValues> queryParams, bool entityEncodeSeparators)
{
if (queryParams.Count == 0)
{
return pathPart;
}
var dict = new Dictionary<string, string?>(queryParams.Count);
foreach (KeyValuePair<string, StringValues> kvp in queryParams)
{
dict[kvp.Key] = kvp.Value.ToString();
}
var withQuery = QueryHelpers.AddQueryString(pathPart, dict);
if (entityEncodeSeparators is false)
{
return withQuery;
}
// Only encode the query portion - the path part is left untouched even if it contains '&'.
var qStart = pathPart.Length + 1;
return withQuery[..qStart] + withQuery[qStart..].Replace("&", "&amp;");
}
}
@@ -32,6 +32,10 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IImageUrlGenerator, ImageSharpImageUrlGenerator>();
// Replaces the no-op IImageUrlTokenGenerator registered in Core; allows rich text render
// paths to re-sign image URLs against the current HMACSecretKey after a key rotation.
builder.Services.AddSingleton<IImageUrlTokenGenerator, ImageSharpImageUrlTokenGenerator>();
builder.Services.AddImageSharp()
// Replace default image provider
.ClearProviders()
@@ -24,6 +24,7 @@ using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Mail;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Packaging;
@@ -220,6 +221,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddSingleton<HtmlImageSourceParser>();
Services.AddSingleton<HtmlUrlParser>();
// Default no-op signer. The ImageSharp 3+ package replaces this with a real implementation
// that re-signs URLs using the current HMACSecretKey; ImageSharp 2 leaves the no-op in
// place (it has no HMAC support).
Services.AddSingleton<IImageUrlTokenGenerator, NoopImageUrlTokenGenerator>();
// register properties fallback
Services.AddUnique<IPublishedValueFallback, PublishedValueFallback>();
@@ -0,0 +1,23 @@
namespace Umbraco.Cms.Core.Media;
/// <summary>
/// Refreshes the HMAC signature on a generated image URL using the secret key
/// that is currently configured on the imaging middleware.
/// </summary>
/// <remarks>
/// Rich text editors persist image URLs (with the HMAC token baked in) into stored markup.
/// When the secret key is rotated, the persisted token no longer validates and the image
/// fails to render. Render-time pipelines call <see cref="RefreshSignature"/> to strip any
/// stale token from the URL and re-sign with the current key.
/// </remarks>
public interface IImageUrlTokenGenerator
{
/// <summary>
/// Strips any existing HMAC token from <paramref name="url"/> and returns the URL
/// signed with the currently configured secret key. If no key is configured, or
/// the input is empty, the URL is returned unchanged.
/// </summary>
/// <param name="url">The image URL, optionally containing a stale HMAC token in its query string.</param>
/// <returns>The image URL with a freshly computed HMAC token (or unchanged if no key is configured).</returns>
string RefreshSignature(string url);
}
@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Core.Media;
/// <summary>
/// Default <see cref="IImageUrlTokenGenerator"/> implementation that returns URLs unchanged.
/// </summary>
/// <remarks>
/// Registered as the fallback in Core so that consumers can inject <see cref="IImageUrlTokenGenerator"/>
/// unconditionally. The ImageSharp 3+ package replaces this registration with a real signer; ImageSharp 2
/// (which has no HMAC support) and any custom imaging package leave the no-op in place.
/// </remarks>
internal sealed class NoopImageUrlTokenGenerator : IImageUrlTokenGenerator
{
/// <inheritdoc />
public string RefreshSignature(string url) => url;
}
@@ -1,4 +1,7 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Routing;
using Umbraco.Extensions;
@@ -7,21 +10,16 @@ namespace Umbraco.Cms.Core.Templates;
/// <summary>
/// Utility class used to parse and update image sources in HTML content based on Umbraco media references.
/// </summary>
public sealed class HtmlImageSourceParser
public sealed partial class HtmlImageSourceParser
{
private static readonly Regex ResolveImgPattern = new(
@"<img[^>]*(data-udi=""([^""]*)"")[^>]*>",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private static readonly Regex _resolveImgRegex = ResolveImgRegex();
private static readonly Regex SrcAttributeRegex = new(
@"src=""([^""\?]*)(\?[^""]*)?""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private static readonly Regex _srcAttributeRegex = SrcAttributeRegex();
private static readonly Regex DataUdiAttributeRegex = new(
@"data-udi=\\?(?:""|')(?<udi>umb://[A-z0-9\-]+/[A-z0-9]+)\\?(?:""|')",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly Regex _dataUdiAttributeRegex = DataUdiAttributeRegex();
private readonly IPublishedUrlProvider? _publishedUrlProvider;
private readonly IImageUrlTokenGenerator _imageUrlTokenGenerator;
private Func<Guid, string?>? _getMediaUrl;
@@ -29,23 +27,53 @@ public sealed class HtmlImageSourceParser
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
/// </summary>
/// <param name="getMediaUrl">A function that retrieves the media URL for a given GUID.</param>
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl) => _getMediaUrl = getMediaUrl;
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl, IImageUrlTokenGenerator imageUrlTokenGenerator)
{
_getMediaUrl = getMediaUrl;
_imageUrlTokenGenerator = imageUrlTokenGenerator;
}
/// <summary>
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
/// </summary>
/// <param name="publishedUrlProvider">The published URL provider for resolving media URLs.</param>
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider) =>
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider, IImageUrlTokenGenerator imageUrlTokenGenerator)
{
_publishedUrlProvider = publishedUrlProvider;
_imageUrlTokenGenerator = imageUrlTokenGenerator;
}
/// <summary>
/// Parses out media UDIs from an html string based on 'data-udi' html attributes
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
/// <param name="getMediaUrl">A function that retrieves the media URL for a given GUID.</param>
[Obsolete("Please use the constructor that accepts IImageUrlTokenGenerator. Scheduled for removal in Umbraco 19.")]
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl)
: this(getMediaUrl, StaticServiceProvider.Instance.GetRequiredService<IImageUrlTokenGenerator>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
/// </summary>
/// <param name="publishedUrlProvider">The published URL provider for resolving media URLs.</param>
[Obsolete("Please use the constructor that accepts IImageUrlTokenGenerator. Scheduled for removal in Umbraco 19.")]
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider)
: this(publishedUrlProvider, StaticServiceProvider.Instance.GetRequiredService<IImageUrlTokenGenerator>())
{
}
/// <summary>
/// Parses media UDIs out of an HTML string by reading <c>data-udi</c> attributes on
/// <c>&lt;a&gt;</c> and <c>&lt;img&gt;</c> tags.
/// </summary>
/// <param name="text">The HTML text to scan.</param>
/// <returns>The parseable UDIs found on <c>data-udi</c> attributes, in document order.</returns>
public IEnumerable<Udi> FindUdisFromDataAttributes(string text)
{
MatchCollection matches = DataUdiAttributeRegex.Matches(text);
MatchCollection matches = _dataUdiAttributeRegex.Matches(text);
if (matches.Count == 0)
{
yield break;
@@ -61,19 +89,21 @@ public sealed class HtmlImageSourceParser
}
/// <summary>
/// Parses the string looking for Umbraco image tags and updates them to their up-to-date image sources.
/// Refreshes the <c>src</c> attribute of every Umbraco <c>&lt;img&gt;</c> tag in the supplied HTML
/// so the rendered URL points at the current media path and carries an up-to-date HMAC signature.
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
/// <remarks>Umbraco image tags are identified by their data-udi attributes</remarks>
/// <param name="text">The HTML text to process.</param>
/// <returns>
/// The HTML with each <c>&lt;img data-udi="..."&gt;</c> rewritten: the path is replaced with the
/// current media URL (preserving the persisted query string), and any HMAC token in the query
/// is re-signed against the currently configured secret key. Other tags are returned unchanged.
/// </returns>
/// <remarks>Umbraco image tags are identified by their <c>data-udi</c> attributes.</remarks>
public string EnsureImageSources(string text)
{
if (_getMediaUrl == null)
{
_getMediaUrl = guid => _publishedUrlProvider?.GetMediaUrl(guid);
}
_getMediaUrl ??= guid => _publishedUrlProvider?.GetMediaUrl(guid);
return ResolveImgPattern.Replace(text, match =>
return _resolveImgRegex.Replace(text, match =>
{
// match groups:
// - 1 = the data-udi attribute
@@ -88,7 +118,7 @@ public sealed class HtmlImageSourceParser
// src match groups:
// - 1 = the src attribute value until the query string
// - 2 = the src attribute query string including the '?'
Match src = SrcAttributeRegex.Match(match.Value);
Match src = _srcAttributeRegex.Match(match.Value);
if (src.Success == false)
{
@@ -104,29 +134,44 @@ public sealed class HtmlImageSourceParser
return match.Value;
}
var newImgTag = match.Value.Replace(src.Value, $"src=\"{mediaUrl}{src.Groups[2].Value}\"");
// Re-sign the URL so a rotated HMAC secret key doesn't break previously-authored images.
// No-op when HMAC isn't configured.
var refreshedSrc = _imageUrlTokenGenerator.RefreshSignature($"{mediaUrl}{src.Groups[2].Value}");
return newImgTag;
return match.Value.Replace(src.Value, $"src=\"{refreshedSrc}\"");
});
}
/// <summary>
/// Removes media URLs from &lt;img&gt; tags where a data-udi attribute is present
/// Clears the media path from the <c>src</c> attribute of every <c>&lt;img&gt;</c> tag that has a
/// <c>data-udi</c> attribute, preserving any query string the URL carried.
/// </summary>
/// <param name="text"></param>
/// <returns></returns>
/// <param name="text">The HTML text to process.</param>
/// <returns>
/// The HTML with the path portion of each Umbraco-managed image <c>src</c> emptied (the query
/// string, if present, is left in place). Tags without a <c>data-udi</c> attribute are unchanged.
/// </returns>
public string RemoveImageSources(string text)
// find each ResolveImgPattern match in the text, then find each
// SrcAttributeRegex match in the match value, then replace the src
// attribute value with an empty string
// (see comment in ResolveMediaFromTextString for group reference)
=> ResolveImgPattern.Replace(text, match =>
=> _resolveImgRegex.Replace(text, match =>
{
// Find the src attribute
Match src = SrcAttributeRegex.Match(match.Value);
Match src = _srcAttributeRegex.Match(match.Value);
return src.Success == false || string.IsNullOrWhiteSpace(src.Groups[1].Value) ?
match.Value : match.Value.Replace(src.Groups[1].Value, string.Empty);
});
[GeneratedRegex(@"<img[^>]*(data-udi=""([^""]*)"")[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace, "en-GB")]
private static partial Regex ResolveImgRegex();
[GeneratedRegex(@"src=""([^""\?]*)(\?[^""]*)?""", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace, "en-GB")]
private static partial Regex SrcAttributeRegex();
[GeneratedRegex(@"data-udi=\\?(?:""|')(?<udi>umb://[A-z0-9\-]+/[A-z0-9]+)\\?(?:""|')", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)]
private static partial Regex DataUdiAttributeRegex();
}
@@ -1,7 +1,7 @@
using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Extensions;
@@ -12,26 +12,30 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
{
private readonly IPublishedContentCache _publishedContentCache;
private readonly IPublishedMediaCache _publishedMediaCache;
private readonly IImageUrlTokenGenerator _imageUrlTokenGenerator;
private readonly ILogger<ApiRichTextMarkupParser> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.DeliveryApi.ApiRichTextMarkupParser"/> class.
/// Initializes a new instance of the <see cref="ApiRichTextMarkupParser"/> class.
/// </summary>
/// <param name="apiContentRouteBuilder">The <see cref="IApiContentRouteBuilder"/> used to build API content routes.</param>
/// <param name="mediaUrlProvider">The <see cref="IApiMediaUrlProvider"/> used to provide media URLs for the API.</param>
/// <param name="publishedContentCache">The <see cref="IPublishedContentCache"/> for accessing published content.</param>
/// <param name="publishedMediaCache">The <see cref="IPublishedMediaCache"/> for accessing published media.</param>
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
/// <param name="logger">The <see cref="ILogger{ApiRichTextMarkupParser}"/> instance for logging.</param>
public ApiRichTextMarkupParser(
IApiContentRouteBuilder apiContentRouteBuilder,
IApiMediaUrlProvider mediaUrlProvider,
IPublishedContentCache publishedContentCache,
IPublishedMediaCache publishedMediaCache,
IImageUrlTokenGenerator imageUrlTokenGenerator,
ILogger<ApiRichTextMarkupParser> logger)
: base(apiContentRouteBuilder, mediaUrlProvider)
{
_publishedContentCache = publishedContentCache;
_publishedMediaCache = publishedMediaCache;
_imageUrlTokenGenerator = imageUrlTokenGenerator;
_logger = logger;
}
@@ -120,7 +124,10 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
? $"?{currentImageSource.Split('?').Last()}"
: null;
image.SetAttributeValue("src", $"{mediaUrl}{currentImageQueryString}");
// Re-sign the URL so a rotated HMAC secret key doesn't break previously-authored images.
// No-op when HMAC isn't configured.
var refreshedSrc = _imageUrlTokenGenerator.RefreshSignature($"{mediaUrl}{currentImageQueryString}");
image.SetAttributeValue("src", refreshedSrc);
image.Attributes.Remove("data-udi");
// we don't want the "data-caption" attribute, it's already part of the output as <figcaption>
@@ -3,6 +3,7 @@ using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
@@ -714,6 +715,7 @@ public class RichTextParserTests : PropertyValueConverterTests
urlProvider,
cacheManager.Content,
cacheManager.Media,
new NoopImageUrlTokenGenerator(),
Mock.Of<ILogger<ApiRichTextMarkupParser>>());
}
@@ -1,6 +1,7 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
@@ -162,7 +163,7 @@ internal class RichTextAllowedMediaTypeValidatorTests
var mediaTypeServiceMock = new Mock<IMediaTypeService>();
var validator = new RichTextAllowedMediaTypeValidator(
new HtmlImageSourceParser(_ => string.Empty),
new HtmlImageSourceParser(_ => string.Empty, new NoopImageUrlTokenGenerator()),
mediaServiceMock.Object,
Mock.Of<ILocalizedTextService>(),
new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()),
@@ -2,15 +2,14 @@
// See LICENSE for more details.
using System.Diagnostics;
using System.Linq;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.Templates;
@@ -22,6 +21,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Templates;
[TestFixture]
public class HtmlImageSourceParserTests
{
private static IImageUrlTokenGenerator NoopSigner() => new NoopImageUrlTokenGenerator();
[Test]
public void Returns_Udis_From_Data_Udi_Html_Attributes()
{
@@ -31,7 +32,7 @@ public class HtmlImageSourceParserTests
</div>
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" /></p>";
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>());
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>(), NoopSigner());
var result = imageSourceParser.FindUdisFromDataAttributes(input).ToList();
Assert.AreEqual(2, result.Count);
@@ -70,7 +71,7 @@ public class HtmlImageSourceParserTests
[Category("Remove image sources")]
public string Remove_Image_Sources(string sourceHtml)
{
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>());
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>(), NoopSigner());
var actual = imageSourceParser.RemoveImageSources(sourceHtml);
@@ -120,7 +121,7 @@ public class HtmlImageSourceParserTests
var mediaCache = Mock.Get(reference.UmbracoContext.Media);
mediaCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(media.Object);
var imageSourceParser = new HtmlImageSourceParser(publishedUrlProvider);
var imageSourceParser = new HtmlImageSourceParser(publishedUrlProvider, NoopSigner());
var result = imageSourceParser.EnsureImageSources(@"<p>
<div>
@@ -197,7 +198,7 @@ public class HtmlImageSourceParserTests
public string Ensure_ImageSources_Processing(string sourceHtml)
{
var fakeMediaUrl = "/media/1001/image.jpg";
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl);
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, NoopSigner());
var actual = parser.EnsureImageSources(sourceHtml);
return actual;
@@ -214,7 +215,7 @@ public class HtmlImageSourceParserTests
var text = $@"<img src=""{longText}"" />";
var fakeMediaUrl = "/media/1001/image.jpg";
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl);
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, NoopSigner());
var timer = new Stopwatch();
timer.Start();
@@ -223,4 +224,44 @@ public class HtmlImageSourceParserTests
Assert.IsTrue(timer.ElapsedMilliseconds <= maxMsToRun);
}
[Test]
public void EnsureImageSources_Calls_TokenGenerator_With_Refreshed_Url()
{
// Verifies the signer is invoked on the final URL (path from media provider + persisted query string)
// and that its return value replaces the src attribute.
var fakeMediaUrl = "/media/1001/image.jpg";
var signerMock = new Mock<IImageUrlTokenGenerator>();
signerMock
.Setup(s => s.RefreshSignature(It.IsAny<string>()))
.Returns<string>(url => url + "&hmac=fresh");
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, signerMock.Object);
var input =
@"<img src=""old/path.jpg?width=100&hmac=stale"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>";
var result = parser.EnsureImageSources(input);
signerMock.Verify(
s => s.RefreshSignature("/media/1001/image.jpg?width=100&hmac=stale"),
Times.Once);
StringAssert.Contains("src=\"/media/1001/image.jpg?width=100&hmac=stale&hmac=fresh\"", result);
}
[Test]
public void EnsureImageSources_Noop_Signer_Leaves_Src_Unchanged()
{
var fakeMediaUrl = "/media/1001/image.jpg";
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, new NoopImageUrlTokenGenerator());
var input =
@"<img src=""x?width=100"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>";
var result = parser.EnsureImageSources(input);
Assert.AreEqual(
@"<img src=""/media/1001/image.jpg?width=100"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>",
result);
}
}
@@ -3,6 +3,7 @@ using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
@@ -16,6 +17,7 @@ public class ApiRichTextMarkupParserTests
{
private Mock<IApiContentRouteBuilder> _apiContentRouteBuilder;
private Mock<IApiMediaUrlProvider> _apiMediaUrlProvider;
private Mock<IImageUrlTokenGenerator> _imageUrlTokenGenerator;
[Test]
public void Can_Parse_Legacy_LocalLinks()
@@ -178,6 +180,41 @@ public class ApiRichTextMarkupParserTests
Assert.AreEqual(expectedOutput, parsedHtml);
}
[Test]
public void LocalImages_Are_ReSigned_Via_TokenGenerator()
{
// Verifies the token generator is given the rebuilt src (provider URL + persisted query string)
// and that its return value replaces the src attribute. The signer is responsible for handling
// any HTML-entity encoding in the query string (RTE markup typically uses &amp;).
var key1 = Guid.Parse("395bdc0e8f4d4ad4af7f3a3f6265651e");
var data1 = new MockData()
.WithKey(key1)
.WithMediaUrl("https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp");
var mockData = new Dictionary<Guid, MockData>
{
{ key1, data1 },
};
var parser = BuildDefaultSut(mockData);
// override the no-op behaviour set up in BuildDefaultSut to verify the call shape
_imageUrlTokenGenerator.Reset();
_imageUrlTokenGenerator
.Setup(g => g.RefreshSignature(It.IsAny<string>()))
.Returns<string>(_ => "FRESH_URL");
var html =
@"<p><img src=""/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?width=500&amp;hmac=stale"" data-udi=""umb://media/395bdc0e8f4d4ad4af7f3a3f6265651e""></p>";
var parsedHtml = parser.Parse(html);
// The signer receives the raw attribute value as returned by HtmlAgilityPack (entities not decoded).
_imageUrlTokenGenerator.Verify(
g => g.RefreshSignature("https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?width=500&amp;hmac=stale"),
Times.Once);
StringAssert.Contains(@"src=""FRESH_URL""", parsedHtml);
}
private ApiRichTextMarkupParser BuildDefaultSut(Dictionary<Guid, MockData> mockData)
{
var contentCacheMock = new Mock<IPublishedContentCache>();
@@ -201,11 +238,16 @@ public class ApiRichTextMarkupParserTests
_apiContentRouteBuilder.Setup(acrb => acrb.Build(It.IsAny<IPublishedContent>(), It.IsAny<string>()))
.Returns<IPublishedContent, string>((content, culture) => mockData[content.Key].ApiContentRoute);
_imageUrlTokenGenerator = new Mock<IImageUrlTokenGenerator>();
_imageUrlTokenGenerator.Setup(g => g.RefreshSignature(It.IsAny<string>()))
.Returns<string>(url => url);
return new ApiRichTextMarkupParser(
_apiContentRouteBuilder.Object,
_apiMediaUrlProvider.Object,
contentCacheMock.Object,
mediaCacheMock.Object,
_imageUrlTokenGenerator.Object,
Mock.Of<ILogger<ApiRichTextMarkupParser>>());
}
@@ -0,0 +1,226 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NUnit.Framework;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Commands;
using SixLabors.ImageSharp.Web.Commands.Converters;
using SixLabors.ImageSharp.Web.Middleware;
using SixLabors.ImageSharp.Web.Processors;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Imaging.ImageSharp.Media;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Media;
[TestFixture]
public class ImageSharpImageUrlTokenGeneratorTests
{
private const string MediaPath = "/media/1001/img.jpg";
private static readonly byte[] _keyBytes =
{
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
};
[Test]
public void Returns_Url_Unchanged_When_No_Secret_Configured()
{
var options = new ImageSharpMiddlewareOptions(); // empty HMACSecretKey
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
const string url = MediaPath + "?width=400&height=400";
Assert.AreEqual(url, generator.RefreshSignature(url));
}
[Test]
public void Returns_Url_Unchanged_When_RequestAuthorization_Is_Null()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var generator = new ImageSharpImageUrlTokenGenerator(null, Options.Create(options));
const string url = MediaPath + "?width=400&height=400";
Assert.AreEqual(url, generator.RefreshSignature(url));
}
[Test]
public void Returns_Empty_Input_Unchanged()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
Assert.AreEqual(string.Empty, generator.RefreshSignature(string.Empty));
}
[Test]
public void Appends_Token_When_None_Present()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var result = generator.RefreshSignature(MediaPath + "?width=400&height=400");
StringAssert.StartsWith(MediaPath + "?width=400&height=400&hmac=", result);
}
[Test]
public void Replaces_Existing_Stale_Token()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var freshFromScratch = generator.RefreshSignature(MediaPath + "?width=400&height=400");
var refreshedFromStale = generator.RefreshSignature(MediaPath + "?width=400&height=400&hmac=deadbeefdeadbeef");
Assert.AreEqual(freshFromScratch, refreshedFromStale);
Assert.AreEqual(1, CountOccurrences(refreshedFromStale, "hmac="));
}
[Test]
public void Produces_Same_Token_As_ImageSharpImageUrlGenerator()
{
// Round-trip: a URL produced by the editor-side generator must round-trip through the signer unchanged.
// Pins down that both call sites canonicalise/sign the URL the same way.
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var editorGenerator = new ImageSharpImageUrlGenerator(
Array.Empty<string>(),
Options.Create(options),
requestAuthorization);
var tokenGenerator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var signedByEditor = editorGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
Width = 400,
Height = 400,
});
Assert.IsNotNull(signedByEditor);
Assert.AreEqual(signedByEditor, tokenGenerator.RefreshSignature(signedByEditor!));
}
[Test]
public void Path_With_No_Recognised_Commands_Returns_Unsigned()
{
// Matches the editor-side behaviour in ImageSharpImageUrlGenerator: when ImageSharp.Web has
// no recognised commands to sign, ComputeHMAC returns empty and no token is attached.
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
Assert.AreEqual(MediaPath, generator.RefreshSignature(MediaPath));
}
[Test]
public void Preserves_HtmlEntity_Encoded_Ampersands()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var result = generator.RefreshSignature(MediaPath + "?width=400&amp;height=400&amp;hmac=stale");
StringAssert.StartsWith(MediaPath + "?width=400&amp;height=400&amp;hmac=", result);
StringAssert.DoesNotContain("hmac=stale", result);
Assert.AreEqual(0, CountOccurrences(result, "&hmac="), "ampersands must remain entity-encoded");
Assert.AreEqual(1, CountOccurrences(result, "&amp;hmac="), "exactly one fresh token, &amp;-encoded");
}
[Test]
public void Does_Not_Encode_Ampersands_In_The_Path()
{
// The path is not part of the query and any '&' in it must not be encoded on output.
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
// The input uses &amp; in the query (triggers entity-preservation), but the path contains a literal '&'.
const string pathWithAmpersand = "/media/foo&bar/img.jpg";
var result = generator.RefreshSignature(pathWithAmpersand + "?width=400&amp;height=400");
StringAssert.StartsWith(pathWithAmpersand + "?width=400&amp;height=400&amp;hmac=", result);
}
[Test]
public void Different_Cache_Busters_Do_Not_Bloat_Cache()
{
// Cache-busters ('v', 'rnd') don't contribute to the HMAC (they're stripped by Sanitize).
// Two URLs differing only in their cache-buster should produce the same token. Same token
// implies the cache key collapsed both inputs onto a single entry.
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var firstResult = generator.RefreshSignature(MediaPath + "?width=400&v=20240101");
var secondResult = generator.RefreshSignature(MediaPath + "?width=400&v=20251231");
var firstToken = TokenFrom(firstResult);
var secondToken = TokenFrom(secondResult);
Assert.IsNotEmpty(firstToken);
Assert.AreEqual(firstToken, secondToken);
// Each output URL retains its own cache buster.
StringAssert.Contains("v=20240101", firstResult);
StringAssert.Contains("v=20251231", secondResult);
}
[Test]
public void Subsequent_Calls_Are_Deterministic()
{
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
var requestAuthorization = BuildRequestAuthorization(options);
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
var first = generator.RefreshSignature(MediaPath + "?width=400");
var second = generator.RefreshSignature(MediaPath + "?width=400");
Assert.AreEqual(first, second);
}
private static RequestAuthorizationUtilities BuildRequestAuthorization(ImageSharpMiddlewareOptions options)
=> new(
Options.Create(options),
new QueryCollectionRequestParser(),
[new ResizeWebProcessor()],
new CommandParser(Enumerable.Empty<ICommandConverter>()),
new ServiceCollection().BuildServiceProvider());
private static int CountOccurrences(string haystack, string needle)
{
var count = 0;
var idx = 0;
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
{
count++;
idx += needle.Length;
}
return count;
}
private static string TokenFrom(string signedUrl)
{
const string marker = "hmac=";
var idx = signedUrl.LastIndexOf(marker, StringComparison.Ordinal);
return idx < 0 ? string.Empty : signedUrl[(idx + marker.Length)..];
}
}