Skip to content

Commit 3bd5144

Browse files
authored
release(v3.19.0): folder creation hardening
- security(folder): reject traversal segments before directory creation
1 parent 9de64e9 commit 3bd5144

3 files changed

Lines changed: 195 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## Changes 06/26/2026 (v3.19.0)
4+
5+
`release(v3.19.0): folder creation hardening`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.19.0): folder creation hardening
11+
12+
- security(folder): reject traversal segments before directory creation
13+
```
14+
15+
**Fixed**
16+
17+
- **Folder creation hardening**
18+
- Folder creation now rejects `.` and `..` path segments before creating directories.
19+
- Local folder creation now verifies the parent path stays inside the configured upload root before directory creation.
20+
- Existing valid child-folder and root-relative nested folder creation behavior is preserved.
21+
22+
---
23+
324
## Changes 06/23/2026 (v3.18.0)
425

526
`release(v3.18.0): file operation hardening`

src/FileRise/Domain/FolderModel.php

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,33 @@ private static function isSafeSegment(string $name): bool
607607
$len = mb_strlen($name);
608608
return $len > 0 && $len <= 255;
609609
}
610+
611+
private static function isValidFolderSegment(string $name): bool
612+
{
613+
return self::isSafeSegment($name) && preg_match(REGEX_FOLDER_NAME, $name) === 1;
614+
}
615+
616+
private static function splitFolderSegments(string $folder, bool $allowRoot = true): ?array
617+
{
618+
$normalized = ACL::normalizeFolder($folder);
619+
if ($normalized === 'root') {
620+
return $allowRoot ? [] : null;
621+
}
622+
623+
$parts = explode('/', $normalized);
624+
if ($parts === [] || in_array('', $parts, true)) {
625+
return null;
626+
}
627+
628+
foreach ($parts as $seg) {
629+
if (!self::isValidFolderSegment($seg)) {
630+
return null;
631+
}
632+
}
633+
634+
return $parts;
635+
}
636+
610637
private static function safeReal(string $baseReal, string $p): ?string
611638
{
612639
$rp = realpath($p);
@@ -1094,16 +1121,10 @@ private static function resolveFolderPath(string $folder, bool $create = false):
10941121
if (strtolower($folder) === 'root') {
10951122
$dir = $base;
10961123
} else {
1097-
// validate each segment against REGEX_FOLDER_NAME
1098-
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
1099-
if (empty($parts)) {
1124+
$parts = self::splitFolderSegments($folder);
1125+
if ($parts === null || $parts === []) {
11001126
return [null, 'root', "Invalid folder name."];
11011127
}
1102-
foreach ($parts as $seg) {
1103-
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
1104-
return [null, 'root', "Invalid folder name."];
1105-
}
1106-
}
11071128
$relative = implode('/', $parts);
11081129
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
11091130
}
@@ -1145,15 +1166,10 @@ private static function resolveFolderPathForAdapter(StorageAdapterInterface $sto
11451166
if (strtolower($folder) === 'root') {
11461167
$dir = $base;
11471168
} else {
1148-
$parts = array_filter(explode('/', trim($folder, "/\\ ")), fn($p) => $p !== '');
1149-
if (empty($parts)) {
1169+
$parts = self::splitFolderSegments($folder);
1170+
if ($parts === null || $parts === []) {
11501171
return [null, 'root', "Invalid folder name."];
11511172
}
1152-
foreach ($parts as $seg) {
1153-
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
1154-
return [null, 'root', "Invalid folder name."];
1155-
}
1156-
}
11571173
$relative = implode('/', $parts);
11581174
$dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
11591175
}
@@ -1533,14 +1549,25 @@ public static function createFolder(string $folderName, string $parent, string $
15331549
return ['success' => false, 'error' => 'Folder name required'];
15341550
}
15351551

1552+
$parentParts = self::splitFolderSegments($parent);
1553+
if ($parentParts === null) {
1554+
return ['success' => false, 'error' => 'Invalid parent folder name'];
1555+
}
1556+
$folderParts = self::splitFolderSegments($folderName, false);
1557+
if ($folderParts === null || count($folderParts) !== 1) {
1558+
return ['success' => false, 'error' => 'Invalid folder name'];
1559+
}
1560+
1561+
$parent = $parentParts === [] ? 'root' : implode('/', $parentParts);
1562+
$folderName = $folderParts[0];
1563+
15361564
// ACL key for new folder
15371565
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
15381566

15391567
// -------- Compose filesystem paths --------
15401568
$base = rtrim(self::uploadRoot(), "/\\");
15411569
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
15421570
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
1543-
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
15441571
$storage = self::storage();
15451572
$isLocal = $storage->isLocal();
15461573

@@ -1552,6 +1579,21 @@ public static function createFolder(string $folderName, string $parent, string $
15521579
if (!$parentExists) {
15531580
return ['success' => false, 'error' => 'Parent folder does not exist'];
15541581
}
1582+
1583+
if ($isLocal) {
1584+
$baseReal = realpath(self::uploadRoot());
1585+
$parentReal = realpath($parentAbs);
1586+
if (
1587+
$baseReal === false ||
1588+
$parentReal === false ||
1589+
!self::isPathInsideOrSame($baseReal, $parentReal)
1590+
) {
1591+
return ['success' => false, 'error' => 'Invalid folder path'];
1592+
}
1593+
$parentAbs = $parentReal;
1594+
}
1595+
1596+
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
15551597
if ($storage->stat($newAbs) !== null) {
15561598
return ['success' => false, 'error' => 'Folder already exists'];
15571599
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
$baseDir = dirname(__DIR__, 2);
5+
$tmpBase = $baseDir . '/tests/.tmp_create_folder_traversal_' . bin2hex(random_bytes(4));
6+
$uploadDir = $tmpBase . '/uploads/';
7+
$usersDir = $tmpBase . '/users/';
8+
$metaDir = $tmpBase . '/metadata/';
9+
$sessionDir = $tmpBase . '/sessions/';
10+
11+
function createFolderTraversalFailIf(bool $cond, string $message, array &$errors): void
12+
{
13+
if ($cond) {
14+
$errors[] = $message;
15+
}
16+
}
17+
18+
function createFolderTraversalRmTree(string $dir): void
19+
{
20+
if (!file_exists($dir) && !is_link($dir)) {
21+
return;
22+
}
23+
if (is_link($dir) || is_file($dir)) {
24+
@unlink($dir);
25+
return;
26+
}
27+
$items = scandir($dir);
28+
if ($items === false) {
29+
return;
30+
}
31+
foreach ($items as $item) {
32+
if ($item === '.' || $item === '..') {
33+
continue;
34+
}
35+
createFolderTraversalRmTree($dir . DIRECTORY_SEPARATOR . $item);
36+
}
37+
@rmdir($dir);
38+
}
39+
40+
@mkdir($uploadDir . 'bob', 0775, true);
41+
@mkdir($usersDir, 0700, true);
42+
@mkdir($metaDir, 0775, true);
43+
@mkdir($sessionDir, 0700, true);
44+
session_save_path($sessionDir);
45+
46+
putenv('FR_TEST_UPLOAD_DIR=' . $uploadDir);
47+
putenv('FR_TEST_USERS_DIR=' . $usersDir);
48+
putenv('FR_TEST_META_DIR=' . $metaDir);
49+
putenv('PERSISTENT_TOKENS_KEY=test_persistent_tokens_key_32bytes!');
50+
51+
require_once $baseDir . '/config/config.php';
52+
require_once $baseDir . '/src/FileRise/Domain/FolderModel.php';
53+
54+
$errors = [];
55+
$escapedPath = $tmpBase . '/escape';
56+
57+
try {
58+
$escapeResult = \FileRise\Domain\FolderModel::createFolder('../../escape', 'bob', 'bob');
59+
createFolderTraversalFailIf(
60+
!empty($escapeResult['success']),
61+
'createFolder: traversal folderName should be rejected',
62+
$errors
63+
);
64+
createFolderTraversalFailIf(
65+
is_dir($escapedPath),
66+
'createFolder: traversal folderName created directory outside upload root',
67+
$errors
68+
);
69+
70+
$dotDotResult = \FileRise\Domain\FolderModel::createFolder('..', 'bob', 'bob');
71+
createFolderTraversalFailIf(
72+
!empty($dotDotResult['success']),
73+
'createFolder: dot-dot leaf name should be rejected',
74+
$errors
75+
);
76+
77+
$badParentResult = \FileRise\Domain\FolderModel::createFolder('child', 'bob/..', 'bob');
78+
createFolderTraversalFailIf(
79+
!empty($badParentResult['success']),
80+
'createFolder: traversal parent should be rejected',
81+
$errors
82+
);
83+
84+
$safeResult = \FileRise\Domain\FolderModel::createFolder('safe', 'bob', 'bob');
85+
createFolderTraversalFailIf(
86+
empty($safeResult['success']),
87+
'createFolder: valid child folder should succeed',
88+
$errors
89+
);
90+
createFolderTraversalFailIf(
91+
!is_dir($uploadDir . 'bob/safe'),
92+
'createFolder: valid child folder was not created',
93+
$errors
94+
);
95+
96+
$nestedResult = \FileRise\Domain\FolderModel::createFolder('bob/nested', 'root', 'bob');
97+
createFolderTraversalFailIf(
98+
empty($nestedResult['success']),
99+
'createFolder: root-relative nested folder behavior should be preserved',
100+
$errors
101+
);
102+
createFolderTraversalFailIf(
103+
!is_dir($uploadDir . 'bob/nested'),
104+
'createFolder: root-relative nested folder was not created',
105+
$errors
106+
);
107+
} finally {
108+
createFolderTraversalRmTree($tmpBase);
109+
}
110+
111+
if ($errors) {
112+
fwrite(STDERR, "Create-folder path traversal regression failures:\n- " . implode("\n- ", $errors) . "\n");
113+
exit(1);
114+
}
115+
116+
echo "Create-folder path traversal regressions passed\n";

0 commit comments

Comments
 (0)