Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions emhttp/plugins/dynamix/nchan/file_manager
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,119 @@ function validname($name, $real=true) {
return in_array($root, ['mnt','boot']) ? $path.'/'.basename($name).(mb_substr($name,-1) == '/' ? '/' : '') : '';
}

function rsync_share_is_exclusive($share) {
global $docroot;
$state = rtrim($docroot ?? '/usr/local/emhttp', '/') . '/state/shares.ini';
$shares = @parse_ini_file($state, true);
return is_array($shares) && in_array((string)($shares[$share]['exclusive'] ?? ''), ['yes', '1'], true);
}

/**
* Return the physical path for an OS-managed exclusive-share link.
*
* The link is trusted only when it is an exclusive share, its system location
* matches the link target, and it points to a configured storage device.
*
* @param string $path The symlink path.
* @return string The trusted physical target, or an empty string otherwise.
*/
function rsync_exclusive_share_target($path) {
global $docroot;
if (!preg_match('#^/mnt/(user|user0)/([^/]+)$#', $path, $matches)) return '';

$share = $matches[2];
if (!rsync_share_is_exclusive($share)) return '';

$link_target = @readlink($path);
if (!is_string($link_target) || $link_target === '') return '';
if (mb_substr($link_target, 0, 1) !== '/') $link_target = dirname($path) . '/' . $link_target;
$target = truepath($link_target);

if (!preg_match('#^/mnt/([^/]+)/([^/]+)$#', $target, $target_parts)) return '';
if ($target_parts[2] !== $share || in_array($target_parts[1], ['user', 'user0', 'rootshare'], true)) return '';

// system.LOCATION is maintained by Unraid and identifies the backing
// storage for an exclusive user share. It prevents a similarly shaped link
// from being treated as the OS-managed mapping.
$location = trim((string)@shell_exec("getfattr --no-dereference --absolute-names --only-values -n system.LOCATION " . escapeshellarg($path) . " 2>/dev/null"));
if (!preg_match('/^[A-Za-z0-9._-]+$/D', $location) || $location !== $target_parts[1]) return '';

$disks_state = rtrim($docroot ?? '/usr/local/emhttp', '/') . '/state/disks.ini';
$disks = @parse_ini_file($disks_state, true);
if (!is_array($disks) || !array_key_exists($target_parts[1], $disks)) return '';

return is_dir($target) ? $target : '';
}

/**
* Resolve a validated destination without following arbitrary symlinks.
*
* Only the OS-managed exclusive-share link is translated to its physical
* backing path. All other symlink components are rejected so the compatibility
* path cannot bypass rsync's symlink policy with a pre-resolved attacker-
* controlled path.
*
* @param string $name The validated destination path.
* @return string The resolved path, or an empty string when resolution fails.
*/
function resolve_rsync_path($name) {
$trailing_slash = mb_substr($name, -1) == '/';
$pending = array_values(array_filter(explode('/', trim($name, '/')), 'mb_strlen'));
$resolved = [];
$symlink_count = 0;

while (!empty($pending)) {
$part = array_shift($pending);
if ($part === '.' || $part === '') continue;
if ($part === '..') {
if (!empty($resolved)) array_pop($resolved);
continue;
}

$candidate = '/' . implode('/', array_merge($resolved, [$part]));
$stat = @lstat($candidate);
if ($stat === false) {
$resolved[] = $part;
foreach ($pending as $remaining) {
if ($remaining !== '') $resolved[] = $remaining;
}
break;
}

if ((($stat['mode'] ?? 0) & 0170000) === 0120000) {
if (++$symlink_count > 40) return '';
$target = rsync_exclusive_share_target($candidate);
if ($target === '') return '';

// Restart from the absolute trusted target so its components are still
// checked rather than blindly accepting a second symlink.
$resolved = [];
$pending = array_merge(array_values(array_filter(explode('/', trim($target, '/')), 'mb_strlen')), $pending);
continue;
}

$resolved[] = $part;
}

$path = '/' . implode('/', $resolved);
return $path . ($trailing_slash && $path !== '/' ? '/' : '');
}

/**
* Validate and resolve a destination for an rsync operation.
*
* @param string $name The requested destination path.
* @return string The safe rsync destination, or an empty string when invalid.
*/
function rsync_target($name) {
$target = validname($name, false);
if (!$target) return '';

$target = resolve_rsync_path($target);
$root = explode('/', $target)[1] ?? '';
return in_array($root, ['mnt','boot']) ? $target : '';
}

