| | | 1 | | using System; |
| | | 2 | | using System.Buffers; |
| | | 3 | | using System.Diagnostics.CodeAnalysis; |
| | | 4 | | using System.IO; |
| | | 5 | | using System.IO.Compression; |
| | | 6 | | using System.Runtime.CompilerServices; |
| | | 7 | | using System.Text; |
| | | 8 | | using System.Xml; |
| | | 9 | | |
| | | 10 | | [assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")] |
| | | 11 | | |
| | | 12 | | namespace Jellyfin.Drawing.Skia; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Validates that an SVG document does not reference external resources before it is rasterized. |
| | | 16 | | /// </summary> |
| | | 17 | | internal 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 | | |
| | 1 | 27 | | private static readonly XmlReaderSettings _scanSettings = new() |
| | 1 | 28 | | { |
| | 1 | 29 | | DtdProcessing = DtdProcessing.Parse, |
| | 1 | 30 | | XmlResolver = null, |
| | 1 | 31 | | MaxCharactersFromEntities = 1024 * 1024, |
| | 1 | 32 | | IgnoreComments = true, |
| | 1 | 33 | | IgnoreProcessingInstructions = true, |
| | 1 | 34 | | IgnoreWhitespace = true, |
| | 1 | 35 | | CloseInput = false |
| | 1 | 36 | | }; |
| | | 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 | | { |
| | 21 | 49 | | using var stream = File.OpenRead(path); |
| | 20 | 50 | | reason = Validate(stream, 0); |
| | 20 | 51 | | } |
| | 1 | 52 | | catch (IOException ex) |
| | | 53 | | { |
| | 1 | 54 | | reason = "Unable to read the file for validation: " + ex.Message; |
| | 1 | 55 | | } |
| | 0 | 56 | | catch (UnauthorizedAccessException ex) |
| | | 57 | | { |
| | 0 | 58 | | reason = "Unable to read the file for validation: " + ex.Message; |
| | 0 | 59 | | } |
| | | 60 | | |
| | 21 | 61 | | 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 | | { |
| | 0 | 72 | | reason = Validate(stream, 0); |
| | 0 | 73 | | return reason is null; |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | private static string? Validate(Stream stream, int depth) |
| | | 77 | | { |
| | | 78 | | try |
| | | 79 | | { |
| | 25 | 80 | | using var reader = XmlReader.Create(stream, _scanSettings); |
| | 80 | 81 | | while (reader.Read()) |
| | | 82 | | { |
| | 71 | 83 | | switch (reader.NodeType) |
| | | 84 | | { |
| | | 85 | | case XmlNodeType.DocumentType: |
| | | 86 | | { |
| | 4 | 87 | | var subset = reader.Value; |
| | 4 | 88 | | if (!string.IsNullOrEmpty(subset) |
| | 4 | 89 | | && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase) |
| | 4 | 90 | | || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase))) |
| | | 91 | | { |
| | 1 | 92 | | return "The document declares an external DTD entity"; |
| | | 93 | | } |
| | | 94 | | |
| | | 95 | | break; |
| | | 96 | | } |
| | | 97 | | |
| | 51 | 98 | | case XmlNodeType.Element when reader.HasAttributes: |
| | | 99 | | { |
| | 326 | 100 | | for (var i = 0; i < reader.AttributeCount; i++) |
| | | 101 | | { |
| | 128 | 102 | | reader.MoveToAttribute(i); |
| | 128 | 103 | | var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase); |
| | 128 | 104 | | var reason = isHref |
| | 128 | 105 | | ? ValidateReference(reader.Value, depth, "href") |
| | 128 | 106 | | : ValidateCss(reader.Value, depth); |
| | 128 | 107 | | if (reason is not null) |
| | | 108 | | { |
| | 13 | 109 | | return reason; |
| | | 110 | | } |
| | | 111 | | } |
| | | 112 | | |
| | 35 | 113 | | reader.MoveToElement(); |
| | 35 | 114 | | break; |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | case XmlNodeType.Text: |
| | | 118 | | case XmlNodeType.CDATA: |
| | | 119 | | { |
| | 2 | 120 | | var reason = ValidateCss(reader.Value, depth); |
| | 1 | 121 | | if (reason is not null) |
| | | 122 | | { |
| | 1 | 123 | | return reason; |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | break; |
| | | 127 | | } |
| | | 128 | | } |
| | | 129 | | } |
| | | 130 | | |
| | 9 | 131 | | return null; |
| | | 132 | | } |
| | 1 | 133 | | catch (XmlException ex) |
| | | 134 | | { |
| | | 135 | | // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render. |
| | 1 | 136 | | return "The document could not be safely parsed: " + ex.Message; |
| | | 137 | | } |
| | 25 | 138 | | } |
| | | 139 | | |
| | | 140 | | private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context) |
| | | 141 | | { |
| | 19 | 142 | | var trimmed = value.Trim(); |
| | 19 | 143 | | if (trimmed.IsEmpty || trimmed[0] == '#') |
| | | 144 | | { |
| | 2 | 145 | | return null; |
| | | 146 | | } |
| | | 147 | | |
| | 17 | 148 | | if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) |
| | | 149 | | { |
| | 6 | 150 | | return ValidateDataUri(trimmed, depth, context); |
| | | 151 | | } |
| | | 152 | | |
| | 11 | 153 | | 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). |
| | 6 | 159 | | var comma = dataUri.IndexOf(','); |
| | 6 | 160 | | if (comma < 0) |
| | | 161 | | { |
| | 0 | 162 | | return "A malformed data URI is referenced via " + context; |
| | | 163 | | } |
| | | 164 | | |
| | 6 | 165 | | var header = dataUri[5..comma]; |
| | 6 | 166 | | var firstSeparator = header.IndexOf(';'); |
| | 6 | 167 | | 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. |
| | 6 | 170 | | if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase)) |
| | | 171 | | { |
| | 1 | 172 | | return null; |
| | | 173 | | } |
| | | 174 | | |
| | 5 | 175 | | if (depth >= MaxDataUriDepth) |
| | | 176 | | { |
| | 0 | 177 | | return "Nested data URIs exceed the allowed depth"; |
| | | 178 | | } |
| | | 179 | | |
| | 5 | 180 | | var lastSeparator = header.LastIndexOf(';'); |
| | 5 | 181 | | var isBase64 = lastSeparator >= 0 |
| | 5 | 182 | | && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase); |
| | | 183 | | |
| | 5 | 184 | | var payload = dataUri[(comma + 1)..].Trim(); |
| | 5 | 185 | | byte[]? buffer = null; |
| | | 186 | | try |
| | | 187 | | { |
| | | 188 | | int length; |
| | 5 | 189 | | if (isBase64) |
| | | 190 | | { |
| | 4 | 191 | | buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3); |
| | 4 | 192 | | if (!Convert.TryFromBase64Chars(payload, buffer, out length)) |
| | | 193 | | { |
| | 0 | 194 | | return "An undecodable data URI is referenced via " + context; |
| | | 195 | | } |
| | | 196 | | } |
| | | 197 | | else |
| | | 198 | | { |
| | 1 | 199 | | var unescaped = Uri.UnescapeDataString(payload.ToString()); |
| | 1 | 200 | | buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length)); |
| | 1 | 201 | | length = Encoding.UTF8.GetBytes(unescaped, buffer); |
| | | 202 | | } |
| | | 203 | | |
| | 5 | 204 | | if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B) |
| | | 205 | | { |
| | 2 | 206 | | using var decompressed = Decompress(buffer, length); |
| | 2 | 207 | | return Validate(decompressed, depth + 1); |
| | | 208 | | } |
| | | 209 | | |
| | 3 | 210 | | using var stream = new MemoryStream(buffer, 0, length, false); |
| | 3 | 211 | | return Validate(stream, depth + 1); |
| | | 212 | | } |
| | 0 | 213 | | catch (FormatException ex) |
| | | 214 | | { |
| | 0 | 215 | | return "An undecodable data URI is referenced via " + context + ": " + ex.Message; |
| | | 216 | | } |
| | 0 | 217 | | catch (InvalidDataException ex) |
| | | 218 | | { |
| | 0 | 219 | | return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message; |
| | | 220 | | } |
| | | 221 | | finally |
| | | 222 | | { |
| | 5 | 223 | | if (buffer is not null) |
| | | 224 | | { |
| | 5 | 225 | | ArrayPool<byte>.Shared.Return(buffer); |
| | | 226 | | } |
| | 5 | 227 | | } |
| | 5 | 228 | | } |
| | | 229 | | |
| | | 230 | | private static MemoryStream Decompress(byte[] compressed, int length) |
| | | 231 | | { |
| | 2 | 232 | | using var input = new MemoryStream(compressed, 0, length, false); |
| | 2 | 233 | | using var gzip = new GZipStream(input, CompressionMode.Decompress); |
| | 2 | 234 | | var output = new MemoryStream(); |
| | 2 | 235 | | var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize); |
| | | 236 | | try |
| | | 237 | | { |
| | 2 | 238 | | var total = 0; |
| | | 239 | | int read; |
| | 4 | 240 | | while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0) |
| | | 241 | | { |
| | 2 | 242 | | total += read; |
| | 2 | 243 | | if (total > MaxDecompressedBytes) |
| | | 244 | | { |
| | 0 | 245 | | throw new InvalidDataException("Compressed data URI exceeds the allowed size"); |
| | | 246 | | } |
| | | 247 | | |
| | 2 | 248 | | output.Write(buffer, 0, read); |
| | | 249 | | } |
| | 2 | 250 | | } |
| | 0 | 251 | | catch |
| | | 252 | | { |
| | 0 | 253 | | output.Dispose(); |
| | 0 | 254 | | throw; |
| | | 255 | | } |
| | | 256 | | finally |
| | | 257 | | { |
| | 2 | 258 | | ArrayPool<byte>.Shared.Return(buffer); |
| | 2 | 259 | | } |
| | | 260 | | |
| | 2 | 261 | | output.Position = 0; |
| | 2 | 262 | | return output; |
| | 2 | 263 | | } |
| | | 264 | | |
| | | 265 | | private static string? ValidateCss(ReadOnlySpan<char> value, int depth) |
| | | 266 | | { |
| | 113 | 267 | | if (value.IsEmpty) |
| | | 268 | | { |
| | 0 | 269 | | return null; |
| | | 270 | | } |
| | | 271 | | |
| | 113 | 272 | | var index = 0; |
| | | 273 | | while (true) |
| | | 274 | | { |
| | 113 | 275 | | var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase); |
| | 113 | 276 | | if (found < 0) |
| | | 277 | | { |
| | | 278 | | break; |
| | | 279 | | } |
| | | 280 | | |
| | 2 | 281 | | var start = index + found + 4; |
| | 2 | 282 | | var close = value[start..].IndexOf(')'); |
| | 2 | 283 | | if (close < 0) |
| | | 284 | | { |
| | | 285 | | break; |
| | | 286 | | } |
| | | 287 | | |
| | 2 | 288 | | var target = value.Slice(start, close).Trim(); |
| | 2 | 289 | | target = target.Trim('\''); |
| | 2 | 290 | | target = target.Trim('"').Trim(); |
| | 2 | 291 | | var reason = ValidateReference(target, depth, "url()"); |
| | 2 | 292 | | if (reason is not null) |
| | | 293 | | { |
| | 1 | 294 | | return reason; |
| | | 295 | | } |
| | | 296 | | |
| | 1 | 297 | | index = start + close + 1; |
| | 1 | 298 | | if (index >= value.Length) |
| | | 299 | | { |
| | | 300 | | break; |
| | | 301 | | } |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above). |
| | 112 | 305 | | index = 0; |
| | | 306 | | while (true) |
| | | 307 | | { |
| | 112 | 308 | | var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase); |
| | 112 | 309 | | if (found < 0) |
| | | 310 | | { |
| | | 311 | | break; |
| | | 312 | | } |
| | | 313 | | |
| | 1 | 314 | | var rest = value[(index + found + 7)..]; |
| | 1 | 315 | | var quote = rest.IndexOfAny('\'', '"'); |
| | 1 | 316 | | if (quote >= 0) |
| | | 317 | | { |
| | 1 | 318 | | var afterQuote = rest[(quote + 1)..]; |
| | 1 | 319 | | var end = afterQuote.IndexOfAny('\'', '"'); |
| | 1 | 320 | | if (end >= 0) |
| | | 321 | | { |
| | 1 | 322 | | var reason = ValidateReference(afterQuote[..end], depth, "@import"); |
| | 1 | 323 | | if (reason is not null) |
| | | 324 | | { |
| | 1 | 325 | | return reason; |
| | | 326 | | } |
| | | 327 | | } |
| | | 328 | | } |
| | | 329 | | |
| | 0 | 330 | | index = index + found + 7; |
| | 0 | 331 | | if (index >= value.Length) |
| | | 332 | | { |
| | | 333 | | break; |
| | | 334 | | } |
| | | 335 | | } |
| | | 336 | | |
| | 111 | 337 | | return null; |
| | | 338 | | } |
| | | 339 | | } |