| | | 1 | | using System; |
| | | 2 | | using System.IO; |
| | | 3 | | |
| | | 4 | | namespace Jellyfin.Extensions; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Helpers for safely composing filesystem paths from untrusted input. |
| | | 8 | | /// </summary> |
| | | 9 | | /// <remarks> |
| | | 10 | | /// <see cref="Path.Combine(string, string)"/> has two issues that matter in |
| | | 11 | | /// any code that joins a trusted directory with an externally-supplied name: |
| | | 12 | | /// it neither normalises <c>..</c> nor rejects a rooted second argument |
| | | 13 | | /// (a rooted second arg silently discards the first). Use the helpers below |
| | | 14 | | /// any time the name comes from media metadata, request input, archive |
| | | 15 | | /// entries, or any other channel that can be influenced by a third party. |
| | | 16 | | /// </remarks> |
| | | 17 | | public static class PathHelper |
| | | 18 | | { |
| | | 19 | | /// <summary> |
| | | 20 | | /// Reduces a possibly-untrusted file name to a safe leaf-only name with no |
| | | 21 | | /// directory components. |
| | | 22 | | /// </summary> |
| | | 23 | | /// <param name="fileName">The candidate file name.</param> |
| | | 24 | | /// <returns> |
| | | 25 | | /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if |
| | | 26 | | /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>). |
| | | 27 | | /// </returns> |
| | | 28 | | public static string? GetSafeLeafFileName(string? fileName) |
| | | 29 | | { |
| | 15 | 30 | | if (string.IsNullOrEmpty(fileName)) |
| | | 31 | | { |
| | 2 | 32 | | return null; |
| | | 33 | | } |
| | | 34 | | |
| | 13 | 35 | | var leaf = Path.GetFileName(fileName); |
| | 13 | 36 | | if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, ".." |
| | | 37 | | { |
| | 2 | 38 | | return null; |
| | | 39 | | } |
| | | 40 | | |
| | 11 | 41 | | return leaf; |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Returns whether <paramref name="candidate"/> resolves to a path that |
| | | 46 | | /// equals or is contained inside <paramref name="root"/>. |
| | | 47 | | /// </summary> |
| | | 48 | | /// <param name="root">The directory the candidate must remain inside.</param> |
| | | 49 | | /// <param name="candidate">The candidate absolute or relative path.</param> |
| | | 50 | | /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns> |
| | | 51 | | /// <remarks> |
| | | 52 | | /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/> |
| | | 53 | | /// so <c>..</c> segments are collapsed before the comparison. The root is |
| | | 54 | | /// compared with a trailing directory separator to prevent prefix |
| | | 55 | | /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of |
| | | 56 | | /// <c>/var/dataset</c>). |
| | | 57 | | /// </remarks> |
| | | 58 | | public static bool IsContainedIn(string root, string candidate) |
| | | 59 | | { |
| | 8 | 60 | | ArgumentException.ThrowIfNullOrEmpty(root); |
| | 8 | 61 | | ArgumentException.ThrowIfNullOrEmpty(candidate); |
| | | 62 | | |
| | 8 | 63 | | var fullRoot = Path.GetFullPath(root); |
| | 8 | 64 | | var fullCandidate = Path.GetFullPath(candidate); |
| | | 65 | | |
| | 8 | 66 | | if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)) |
| | | 67 | | { |
| | 1 | 68 | | return true; |
| | | 69 | | } |
| | | 70 | | |
| | 7 | 71 | | var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar) |
| | 7 | 72 | | ? fullRoot |
| | 7 | 73 | | : fullRoot + Path.DirectorySeparatorChar; |
| | | 74 | | |
| | 7 | 75 | | return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal); |
| | | 76 | | } |
| | | 77 | | } |