function update_translation($locale) {
global $docroot,$language;
$language = [];
Expand Down Expand Up @@ -456,7 +569,7 @@ while (true) {
// start action
} else {
parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
$target = rsync_target($target);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
$cmd = "rsync -ahPIX$H $sparse $exist $mkpath --no-inc-recursive --out-format=%f --info=flist0,misc0,stats0,name1,progress2 ".quoted($source)." ".escapeshellarg($target)." > >(stdbuf -o0 tr '\\r' '\\n' >$status) 2>$error & echo \$!";
Expand Down Expand Up @@ -500,7 +613,7 @@ while (true) {
// start action
} else {
parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
$target = rsync_target($target);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';

Expand Down
103 changes: 103 additions & 0 deletions tests/file-manager-rsync-target-path.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);

function extractFunction(string $source, string $name): string
{
$source = preg_replace('/<\?(?!php|=|xml)/i', '<?php', $source) ?? $source;
$tokens = token_get_all($source);
$capturing = false;
$braces = 0;
$inDoubleQuote = false;
$code = '';
$tokenCount = count($tokens);

for ($i = 0; $i < $tokenCount; $i++) {
$token = $tokens[$i];
if (!$capturing && is_array($token) && $token[0] === T_FUNCTION) {
$j = $i + 1;
while ($j < $tokenCount && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) $j++;
if ($j < $tokenCount && $tokens[$j] === '&') $j++;
while ($j < $tokenCount && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) $j++;
if ($j >= $tokenCount || !is_array($tokens[$j]) || $tokens[$j][0] !== T_STRING || $tokens[$j][1] !== $name) continue;
$capturing = true;
}

if (!$capturing) continue;

$code .= is_array($token) ? $token[1] : $token;
if (is_string($token)) {
if ($token === '"') {
$inDoubleQuote = !$inDoubleQuote;
} elseif (!$inDoubleQuote && $token === '{') {
$braces++;
} elseif (!$inDoubleQuote && $token === '}' && --$braces === 0) {
break;
}
}
}

if ($code === '') throw new RuntimeException("Could not extract $name");
return $code;
}

function assertSameValue(string $expected, string $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, $expected, $actual));
}
}

function assertContainsText(string $needle, string $haystack, string $message): void
{
if (!str_contains($haystack, $needle)) throw new RuntimeException($message);
}

function assertNotContainsText(string $needle, string $haystack, string $message): void
{
if (str_contains($haystack, $needle)) throw new RuntimeException($message);
}

$fileManager = dirname(__DIR__) . '/emhttp/plugins/dynamix/nchan/file_manager';
$fileManagerSource = file_get_contents($fileManager);
if ($fileManagerSource === false) throw new RuntimeException('Could not read the File Manager source.');

foreach (['truepath', 'validname', 'rsync_share_is_exclusive', 'rsync_exclusive_share_target', 'resolve_rsync_path', 'rsync_target'] as $function) {
eval(extractFunction($fileManagerSource, $function));
}

assertNotContainsText('realpath(', extractFunction($fileManagerSource, 'resolve_rsync_path'), 'The rsync path resolver must not follow arbitrary symlinks with realpath().');
assertContainsText('--no-dereference', extractFunction($fileManagerSource, 'rsync_exclusive_share_target'), 'The exclusive-share location lookup must inspect the symlink itself.');

assertSameValue('', rsync_target('/tmp/outside/'), 'A target outside the allowed roots must be rejected.');

$copyStart = strpos($fileManagerSource, 'case 3: // copy folder');
$moveStart = strpos($fileManagerSource, 'case 4: // move folder');
$moveEnd = strpos($fileManagerSource, 'case 11: // change owner', $moveStart ?: 0);
if ($copyStart === false || $moveStart === false || $moveEnd === false || $copyStart >= $moveStart) {
throw new RuntimeException('Could not isolate copy and move operation bodies.');
}

$copyBody = substr($fileManagerSource, $copyStart, $moveStart - $copyStart);
$moveBody = substr($fileManagerSource, $moveStart, $moveEnd - $moveStart);
assertContainsText('$target = rsync_target($target);', $copyBody, 'Copy must use the rsync target adapter.');
assertContainsText('$target = rsync_target($target);', $moveBody, 'Move must use the rsync target adapter.');

$root = sys_get_temp_dir() . '/unraid-file-manager-path-' . bin2hex(random_bytes(6));
$real = "$root/real";
$link = "$root/link";

try {
if (!mkdir($real, 0777, true) || !symlink($real, $link)) {
throw new RuntimeException('Could not create the symlink test fixture.');
}

assertSameValue('', resolve_rsync_path("$link/"), 'An unrecognized symlink destination must be rejected.');
assertSameValue('', resolve_rsync_path("$link/new/deep/"), 'A missing descendant under an unrecognized symlink must be rejected.');
} finally {
if (is_link($link)) unlink($link);
if (is_dir($real)) rmdir($real);
if (is_dir($root)) rmdir($root);
}

echo "File Manager rsync target path regression test passed.\n";
Loading