aboutsummaryrefslogtreecommitdiff
path: root/src/Jellyfin.Extensions/PathHelper.cs
blob: ab74a7749d720ce35616f2d14c531efba6cf8cd6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.IO;

namespace Jellyfin.Extensions;

/// <summary>
/// Helpers for safely composing filesystem paths from untrusted input.
/// </summary>
/// <remarks>
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
/// any code that joins a trusted directory with an externally-supplied name:
/// it neither normalises <c>..</c> 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.
/// </remarks>
public static class PathHelper
{
    /// <summary>
    /// Reduces a possibly-untrusted file name to a safe leaf-only name with no
    /// directory components.
    /// </summary>
    /// <param name="fileName">The candidate file name.</param>
    /// <returns>
    /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
    /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
    /// </returns>
    public static string? GetSafeLeafFileName(string? fileName)
    {
        if (string.IsNullOrEmpty(fileName))
        {
            return null;
        }

        var leaf = Path.GetFileName(fileName);
        if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal))
        {
            return null;
        }

        return leaf;
    }

    /// <summary>
    /// Returns whether <paramref name="candidate"/> resolves to a path that
    /// equals or is contained inside <paramref name="root"/>.
    /// </summary>
    /// <param name="root">The directory the candidate must remain inside.</param>
    /// <param name="candidate">The candidate absolute or relative path.</param>
    /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
    /// <remarks>
    /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
    /// so <c>..</c> segments are collapsed before the comparison. The root is
    /// compared with a trailing directory separator to prevent prefix
    /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
    /// <c>/var/dataset</c>).
    /// </remarks>
    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);
    }
}