aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs20
-rw-r--r--src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs339
2 files changed, 359 insertions, 0 deletions
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index b6d2914efa..3e353db8de 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Model.Drawing;
using Microsoft.Extensions.Logging;
using SkiaSharp;
+using Svg;
using Svg.Skia;
namespace Jellyfin.Drawing.Skia;
@@ -48,6 +49,13 @@ public class SkiaEncoder : IImageEncoder
/// </summary>
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
+ static SkiaEncoder()
+ {
+ SvgDocument.ResolveExternalElements = ExternalType.None;
+ SvgDocument.ResolveExternalImages = ExternalType.None;
+ SvgDocument.ResolveExternalXmlEntites = ExternalType.None;
+ }
+
/// <summary>
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
/// </summary>
@@ -183,6 +191,12 @@ public class SkiaEncoder : IImageEncoder
var extension = Path.GetExtension(path.AsSpan());
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason);
+ return default;
+ }
+
using var svg = new SKSvg();
try
{
@@ -445,6 +459,12 @@ public class SkiaEncoder : IImageEncoder
throw new FileNotFoundException("File not found", path);
}
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason);
+ return null;
+ }
+
using var svg = SKSvg.CreateFromFile(path);
if (svg.Drawable is null)
{
diff --git a/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
new file mode 100644
index 0000000000..f8a1d7d443
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
@@ -0,0 +1,339 @@
+using System;
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Xml;
+
+[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")]
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Validates that an SVG document does not reference external resources before it is rasterized.
+/// </summary>
+internal static class SvgSecurityValidator
+{
+ // Guards against a chain of nested data:image/svg+xml payloads.
+ private const int MaxDataUriDepth = 4;
+
+ // Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs.
+ private const int MaxDecompressedBytes = 16 * 1024 * 1024;
+
+ private const int DecompressBufferSize = 81920;
+
+ private static readonly XmlReaderSettings _scanSettings = new()
+ {
+ DtdProcessing = DtdProcessing.Parse,
+ XmlResolver = null,
+ MaxCharactersFromEntities = 1024 * 1024,
+ IgnoreComments = true,
+ IgnoreProcessingInstructions = true,
+ IgnoreWhitespace = true,
+ CloseInput = false
+ };
+
+ /// <summary>
+ /// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references
+ /// to external resources.
+ /// </summary>
+ /// <param name="path">The path to the SVG file.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+ reason = Validate(stream, 0);
+ }
+ catch (IOException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+
+ return reason is null;
+ }
+
+ /// <summary>
+ /// Determines whether the SVG in the given stream is safe to rasterize.
+ /// </summary>
+ /// <param name="stream">The stream containing the SVG document.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason)
+ {
+ reason = Validate(stream, 0);
+ return reason is null;
+ }
+
+ private static string? Validate(Stream stream, int depth)
+ {
+ try
+ {
+ using var reader = XmlReader.Create(stream, _scanSettings);
+ while (reader.Read())
+ {
+ switch (reader.NodeType)
+ {
+ case XmlNodeType.DocumentType:
+ {
+ var subset = reader.Value;
+ if (!string.IsNullOrEmpty(subset)
+ && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase)
+ || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase)))
+ {
+ return "The document declares an external DTD entity";
+ }
+
+ break;
+ }
+
+ case XmlNodeType.Element when reader.HasAttributes:
+ {
+ for (var i = 0; i < reader.AttributeCount; i++)
+ {
+ reader.MoveToAttribute(i);
+ var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase);
+ var reason = isHref
+ ? ValidateReference(reader.Value, depth, "href")
+ : ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+
+ reader.MoveToElement();
+ break;
+ }
+
+ case XmlNodeType.Text:
+ case XmlNodeType.CDATA:
+ {
+ var reason = ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ break;
+ }
+ }
+ }
+
+ return null;
+ }
+ catch (XmlException ex)
+ {
+ // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render.
+ return "The document could not be safely parsed: " + ex.Message;
+ }
+ }
+
+ private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context)
+ {
+ var trimmed = value.Trim();
+ if (trimmed.IsEmpty || trimmed[0] == '#')
+ {
+ return null;
+ }
+
+ if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ return ValidateDataUri(trimmed, depth, context);
+ }
+
+ return "An external resource is referenced via " + context;
+ }
+
+ private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context)
+ {
+ // "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing).
+ var comma = dataUri.IndexOf(',');
+ if (comma < 0)
+ {
+ return "A malformed data URI is referenced via " + context;
+ }
+
+ var header = dataUri[5..comma];
+ var firstSeparator = header.IndexOf(';');
+ var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim();
+
+ // Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data.
+ if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ if (depth >= MaxDataUriDepth)
+ {
+ return "Nested data URIs exceed the allowed depth";
+ }
+
+ var lastSeparator = header.LastIndexOf(';');
+ var isBase64 = lastSeparator >= 0
+ && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase);
+
+ var payload = dataUri[(comma + 1)..].Trim();
+ byte[]? buffer = null;
+ try
+ {
+ int length;
+ if (isBase64)
+ {
+ buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3);
+ if (!Convert.TryFromBase64Chars(payload, buffer, out length))
+ {
+ return "An undecodable data URI is referenced via " + context;
+ }
+ }
+ else
+ {
+ var unescaped = Uri.UnescapeDataString(payload.ToString());
+ buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length));
+ length = Encoding.UTF8.GetBytes(unescaped, buffer);
+ }
+
+ if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B)
+ {
+ using var decompressed = Decompress(buffer, length);
+ return Validate(decompressed, depth + 1);
+ }
+
+ using var stream = new MemoryStream(buffer, 0, length, false);
+ return Validate(stream, depth + 1);
+ }
+ catch (FormatException ex)
+ {
+ return "An undecodable data URI is referenced via " + context + ": " + ex.Message;
+ }
+ catch (InvalidDataException ex)
+ {
+ return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message;
+ }
+ finally
+ {
+ if (buffer is not null)
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+ }
+
+ private static MemoryStream Decompress(byte[] compressed, int length)
+ {
+ using var input = new MemoryStream(compressed, 0, length, false);
+ using var gzip = new GZipStream(input, CompressionMode.Decompress);
+ var output = new MemoryStream();
+ var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize);
+ try
+ {
+ var total = 0;
+ int read;
+ while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ total += read;
+ if (total > MaxDecompressedBytes)
+ {
+ throw new InvalidDataException("Compressed data URI exceeds the allowed size");
+ }
+
+ output.Write(buffer, 0, read);
+ }
+ }
+ catch
+ {
+ output.Dispose();
+ throw;
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+
+ output.Position = 0;
+ return output;
+ }
+
+ private static string? ValidateCss(ReadOnlySpan<char> value, int depth)
+ {
+ if (value.IsEmpty)
+ {
+ return null;
+ }
+
+ var index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var start = index + found + 4;
+ var close = value[start..].IndexOf(')');
+ if (close < 0)
+ {
+ break;
+ }
+
+ var target = value.Slice(start, close).Trim();
+ target = target.Trim('\'');
+ target = target.Trim('"').Trim();
+ var reason = ValidateReference(target, depth, "url()");
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ index = start + close + 1;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above).
+ index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var rest = value[(index + found + 7)..];
+ var quote = rest.IndexOfAny('\'', '"');
+ if (quote >= 0)
+ {
+ var afterQuote = rest[(quote + 1)..];
+ var end = afterQuote.IndexOfAny('\'', '"');
+ if (end >= 0)
+ {
+ var reason = ValidateReference(afterQuote[..end], depth, "@import");
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+ }
+
+ index = index + found + 7;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ return null;
+ }
+}