< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.Skia.SvgSecurityValidator
Assembly: Jellyfin.Drawing.Skia
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
Line coverage
86%
Covered lines: 120
Uncovered lines: 19
Coverable lines: 139
Total lines: 339
Line coverage: 86.3%
Branch coverage
83%
Covered branches: 65
Total branches: 78
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/29/2026 - 12:17:11 AM Line coverage: 86.3% (120/139) Branch coverage: 83.3% (65/78) Total lines: 339 10/29/2026 - 12:17:11 AM Line coverage: 86.3% (120/139) Branch coverage: 83.3% (65/78) Total lines: 339

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
IsSafe(...)100%1170%
IsSafe(...)100%210%
Validate(...)100%2424100%
ValidateReference(...)83.33%66100%
ValidateDataUri(...)83.33%292480%
Decompress(...)75%4478.94%
ValidateCss(...)65%202090.32%

File(s)

/srv/git/jellyfin/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs

#LineLine coverage
 1using System;
 2using System.Buffers;
 3using System.Diagnostics.CodeAnalysis;
 4using System.IO;
 5using System.IO.Compression;
 6using System.Runtime.CompilerServices;
 7using System.Text;
 8using System.Xml;
 9
 10[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")]
 11
 12namespace Jellyfin.Drawing.Skia;
 13
 14/// <summary>
 15/// Validates that an SVG document does not reference external resources before it is rasterized.
 16/// </summary>
 17internal static class SvgSecurityValidator
 18{
 19    // Guards against a chain of nested data:image/svg+xml payloads.
 20    private const int MaxDataUriDepth = 4;
 21
 22    // Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs.
 23    private const int MaxDecompressedBytes = 16 * 1024 * 1024;
 24
 25    private const int DecompressBufferSize = 81920;
 26
 127    private static readonly XmlReaderSettings _scanSettings = new()
 128    {
 129        DtdProcessing = DtdProcessing.Parse,
 130        XmlResolver = null,
 131        MaxCharactersFromEntities = 1024 * 1024,
 132        IgnoreComments = true,
 133        IgnoreProcessingInstructions = true,
 134        IgnoreWhitespace = true,
 135        CloseInput = false
 136    };
 37
 38    /// <summary>
 39    /// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references
 40    /// to external resources.
 41    /// </summary>
 42    /// <param name="path">The path to the SVG file.</param>
 43    /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
 44    /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
 45    public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason)
 46    {
 47        try
 48        {
 2149            using var stream = File.OpenRead(path);
 2050            reason = Validate(stream, 0);
 2051        }
 152        catch (IOException ex)
 53        {
 154            reason = "Unable to read the file for validation: " + ex.Message;
 155        }
 056        catch (UnauthorizedAccessException ex)
 57        {
 058            reason = "Unable to read the file for validation: " + ex.Message;
 059        }
 60
 2161        return reason is null;
 62    }
 63
 64    /// <summary>
 65    /// Determines whether the SVG in the given stream is safe to rasterize.
 66    /// </summary>
 67    /// <param name="stream">The stream containing the SVG document.</param>
 68    /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
 69    /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
 70    public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason)
 71    {
 072        reason = Validate(stream, 0);
 073        return reason is null;
 74    }
 75
 76    private static string? Validate(Stream stream, int depth)
 77    {
 78        try
 79        {
 2580            using var reader = XmlReader.Create(stream, _scanSettings);
 8081            while (reader.Read())
 82            {
 7183                switch (reader.NodeType)
 84                {
 85                    case XmlNodeType.DocumentType:
 86                        {
 487                            var subset = reader.Value;
 488                            if (!string.IsNullOrEmpty(subset)
 489                                && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase)
 490                                    || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase)))
 91                            {
 192                                return "The document declares an external DTD entity";
 93                            }
 94
 95                            break;
 96                        }
 97
 5198                    case XmlNodeType.Element when reader.HasAttributes:
 99                        {
 326100                            for (var i = 0; i < reader.AttributeCount; i++)
 101                            {
 128102                                reader.MoveToAttribute(i);
 128103                                var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase);
 128104                                var reason = isHref
 128105                                    ? ValidateReference(reader.Value, depth, "href")
 128106                                    : ValidateCss(reader.Value, depth);
 128107                                if (reason is not null)
 108                                {
 13109                                    return reason;
 110                                }
 111                            }
 112
 35113                            reader.MoveToElement();
 35114                            break;
 115                        }
 116
 117                    case XmlNodeType.Text:
 118                    case XmlNodeType.CDATA:
 119                        {
 2120                            var reason = ValidateCss(reader.Value, depth);
 1121                            if (reason is not null)
 122                            {
 1123                                return reason;
 124                            }
 125
 126                            break;
 127                        }
 128                }
 129            }
 130
 9131            return null;
 132        }
 1133        catch (XmlException ex)
 134        {
 135            // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render.
 1136            return "The document could not be safely parsed: " + ex.Message;
 137        }
 25138    }
 139
 140    private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context)
 141    {
 19142        var trimmed = value.Trim();
 19143        if (trimmed.IsEmpty || trimmed[0] == '#')
 144        {
 2145            return null;
 146        }
 147
 17148        if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
 149        {
 6150            return ValidateDataUri(trimmed, depth, context);
 151        }
 152
 11153        return "An external resource is referenced via " + context;
 154    }
 155
 156    private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context)
 157    {
 158        // "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing).
 6159        var comma = dataUri.IndexOf(',');
 6160        if (comma < 0)
 161        {
 0162            return "A malformed data URI is referenced via " + context;
 163        }
 164
 6165        var header = dataUri[5..comma];
 6166        var firstSeparator = header.IndexOf(';');
 6167        var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim();
 168
 169        // Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data.
 6170        if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase))
 171        {
 1172            return null;
 173        }
 174
 5175        if (depth >= MaxDataUriDepth)
 176        {
 0177            return "Nested data URIs exceed the allowed depth";
 178        }
 179
 5180        var lastSeparator = header.LastIndexOf(';');
 5181        var isBase64 = lastSeparator >= 0
 5182            && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase);
 183
 5184        var payload = dataUri[(comma + 1)..].Trim();
 5185        byte[]? buffer = null;
 186        try
 187        {
 188            int length;
 5189            if (isBase64)
 190            {
 4191                buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3);
 4192                if (!Convert.TryFromBase64Chars(payload, buffer, out length))
 193                {
 0194                    return "An undecodable data URI is referenced via " + context;
 195                }
 196            }
 197            else
 198            {
 1199                var unescaped = Uri.UnescapeDataString(payload.ToString());
 1200                buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length));
 1201                length = Encoding.UTF8.GetBytes(unescaped, buffer);
 202            }
 203
 5204            if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B)
 205            {
 2206                using var decompressed = Decompress(buffer, length);
 2207                return Validate(decompressed, depth + 1);
 208            }
 209
 3210            using var stream = new MemoryStream(buffer, 0, length, false);
 3211            return Validate(stream, depth + 1);
 212        }
 0213        catch (FormatException ex)
 214        {
 0215            return "An undecodable data URI is referenced via " + context + ": " + ex.Message;
 216        }
 0217        catch (InvalidDataException ex)
 218        {
 0219            return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message;
 220        }
 221        finally
 222        {
 5223            if (buffer is not null)
 224            {
 5225                ArrayPool<byte>.Shared.Return(buffer);
 226            }
 5227        }
 5228    }
 229
 230    private static MemoryStream Decompress(byte[] compressed, int length)
 231    {
 2232        using var input = new MemoryStream(compressed, 0, length, false);
 2233        using var gzip = new GZipStream(input, CompressionMode.Decompress);
 2234        var output = new MemoryStream();
 2235        var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize);
 236        try
 237        {
 2238            var total = 0;
 239            int read;
 4240            while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0)
 241            {
 2242                total += read;
 2243                if (total > MaxDecompressedBytes)
 244                {
 0245                    throw new InvalidDataException("Compressed data URI exceeds the allowed size");
 246                }
 247
 2248                output.Write(buffer, 0, read);
 249            }
 2250        }
 0251        catch
 252        {
 0253            output.Dispose();
 0254            throw;
 255        }
 256        finally
 257        {
 2258            ArrayPool<byte>.Shared.Return(buffer);
 2259        }
 260
 2261        output.Position = 0;
 2262        return output;
 2263    }
 264
 265    private static string? ValidateCss(ReadOnlySpan<char> value, int depth)
 266    {
 113267        if (value.IsEmpty)
 268        {
 0269            return null;
 270        }
 271
 113272        var index = 0;
 273        while (true)
 274        {
 113275            var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase);
 113276            if (found < 0)
 277            {
 278                break;
 279            }
 280
 2281            var start = index + found + 4;
 2282            var close = value[start..].IndexOf(')');
 2283            if (close < 0)
 284            {
 285                break;
 286            }
 287
 2288            var target = value.Slice(start, close).Trim();
 2289            target = target.Trim('\'');
 2290            target = target.Trim('"').Trim();
 2291            var reason = ValidateReference(target, depth, "url()");
 2292            if (reason is not null)
 293            {
 1294                return reason;
 295            }
 296
 1297            index = start + close + 1;
 1298            if (index >= value.Length)
 299            {
 300                break;
 301            }
 302        }
 303
 304        // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above).
 112305        index = 0;
 306        while (true)
 307        {
 112308            var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase);
 112309            if (found < 0)
 310            {
 311                break;
 312            }
 313
 1314            var rest = value[(index + found + 7)..];
 1315            var quote = rest.IndexOfAny('\'', '"');
 1316            if (quote >= 0)
 317            {
 1318                var afterQuote = rest[(quote + 1)..];
 1319                var end = afterQuote.IndexOfAny('\'', '"');
 1320                if (end >= 0)
 321                {
 1322                    var reason = ValidateReference(afterQuote[..end], depth, "@import");
 1323                    if (reason is not null)
 324                    {
 1325                        return reason;
 326                    }
 327                }
 328            }
 329
 0330            index = index + found + 7;
 0331            if (index >= value.Length)
 332            {
 333                break;
 334            }
 335        }
 336
 111337        return null;
 338    }
 339}