diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs new file mode 100644 index 0000000000..f519cbb651 --- /dev/null +++ b/src/Jellyfin.Extensions/PathHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; + +namespace Jellyfin.Extensions; + +/// +/// Helpers for safely composing filesystem paths from untrusted input. +/// +/// +/// has two issues that matter in +/// any code that joins a trusted directory with an externally-supplied name: +/// it neither normalises .. nor rejects a rooted second argument +/// (a rooted second arg silently discards the first). Use the helpers below +/// any time the name comes from media metadata, request input, archive +/// entries, or any other channel that can be influenced by a third party. +/// +public static class PathHelper +{ + /// + /// Reduces a possibly-untrusted file name to a safe leaf-only name with no + /// directory components. + /// + /// The candidate file name. + /// + /// The leaf component of , or null if + /// the input has no usable leaf (empty, ., or ..). + /// + public static string? GetSafeLeafFileName(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return null; + } + + var leaf = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..") + { + return null; + } + + return leaf; + } + + /// + /// Returns whether resolves to a path that + /// equals or is contained inside . + /// + /// The directory the candidate must remain inside. + /// The candidate absolute or relative path. + /// true if the candidate is inside or equal to root; otherwise false. + /// + /// Both arguments are resolved via + /// so .. segments are collapsed before the comparison. The root is + /// compared with a trailing directory separator to prevent prefix + /// collisions (e.g. /var/data must not be accepted as a parent of + /// /var/dataset). + /// + public static bool IsContainedIn(string root, string candidate) + { + ArgumentException.ThrowIfNullOrEmpty(root); + ArgumentException.ThrowIfNullOrEmpty(candidate); + + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + + if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)) + { + return true; + } + + var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal); + } +}