Repository navigation
Expand file tree
/
Copy pathpath-validation.ts
More file actions
106 lines (93 loc) · 3.62 KB
/
Copy pathpath-validation.ts
File metadata and controls
106 lines (93 loc) · 3.62 KB
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import path from "path";
/**
* Checks if an absolute path is within any of the allowed directories.
*
* @param absolutePath - The absolute path to check (will be normalized)
* @param allowedDirectories - Array of absolute allowed directory paths (will be normalized)
* @returns true if the path is within an allowed directory, false otherwise
* @throws Error if given relative paths after normalization
*/
export function isPathWithinAllowedDirectories(
absolutePath: string,
allowedDirectories: readonly string[],
): boolean {
// Type validation
if (typeof absolutePath !== "string" || !Array.isArray(allowedDirectories)) {
return false;
}
// Reject empty inputs
if (!absolutePath || allowedDirectories.length === 0) {
return false;
}
// Reject null bytes (forbidden in paths)
if (absolutePath.includes("\x00")) {
return false;
}
// Normalize the input path
let normalizedPath: string;
try {
normalizedPath = path.resolve(path.normalize(absolutePath));
} catch {
/* v8 ignore next -- path.normalize/resolve throw only on a non-string, and the typeof check above already returned for one */
return false;
}
// Verify it's absolute after normalization
/* v8 ignore next -- path.resolve always returns an absolute path, so this cannot fire */
if (!path.isAbsolute(normalizedPath)) {
throw new Error("Path must be absolute after normalization");
}
// Check against each allowed directory
return allowedDirectories.some((dir) => {
if (typeof dir !== "string" || !dir) {
return false;
}
// Reject null bytes in allowed dirs
if (dir.includes("\x00")) {
return false;
}
// Normalize the allowed directory
let normalizedDir: string;
try {
normalizedDir = path.resolve(path.normalize(dir));
} catch {
/* v8 ignore next -- path.normalize/resolve throw only on a non-string, and the typeof check above already returned false for one */
return false;
}
// Verify allowed directory is absolute after normalization
/* v8 ignore next -- path.resolve always returns an absolute path, so this cannot fire */
if (!path.isAbsolute(normalizedDir)) {
throw new Error(
"Allowed directories must be absolute paths after normalization",
);
}
// Check if normalizedPath is within normalizedDir
// Path is inside if it's the same or a subdirectory
if (normalizedPath === normalizedDir) {
return true;
}
// Special case for root directory to avoid double slash
// On Windows, we need to check if both paths are on the same drive
if (normalizedDir === path.sep) {
return normalizedPath.startsWith(path.sep);
}
// On Windows, also check for drive root (e.g., "C:\")
if (path.sep === "\\" && normalizedDir.match(/^[A-Za-z]:\\?$/)) {
// Ensure both paths are on the same drive
const dirDrive = normalizedDir.charAt(0).toLowerCase();
const pathDrive = normalizedPath.charAt(0).toLowerCase();
return (
pathDrive === dirDrive &&
normalizedPath.startsWith(normalizedDir.replace(/\\?$/, "\\"))
);
}
// path.resolve strips a trailing separator except from a root, and a UNC
// share root (\\server\share\) keeps one, so appending another would give
// \\server\share\\, a prefix nothing matches (#3527). Append one only when
// it is missing; the boundary stays at a separator either way, so a
// sibling such as \\server\share-evil still does not match.
const dirWithSep = normalizedDir.endsWith(path.sep)
? normalizedDir
: normalizedDir + path.sep;
return normalizedPath.startsWith(dirWithSep);
});
}