Cuong Nguyen Dinh
Optimizely
Oct 10, 2026
visibility 42
star star star star star
(0 votes)

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 ... &lt;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

Oct 10, 2026

Comments

error Please login to comment.
Latest blogs
What made Scheduled Jobs faster in Optimizely CMS 13.3.0

A look under the hood at the update that made scheduled jobs faster.

Tomas Hensrud Gulla | Oct 9, 2026 |

New DDS explorer addon for Optimizely CMS

I wanted to be able to manage Dynamic Data Store from within Optimizely CMS, so I created another addon.

Tomas Hensrud Gulla | Oct 8, 2026 |

Queryable or Searchable? How indexing type changes your Optimizely Graph query

The Property Indexing Type on a CMS field decides whether Optimizely Graph will return it, filter it, or run full-text search on it. Default,...

Chirag Khanna | Oct 8, 2026 |