Handling SixLabors.ImageSharp vulnerability warnings in Optimizely CMS 12 (unofficial)
UPDATED: Optimizely is officially working with SixLabors to find a solution, if possible; the work ID is CMS-57231. An official decision/announcement might come soon.
Written by Claude Opus 5.5 (Anthropic) on behalf of Cuong Nguyen, Optimizely Technical Support.
A note from Cuong: Thanks to Optimizely for giving me access to Claude, which made this kind of deep dive possible within a support ticket.
SixLabors.ImageSharp is one of the most popular image libraries in the .NET world. Chances are it's in your solution too, even if you never added it yourself. Then one morning you open the project, run a build like any other day, and the output is suddenly full of NU1902/NU1903 warnings. Nothing changed in your code. If TreatWarningsAsErrors is on, the build simply fails.
That's the story behind a lot of tickets we've been getting in Optimizely Support lately. This post covers where the dependency comes from, why "just upgrade ImageSharp" doesn't work, and the four options you have today, including a SkiaSharp-based IImageService sample.
1. What you'll see
$ dotnet list package --include-transitive --vulnerable
Transitive Package Resolved Severity Advisory URL
> SixLabors.ImageSharp 3.1.11 Moderate https://github.com/advisories/GHSA-gwg2-r3hj-4w44
High https://github.com/advisories/GHSA-j3p4-wp97-rph4
High https://github.com/advisories/GHSA-j9gm-c75j-xc9q
Moderate https://github.com/advisories/GHSA-wmxv-xphr-5c9g
High https://github.com/advisories/GHSA-jjfr-hcj7-qf5w
You'll also get NU1902/NU1903 during restore and build. With TreatWarningsAsErrors enabled, the build fails.
2. Where the dependency comes from
The CMS uses IImageService for thumbnails and the image editor. The default implementation lives in EPiServer.ImageLibrary.ImageSharp, which depends on SixLabors.ImageSharp:
EPiServer.CMS 12.34.x
└── EPiServer.ImageLibrary.ImageSharp (>= 1.0.6) ← NuGet picks the lowest matching version
└── SixLabors.ImageSharp 2.1.11
NuGet resolves dependencies with the lowest applicable version rule (docs). So EPiServer.CMS 12.34.8 resolves EPiServer.ImageLibrary.ImageSharp 1.0.6, which brings in ImageSharp 2.1.11. This is standard NuGet behavior, not something specific to Optimizely packages.
To get a newer version, reference the CMS components directly instead of the EPiServer.CMS metapackage, and pin EPiServer.ImageLibrary.ImageSharp to 2.0.6:
<PackageReference Include="EPiServer.CMS.UI" Version="12.34.6" />
<PackageReference Include="EPiServer.Hosting" Version="12.24.1" />
<!-- ... -->
<PackageReference Include="EPiServer.ImageLibrary.ImageSharp" Version="2.0.6" />
That moves you to ImageSharp 3.1.11, which clears the older advisories but not the five listed above.
3. Why not force a newer ImageSharp?
The advisories above are fixed in ImageSharp 4.x. Six Labors changed the licensing model in 4.x, and Release builds require a license key (discussion). Forcing 4.x through Directory.Packages.props therefore gives you a build error unless you have a Six Labors license. The 3.1.x line, the last one usable without a license, still carries the advisories.
4. Your options
| # | Option | Pros | Cons |
|---|---|---|---|
| 1 | Remove EPiServer.ImageLibrary.ImageSharp | No code, warnings disappear | No image processing: thumbnails and the image editor stop working |
| 2 | Provide your own IImageService, e.g. with SkiaSharp | Clean audit, features kept | Custom code that your team owns and maintains |
| 3 | Suppress the audit warnings | Quick | The vulnerable package stays in your solution |
| 4 | Wait for an updated EPiServer.ImageLibrary.ImageSharp | No custom code | No committed timeline today |
On risk: the image service only processes media that authenticated editors upload through the CMS. It doesn't handle arbitrary anonymous input. That lowers the exposure, and editors validating images on upload lowers it further. Whether that's acceptable is a decision for your own security policy and threat model.
5. Option 2: a custom IImageService with SkiaSharp
IImageService is a public extension point with a single method:
public interface IImageService
{
void RenderImage(Stream input, Stream output, RenderRequest request);
}
The default implementation maps each ImageEditorCommand like this, and a replacement should do the same:
| ImageEditorCommand | Default (ImageSharp) | SkiaSharp equivalent |
|---|---|---|
| Grayscale | Grayscale() (BT.709) | SKColorFilter.CreateColorMatrix with BT.709 coefficients |
| Crop | Crop(rect) | DrawBitmap(src, -left, -top) |
| FlipX / FlipY | Flip(Horizontal/Vertical) | canvas.Scale(-1, 1, cx, 0) / Scale(1, -1, 0, cy) |
| Resize | ResizeMode.Stretch | DrawBitmap(src, destRect, Mitchell) |
| ResizeKeepScale | ResizeMode.BoxPad + BackgroundColor | fit the box, no upscaling, center, Clear(bg) |
| Rotate | Rotate(deg), canvas expands | Translate + RotateDegrees on an expanded canvas |
Packages
Remove EPiServer.ImageLibrary.ImageSharp and add SkiaSharp. If you reference the EPiServer.CMS metapackage, switch to individual component references first (see section 2). The metapackage brings EPiServer.ImageLibrary.ImageSharp in transitively, so you can't remove it otherwise.
<!-- remove: <PackageReference Include="EPiServer.ImageLibrary.ImageSharp" Version="..." /> -->
<PackageReference Include="SkiaSharp" Version="4.153.1" />
<!-- Required on Linux, containers and DXP: -->
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
The service
ResizeKeepScale matters most, because thumbnail generation calls it with BackgroundColor = "#00000000". The full implementation:
using EPiServer.ImageLibrary;
using SkiaSharp;
namespace YourSite.Business.Imaging;
/// <summary>
/// SkiaSharp-based replacement for EPiServer.ImageLibrary.ImageSharp's DefaultImageService,
/// which pulls in vulnerable SixLabors.ImageSharp versions. Mirrors the default behavior:
/// same commands, ResizeKeepScale = ImageSharp "BoxPad" + background color, ZoomFactor applied last.
/// </summary>
/// <remarks>
/// Skia only encodes PNG, JPEG and WebP. GIF/BMP/TIFF output is not supported (NotSupportedException),
/// and animated GIF input is flattened to its first frame.
/// </remarks>
public class SkiaImageService : IImageService
{
private static readonly SKSamplingOptions ResizeSampling = new(SKCubicResampler.Mitchell);
private static readonly SKSamplingOptions RotateSampling = new(SKFilterMode.Linear);
private static readonly SKSamplingOptions CopySampling = new(SKFilterMode.Nearest);
// ITU-R BT.709 luma, same coefficients as ImageSharp's Grayscale().
private static readonly float[] GrayscaleMatrix =
[
0.2126f, 0.7152f, 0.0722f, 0, 0,
0.2126f, 0.7152f, 0.0722f, 0, 0,
0.2126f, 0.7152f, 0.0722f, 0, 0,
0, 0, 0, 1, 0,
];
public void RenderImage(Stream input, Stream output, RenderRequest request)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(request);
var image = Decode(input);
try
{
foreach (var operation in request.Operations ?? [])
{
image = Replace(image, Apply(image, operation));
}
if (request.ZoomFactor != 1f)
{
var width = (int)Math.Floor(image.Width * request.ZoomFactor);
var height = (int)Math.Floor(image.Height * request.ZoomFactor);
image = Replace(image, Resize(image, width, height));
}
Encode(image, output, request);
}
finally
{
image.Dispose();
}
}
private static SKBitmap Decode(Stream input)
{
using var codec = SKCodec.Create(input)
?? throw new NotSupportedException("The source image format is not supported.");
var info = codec.Info
.WithColorType(SKImageInfo.PlatformColorType)
.WithAlphaType(SKAlphaType.Premul);
return SKBitmap.Decode(codec, info)
?? throw new NotSupportedException("The source image could not be decoded.");
}
private static SKBitmap Replace(SKBitmap current, SKBitmap next)
{
current.Dispose();
return next;
}
private static SKBitmap Apply(SKBitmap source, ImageOperation operation) => operation.Command switch
{
ImageEditorCommand.Grayscale => Grayscale(source),
ImageEditorCommand.Crop => Crop(source, operation),
ImageEditorCommand.FlipX => Draw(source.Width, source.Height, canvas =>
{
canvas.Scale(-1, 1, source.Width / 2f, 0);
canvas.DrawBitmap(source, 0, 0, CopySampling);
}),
ImageEditorCommand.FlipY => Draw(source.Width, source.Height, canvas =>
{
canvas.Scale(1, -1, 0, source.Height / 2f);
canvas.DrawBitmap(source, 0, 0, CopySampling);
}),
ImageEditorCommand.Resize => Resize(source, operation.Width, operation.Height),
ImageEditorCommand.ResizeKeepScale => ResizeKeepScale(source, operation),
ImageEditorCommand.Rotate => Rotate(source, operation.Angle),
_ => throw new InvalidOperationException($"Unknown command {operation.Command}"),
};
private static SKBitmap Draw(int width, int height, Action<SKCanvas> draw)
{
var bitmap = new SKBitmap(width, height, SKImageInfo.PlatformColorType, SKAlphaType.Premul);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Transparent);
draw(canvas);
return bitmap;
}
private static SKBitmap Grayscale(SKBitmap source)
{
using var filter = SKColorFilter.CreateColorMatrix(GrayscaleMatrix);
using var paint = new SKPaint { ColorFilter = filter };
return Draw(source.Width, source.Height, canvas => canvas.DrawBitmap(source, 0, 0, CopySampling, paint));
}
private static SKBitmap Crop(SKBitmap source, ImageOperation operation)
{
var area = SKRectI.Create(operation.Left, operation.Top, operation.Width, operation.Height);
if (area.IsEmpty || !SKRectI.Create(source.Width, source.Height).Contains(area))
{
throw new ArgumentOutOfRangeException(nameof(operation), $"Crop area {area} is outside the image bounds.");
}
return Draw(area.Width, area.Height, canvas => canvas.DrawBitmap(source, -area.Left, -area.Top, CopySampling));
}
private static SKBitmap Resize(SKBitmap source, int width, int height)
{
var size = TargetSize(source, width, height);
return Draw(size.Width, size.Height, canvas =>
canvas.DrawBitmap(source, SKRect.Create(size.Width, size.Height), ResizeSampling));
}
/// <summary>ImageSharp BoxPad: fit inside the box without upscaling, center, fill the rest with the background color.</summary>
private static SKBitmap ResizeKeepScale(SKBitmap source, ImageOperation operation)
{
var box = TargetSize(source, operation.Width, operation.Height);
var scale = Math.Min(1f, Math.Min(box.Width / (float)source.Width, box.Height / (float)source.Height));
var width = Math.Max(1, (int)MathF.Round(source.Width * scale));
var height = Math.Max(1, (int)MathF.Round(source.Height * scale));
var destination = SKRect.Create((box.Width - width) / 2, (box.Height - height) / 2, width, height);
var background = ParseColor(operation.BackgroundColor);
return Draw(box.Width, box.Height, canvas =>
{
canvas.Clear(background);
canvas.DrawBitmap(source, destination, ResizeSampling);
});
}
private static SKBitmap Rotate(SKBitmap source, double degrees)
{
var radians = degrees * Math.PI / 180;
var cos = Math.Abs(Math.Cos(radians));
var sin = Math.Abs(Math.Sin(radians));
// Round before Ceiling so right angles don't grow by a pixel from floating point noise (50.0000001 -> 51).
var width = (int)Math.Ceiling(Math.Round(source.Width * cos + source.Height * sin, 3));
var height = (int)Math.Ceiling(Math.Round(source.Width * sin + source.Height * cos, 3));
return Draw(width, height, canvas =>
{
canvas.Translate(width / 2f, height / 2f);
canvas.RotateDegrees((float)degrees);
canvas.DrawBitmap(source, -source.Width / 2f, -source.Height / 2f, RotateSampling);
});
}
/// <summary>Same as ImageSharp: a 0 dimension is derived from the other one, keeping the aspect ratio.</summary>
private static SKSizeI TargetSize(SKBitmap source, int width, int height)
{
if (width == 0 && height > 0)
{
width = Math.Max(1, (int)MathF.Round(source.Width * height / (float)source.Height));
}
if (height == 0 && width > 0)
{
height = Math.Max(1, (int)MathF.Round(source.Height * width / (float)source.Width));
}
if (width <= 0 || height <= 0)
{
throw new ArgumentOutOfRangeException(nameof(width), $"Invalid target size {width}x{height}.");
}
return new SKSizeI(width, height);
}
/// <summary>Parses ImageSharp-style hex colors (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). Empty means white.</summary>
private static SKColor ParseColor(string value)
{
if (string.IsNullOrEmpty(value))
{
return SKColors.White;
}
var hex = value.TrimStart('#');
// Skia expects the alpha first (#ARGB / #AARRGGBB).
var argb = hex.Length switch
{
4 => hex[3..] + hex[..3],
8 => hex[6..] + hex[..6],
_ => hex,
};
return SKColor.TryParse(argb, out var color)
? color
: throw new ArgumentException($"Invalid background color '{value}'.", nameof(value));
}
private static void Encode(SKBitmap image, Stream output, RenderRequest request)
{
using var pixmap = image.PeekPixels();
var quality = Math.Clamp(request.Quality, 0, 100);
var encoded = request.MimeType switch
{
"image/png" or "image/x-icon" =>
pixmap.Encode(output, new SKPngEncoderOptions(SKPngEncoderFilterFlags.AllFilters, PngZLibLevel(request.Quality))),
"image/jpg" or "image/jpe" or "image/jpeg" or "image/pjpeg" =>
pixmap.Encode(output, new SKJpegEncoderOptions(quality)),
"image/webp" =>
pixmap.Encode(output, new SKWebpEncoderOptions(SKWebpEncoderCompression.Lossy, quality)),
_ => throw new NotSupportedException($"Not supported image format [{request.MimeType}] when creating images."),
};
if (!encoded)
{
throw new InvalidOperationException($"Failed to encode image as [{request.MimeType}].");
}
}
/// <summary>Same quality -> compression mapping as the default service: 100 = level 0 ... <20 = level 9.</summary>
private static int PngZLibLevel(int quality) => quality < 0 ? 6 : Math.Clamp(10 - quality / 10, 0, 9);
}
Two details that are easy to miss:
- Channel order (ParseColor). ImageSharp parses #RRGGBBAA, Skia parses #AARRGGBB. Rotate the hex string before parsing, or #00FF0080 comes out as a different color.
- 90° rotation (Rotate). cos(90°) evaluates to 6e-17, not 0, so Math.Ceiling(50.00000000000001) gives 51 and the output gains an extra pixel. Round before taking the ceiling.
Registration
using EPiServer.ImageLibrary;
using YourSite.Business.Imaging;
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services
.AddCmsAspNetIdentity<ApplicationUser>()
.AddCms();
// Replaces EPiServer.ImageLibrary.ImageSharp for thumbnails and the CMS image editor.
services.AddSingleton<IImageService, SkiaImageService>();
}
}
The CMS only registers a fallback IImageService when none exists (TryAddSingleton), so your implementation takes precedence. Without any registration, that fallback throws InvalidOperationException the first time it's resolved.
$ dotnet list package --include-transitive --vulnerable
The given project `Alloy` has no vulnerable packages given the current sources.
Known limitations
- Skia encodes PNG, JPEG and WebP only. GIF, BMP or TIFF output throws NotSupportedException, so the image editor won't work for those formats. Thumbnails aren't affected, because the CMS always stores them as .png.
- Skia can't decode TIFF, so TIFF uploads get no thumbnail. The upload itself still succeeds, because the CMS handles thumbnail errors.
- Animated GIFs are flattened to the first frame.
Note: this sample is not an officially supported Optimizely package. It's provided as guidance, and you own it once it's in your solution. Test it against your own media library before you deploy.
6. Option 3: suppress the warnings
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-gwg2-r3hj-4w44" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j3p4-wp97-rph4" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j9gm-c75j-xc9q" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-wmxv-xphr-5c9g" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-jjfr-hcj7-qf5w" />
</ItemGroup>
Suppressing only hides the warning, the vulnerability is still there. Record the decision (reason, owner, review date) wherever your team tracks accepted risks.
7. Option 4: wait for an updated package
An updated EPiServer.ImageLibrary.ImageSharp would remove the problem at the source. We can't share a timeline for that yet. If you go this route:
-
Watch the Optimizely release notes for a new EPiServer.ImageLibrary.ImageSharp version, or check from the command line:
dotnet list package --outdated --include-transitive | grep -i imagelibrary -
Combine it with option 3 in the meantime, and add a review date so the suppression gets removed once the update ships:
<!-- TEMP: waiting for an EPiServer.ImageLibrary.ImageSharp update without the vulnerable ImageSharp. Review by: 2026-12-31. Owner: @team-cms --> <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-j3p4-wp97-rph4" /> -
If you're on option 2, moving to the official package later means deleting your IImageService implementation and its registration.
References
- SixLabors/ImageSharp, 4.x license discussion: https://github.com/SixLabors/ImageSharp/issues/3205#issuecomment-6062121101
- Optimizely IImageService API: https://world.optimizely.com/CsClassLibraries/cms/EPiServer.ImageLibrary.IImageService?version=12
- SkiaSharp: https://github.com/mono/SkiaSharp
- NuGet dependency resolution: https://learn.microsoft.com/nuget/concepts/dependency-resolution
- NuGet Audit: https://learn.microsoft.com/nuget/concepts/auditing-packages
- Advisories: GHSA-gwg2-r3hj-4w44, GHSA-j3p4-wp97-rph4, GHSA-j9gm-c75j-xc9q, GHSA-wmxv-xphr-5c9g, GHSA-jjfr-hcj7-qf5w
Comments