diff --git a/Panel/js/modules/litefm.js b/Panel/js/modules/litefm.js
index 32f10bec..9de261a6 100644
--- a/Panel/js/modules/litefm.js
+++ b/Panel/js/modules/litefm.js
@@ -234,7 +234,7 @@ function downloadFile(home_id, file_id, file_size, file_name)
function getFilePart()
{
var xhr = new XMLHttpRequest();
- xhr.open('POST', "home.php?m=litefm&home_id="+home_id+"&item="+file_id+"&name="+file_name+"&p=get&type=cleared&size="+file_size+"&did="+did, true);
+ xhr.open('POST', "home.php?m=litefm&home_id="+home_id+"&item="+file_id+"&name="+encodeURIComponent(file_name)+"&p=get&type=cleared&size="+file_size+"&did="+did, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function(e) {
if(this.response.byteLength != 0)
@@ -283,6 +283,15 @@ function downloadFile(home_id, file_id, file_size, file_name)
}
$(document).ready(function(){
+ function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
/* translation & info */
var select_at_least_one_item = $('#dialog').attr('data-select_at_least_one_item'),
ask_delete = $('#dialog').attr('data-ask_delete'),
@@ -389,7 +398,7 @@ $(document).ready(function(){
item = $(this).attr('data-item');
value = $(this).attr('value');
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
});
if(items != '')
{
@@ -434,9 +443,9 @@ $(document).ready(function(){
var items = '';
$('input[class="item"]:checked').each(function(){
item = $(this).attr('data-item');
- value = $(this).attr('value').replace('"', """);
+ value = $(this).attr('value');
addpost.items.push(item);
- items += "
";
+ items += "
";
});
if(items != '')
{
@@ -487,7 +496,7 @@ $(document).ready(function(){
item = $(this).attr('data-item');
value = $(this).attr('value');
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
});
if(items != '')
{
@@ -550,7 +559,7 @@ $(document).ready(function(){
item = $(this).attr('data-item');
value = $(this).attr('value');
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
});
if(items != '')
{
@@ -611,7 +620,7 @@ $(document).ready(function(){
item = $(this).attr('data-item');
value = $(this).attr('value');
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
});
if(items != '')
{
@@ -675,7 +684,7 @@ $(document).ready(function(){
if(ext.match(/^(tar|tgz|gz|Z|zip|bz2|tbz|lzma|xz|txz)$/i) != null)
{
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
}
});
if(items != '')
@@ -902,7 +911,7 @@ $(document).ready(function(){
}
$.ajax({
type: "POST",
- url: 'home.php?m=litefm&home_id='+home_id+'&type=cleared&pid='+file['pid']+'&size='+file['size']+'&filename='+file['filename']+"&data_type=json",
+ url: 'home.php?m=litefm&home_id='+home_id+'&type=cleared&pid='+file['pid']+'&size='+file['size']+'&filename='+encodeURIComponent(file['filename'])+"&data_type=json",
dataType:'json',
async: false,
success: function(data){
@@ -978,7 +987,7 @@ $(document).ready(function(){
addpost.set_attr = $(this).attr('data-set_attr'),
addpost.file_name = $(this).attr('data-file_name');
addpost.item = $(this).attr('data-item');
- $('#dialog').html(ask_change_attr.replace("%file_name%",addpost.file_name));
+ $('#dialog').html(ask_change_attr.replace("%file_name%",escapeHtml(addpost.file_name)));
$('#dialog').dialog({
autoOpen: true,
width: 450,
@@ -1016,7 +1025,7 @@ $(document).ready(function(){
item = $(this).attr('data-item');
value = $(this).attr('value');
addpost.items.push(item);
- items += "
"+value;
+ items += "
"+escapeHtml(value);
});
if(items != '')
{
diff --git a/Panel/modules/ftp/monitor_buttons.php b/Panel/modules/ftp/monitor_buttons.php
index 21b3c639..89d92e0b 100644
--- a/Panel/modules/ftp/monitor_buttons.php
+++ b/Panel/modules/ftp/monitor_buttons.php
@@ -27,6 +27,9 @@ if(preg_match("/t/",$server_home['access_rights']) > 0)
{
$module_buttons = array();
+ // WE DONT WANT A FTP BUTTON, BUT WE DO NEED THE FTP FUNCTIONALITY, SO WE WILL NOT ADD A FTP BUTTON TO THE GAME MONITOR, BUT WE WILL STILL ALLOW THE USER TO ACCESS THE FTP MODULE FOR THIS HOME
+ return;
+
$module_buttons = array(
"
diff --git a/Panel/modules/litefm/fm_dir.php b/Panel/modules/litefm/fm_dir.php
index d2862bb6..724b5945 100644
--- a/Panel/modules/litefm/fm_dir.php
+++ b/Panel/modules/litefm/fm_dir.php
@@ -68,30 +68,29 @@ function exec_ogp_module()
if (!isset($_SESSION[$cwd_session_key]) || !is_string($_SESSION[$cwd_session_key])) {
$_SESSION[$cwd_session_key] = '';
}
- $path = clean_path($home_cfg['home_path']."/".$_SESSION[$cwd_session_key]);
+ $path = litefm_safe_join_home_path($home_cfg['home_path'], $_SESSION[$cwd_session_key]);
+ if ($path === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ echo "
";
+ return;
+ }
+
+ $home_root = clean_path($home_cfg['home_path']);
+ if (!$remote->rfile_exists($home_root))
+ {
+ print_failure("Server files have not been installed yet.");
+ echo "";
+ return;
+ }
+
if (!$remote->rfile_exists($path))
{
- while(!$remote->rfile_exists($path))
- {
- $current_cwd = isset($_SESSION[$cwd_session_key]) ? (string)$_SESSION[$cwd_session_key] : '';
- if ($current_cwd === '' || $current_cwd === '.' || $current_cwd === DIRECTORY_SEPARATOR) {
- print_failure("Server files have not been installed yet.");
- echo "";
- return;
- }
- $parent_cwd = dirname($current_cwd);
- if (!is_string($parent_cwd) || $parent_cwd === '.' || $parent_cwd === DIRECTORY_SEPARATOR) {
- $parent_cwd = '';
- }
- $_SESSION[$cwd_session_key] = $parent_cwd;
- $path = clean_path($home_cfg['home_path']."/".$_SESSION[$cwd_session_key]);
- if($path == clean_path($home_cfg['home_path']."/"))
- {
- print_failure("Server files have not been installed yet.");
- echo "";
- return;
- }
- }
+ $requested_rel_path = isset($_SESSION[$cwd_session_key]) ? trim((string)$_SESSION[$cwd_session_key], '/') : '';
+ $requested_rel_path = $requested_rel_path === '' ? '/' : $requested_rel_path;
+ print_failure("Directory not found: " . litefm_escape_html($requested_rel_path));
+ echo "";
+ return;
}
// Get File Operations Keys
@@ -112,7 +111,12 @@ function exec_ogp_module()
{
$bytes = $_GET['size'];
$totalsize = $bytes / 1024;
- $filename = $_GET['filename'];
+ $filename = litefm_decode_name_param($_GET['filename']);
+ if (!litefm_is_valid_path_component($filename))
+ {
+ echo json_encode(array('pct' => 0, 'complete' => false));
+ return;
+ }
$kbytes = $remote->rsync_progress( clean_path( $path."/".$filename ) );
list($totalsize,$mbytes,$pct) = explode(";",do_progress($kbytes,$totalsize));
$totalmbytes = round($totalsize / 1024, 2);
@@ -177,10 +181,9 @@ function exec_ogp_module()
// if file not uploaded then skip it
if ( !is_uploaded_file($_FILES['files']['tmp_name'][$i]) )
continue;
- // now we can move uploaded files
- $bad_chars = preg_replace( "/([[:alnum:]_\.-]*)/", "", $_FILES['files']['name'][$i] );
- $bad_arr = str_split( $bad_chars );
- $filename = str_replace( $bad_arr, "", $_FILES['files']['name'][$i] );
+ $filename = basename((string)$_FILES['files']['name'][$i]);
+ if (!litefm_is_valid_path_component($filename))
+ continue;
$dest_file_path = clean_path( $upload_folder_path . "/" . $filename . ".txt" );
$file_url = str_replace( "home.php", $dest_file_path, $url );
if( file_exists( $dest_file_path ) )
@@ -205,7 +208,17 @@ function exec_ogp_module()
elseif( isset( $_POST['create_folder'] ) and $fo['create_folder'] == "1" )
{
$folder_name = stripslashes($_POST['folder_name']);
+ if (!litefm_is_valid_path_component($folder_name))
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
$folder_path = clean_path( $path . "/" . $folder_name );
+ if (!litefm_path_within_home($home_cfg['home_path'], $folder_path))
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
$remote->shell_action('create_dir', $folder_path);
$db->logger( get_lang("create_folder") . ": " . $folder_path );
}
@@ -242,8 +255,18 @@ function exec_ogp_module()
if(isset($_SESSION['fm_files_'.$home_id][$item]))
{
$item_path = clean_path( $path . "/" . $_SESSION['fm_files_'.$home_id][$item] );
- $new_item = removeInvalidFileNameCharacters(stripslashes($_POST['values'][$i]));
+ $new_item = stripslashes($_POST['values'][$i]);
+ if (!litefm_is_valid_path_component($new_item))
+ {
+ print_failure(get_lang("unallowed_char"));
+ continue;
+ }
$new_item_path = clean_path( $path . "/" . $new_item );
+ if (!litefm_path_within_home($home_cfg['home_path'], $new_item_path))
+ {
+ print_failure(get_lang("unallowed_char"));
+ continue;
+ }
if ($item_path != $new_item_path)
{
$remote->shell_action('rename', "$item_path;$new_item_path");
@@ -256,8 +279,18 @@ function exec_ogp_module()
// Move Files/Folders
elseif( isset( $_POST['move'] ) and $fo['move'] == "1" )
{
- $selected_path = preg_replace("#[/\.\./]+#","/", stripslashes($_POST['selected_path']));
- $destination = clean_path($home_cfg['home_path']. "/" . $selected_path);
+ $selected_path = litefm_normalize_relative_path(stripslashes($_POST['selected_path']));
+ if ($selected_path === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
+ $destination = litefm_safe_join_home_path($home_cfg['home_path'], $selected_path);
+ if ($destination === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
if($path != $destination)
{
if($remote->rfile_exists($destination))
@@ -278,8 +311,18 @@ function exec_ogp_module()
// Copy Files/Folders
elseif( isset( $_POST['copy'] ) and $fo['copy'] == "1" )
{
- $selected_path = preg_replace("#[/\.\./]+#","/", stripslashes($_POST['selected_path']));
- $destination = clean_path($home_cfg['home_path']. "/" . $selected_path);
+ $selected_path = litefm_normalize_relative_path(stripslashes($_POST['selected_path']));
+ if ($selected_path === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
+ $destination = litefm_safe_join_home_path($home_cfg['home_path'], $selected_path);
+ if ($destination === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
if($path != $destination)
{
if($remote->rfile_exists($destination))
@@ -320,8 +363,18 @@ function exec_ogp_module()
// uncompress
elseif( isset( $_POST['uncompress'] ) and $fo['uncompress'] == "1" )
{
- $selected_path = preg_replace("#[/\.\./]+#","/", stripslashes($_POST['selected_path']));
- $destination = clean_path($home_cfg['home_path']. "/" . $selected_path);
+ $selected_path = litefm_normalize_relative_path(stripslashes($_POST['selected_path']));
+ if ($selected_path === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
+ $destination = litefm_safe_join_home_path($home_cfg['home_path'], $selected_path);
+ if ($destination === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
if($remote->rfile_exists($destination))
{
foreach ((array)$_POST['items'] as $item)
@@ -338,8 +391,20 @@ function exec_ogp_module()
// Create file
elseif( isset( $_POST['create_file'] ) and $fo['create_file'] == "1" )
{
- $file_name = removeInvalidFileNameCharacters(stripslashes($_POST['file_name']));
+ $file_name = stripslashes($_POST['file_name']);
+ if (!litefm_is_valid_path_component($file_name))
+ {
+ print_failure(get_lang("unallowed_char"));
+ $file_name = "";
+ }
+ if ($file_name === "")
+ return;
$destination = clean_path( $path . "/" . $file_name);
+ if (!litefm_path_within_home($home_cfg['home_path'], $destination))
+ {
+ print_failure(get_lang("unallowed_char"));
+ return;
+ }
$remote->shell_action('touch', $destination);
$db->logger( get_lang("create_file") . ": $destination" );
}
@@ -437,7 +502,12 @@ function exec_ogp_module()
if (!is_array($dirlist))
{
- if(isset($_SESSION['fm_cwd_'.$home_id]))
+ if ($remote->rfile_exists($path))
+ {
+ print_failure("Directory exists but cannot be opened. Check permissions.");
+ return;
+ }
+ elseif(isset($_SESSION['fm_cwd_'.$home_id]))
{
unset($_SESSION['fm_cwd_'.$home_id]);
$view->refresh("?m=litefm&home_id=$home_id",0);
@@ -475,21 +545,25 @@ function exec_ogp_module()
$dirlist['directorys'] = array_orderby($dirlist['directorys'], 'filename', SORT_ASC);
foreach ((array)$dirlist['directorys'] as $directory)
{
- $directory['filename'] = removeInvalidFileNameCharacters($directory['filename']);
+ $directoryName = $directory['filename'];
+ if (!litefm_is_valid_path_component($directoryName))
+ continue;
+ $encodedDirectoryName = rawurlencode($directoryName);
+ $escapedDirectoryName = litefm_escape_html($directoryName);
echo "\n".
"| ".
- "\n".
+ "\n".
" | ".
"".
" ".
- "".
- $directory['filename'] . " | ";
+ "".
+ $escapedDirectoryName . "";
if( $os == "linux" )
echo "- | ";
echo "- | " . $directory['user'] . " " . $directory['group']. " | \n".
"
\n";
- $_SESSION['fm_files_'.$home_id][$i] = $directory['filename'];
+ $_SESSION['fm_files_'.$home_id][$i] = $directoryName;
$i++;
}
}
@@ -499,12 +573,16 @@ function exec_ogp_module()
$dirlist['files'] = array_orderby($dirlist['files'], 'filename', SORT_ASC);
foreach ((array)$dirlist['files'] as $file)
{
- $file['filename'] = removeInvalidFileNameCharacters($file['filename']);
+ $fileName = $file['filename'];
+ if (!litefm_is_valid_path_component($fileName))
+ continue;
+ $encodedFileName = rawurlencode($fileName);
+ $escapedFileName = litefm_escape_html($fileName);
if( $os == "linux" )
{
if($isAdmin){
- $secureFile = "\n".
" ".
- "\n".
+ "\n".
" | ".
" ";
echo " ".
- "". get_lang("button_edit") ."".
- "" .$file['filename'] . " ".
+ "". get_lang("button_edit") ."".
+ "" .$escapedFileName . " ".
" | $secureFile " . $file['size'] . " | " . $file['user'] . " " . $file['group']. " | \n";
echo "\n";
- $_SESSION['fm_files_'.$home_id][$i] = $file['filename'];
+ $_SESSION['fm_files_'.$home_id][$i] = $fileName;
$i++;
}
}
@@ -544,12 +622,15 @@ function exec_ogp_module()
$dirlist['binarys'] = array_orderby($dirlist['binarys'], 'filename', SORT_ASC);
foreach ((array)$dirlist['binarys'] as $binary)
{
- $binary['filename'] = removeInvalidFileNameCharacters($binary['filename']);
+ $binaryName = $binary['filename'];
+ if (!litefm_is_valid_path_component($binaryName))
+ continue;
+ $escapedBinaryName = litefm_escape_html($binaryName);
if( $os == "linux" )
{
if($isAdmin){
- $secureFile = " \n".
" ".
- "\n".
+ "\n".
" | ".
" ";
echo " ".
- "" .$binary['filename'] . " ".
+ "" .$escapedBinaryName . " ".
" | $secureFile " . $binary['size'] . " | " . $binary['user'] . " " . $binary['group']. " | \n";
echo "\n";
- $_SESSION['fm_files_'.$home_id][$i] = $binary['filename'];
+ $_SESSION['fm_files_'.$home_id][$i] = $binaryName;
$i++;
}
}
@@ -589,7 +670,7 @@ function exec_ogp_module()
// Dialog translation && info
$user = $db->getUserById($_SESSION['user_id']);
echo " ";
}
}
diff --git a/Panel/modules/litefm/litefm.php b/Panel/modules/litefm/litefm.php
index c73e2f88..96312dc8 100644
--- a/Panel/modules/litefm/litefm.php
+++ b/Panel/modules/litefm/litefm.php
@@ -24,6 +24,74 @@
require_once('includes/lib_remote.php');
+function litefm_decode_name_param($value)
+{
+ return rawurldecode((string)$value);
+}
+
+function litefm_escape_html($value)
+{
+ return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
+}
+
+function litefm_is_valid_path_component($name)
+{
+ if (!is_string($name) || $name === '' || $name === '.' || $name === '..') {
+ return false;
+ }
+ if (strpos($name, "\0") !== false || strpos($name, '/') !== false || strpos($name, '\\') !== false) {
+ return false;
+ }
+ return true;
+}
+
+function litefm_normalize_relative_path($relativePath)
+{
+ $relativePath = str_replace('\\', '/', (string)$relativePath);
+ $relativePath = preg_replace('#/+#', '/', $relativePath);
+ $relativePath = trim($relativePath, '/');
+ if ($relativePath === '') {
+ return '';
+ }
+ if (strpos($relativePath, "\0") !== false) {
+ return false;
+ }
+ if (preg_match('#(^|/)\.{1,2}(/|$)#', $relativePath)) {
+ return false;
+ }
+ foreach (explode('/', $relativePath) as $segment) {
+ if (!litefm_is_valid_path_component($segment)) {
+ return false;
+ }
+ }
+ return $relativePath;
+}
+
+function litefm_path_within_home($homePath, $candidatePath)
+{
+ $homeNorm = str_replace('\\', '/', clean_path((string)$homePath));
+ $candidateNorm = str_replace('\\', '/', clean_path((string)$candidatePath));
+ $homeCmp = strtolower(rtrim($homeNorm, '/'));
+ $candidateCmp = strtolower($candidateNorm);
+ if ($candidateCmp === $homeCmp) {
+ return true;
+ }
+ return strpos($candidateCmp, $homeCmp . '/') === 0;
+}
+
+function litefm_safe_join_home_path($homePath, $relativePath)
+{
+ $normalizedRel = litefm_normalize_relative_path($relativePath);
+ if ($normalizedRel === false) {
+ return false;
+ }
+ $fullPath = clean_path(rtrim((string)$homePath, '/') . '/' . $normalizedRel);
+ if (!litefm_path_within_home($homePath, $fullPath)) {
+ return false;
+ }
+ return $fullPath;
+}
+
function do_progress($kbytes,$totalsize)
{
if( $totalsize != 0 )
@@ -54,7 +122,12 @@ function litefm_check($home_id)
{
if (isset($_GET['item']) and !isset($_GET['upload']) and !isset( $_POST['delete'] ) and !isset( $_POST['create_folder'] ) and !isset( $_POST['secureButton'] ) and !isset( $_POST['delete_check'] ) and !isset( $_POST['secure_check'] ))
{
- $fileName = !empty($_POST['name']) ? urldecode($_POST['name']) : urldecode($_GET['name']);
+ $fileName = !empty($_POST['name']) ? litefm_decode_name_param($_POST['name']) : litefm_decode_name_param(isset($_GET['name']) ? $_GET['name'] : '');
+ if (!litefm_is_valid_path_component($fileName))
+ {
+ print_failure("Path decode failed");
+ return FALSE;
+ }
if(isset($_GET['type'])){
$type = $_GET['type'];
}else{
@@ -66,23 +139,29 @@ function litefm_check($home_id)
$path = $_SESSION['fm_files_'.$home_id][$_GET['item']];
if($path == $fileName){
- // Make sure nobody tries to get outside thier game server by referencing the .. directory
- if(preg_match("/\/\.\.\/|\||;/", $path))
- {
- print_failure(get_lang("unallowed_char"));
- $_SESSION['fm_cwd_'.$home_id] = NULL;
- return FALSE;
- }
- else
- {
- if($type != "file"){
- $_SESSION['fm_cwd_'.$home_id] = @$_SESSION['fm_cwd_'.$home_id] . "/" . $path;
- $_SESSION['fm_cwd_'.$home_id] = clean_path($_SESSION['fm_cwd_'.$home_id]);
- }else{
- if((isset($_SESSION['fm_cwd_'.$home_id]) and !endsWith($_SESSION['fm_cwd_'.$home_id], $path)) or !isset($_SESSION['fm_cwd_'.$home_id])){
- $_SESSION['fm_cwd_'.$home_id] = @$_SESSION['fm_cwd_'.$home_id] . "/" . $path;
- $_SESSION['fm_cwd_'.$home_id] = clean_path($_SESSION['fm_cwd_'.$home_id]);
+ if($type != "file"){
+ $nextPath = trim((string)@$_SESSION['fm_cwd_'.$home_id], '/');
+ $nextPath = $nextPath === '' ? $path : $nextPath . '/' . $path;
+ $normalizedNext = litefm_normalize_relative_path($nextPath);
+ if($normalizedNext === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ $_SESSION['fm_cwd_'.$home_id] = NULL;
+ return FALSE;
+ }
+ $_SESSION['fm_cwd_'.$home_id] = $normalizedNext;
+ }else{
+ if((isset($_SESSION['fm_cwd_'.$home_id]) and !endsWith($_SESSION['fm_cwd_'.$home_id], $path)) or !isset($_SESSION['fm_cwd_'.$home_id])){
+ $nextPath = trim((string)@$_SESSION['fm_cwd_'.$home_id], '/');
+ $nextPath = $nextPath === '' ? $path : $nextPath . '/' . $path;
+ $normalizedNext = litefm_normalize_relative_path($nextPath);
+ if($normalizedNext === false)
+ {
+ print_failure(get_lang("unallowed_char"));
+ $_SESSION['fm_cwd_'.$home_id] = NULL;
+ return FALSE;
}
+ $_SESSION['fm_cwd_'.$home_id] = $normalizedNext;
}
}
}
diff --git a/docs/modules/litefm.md b/docs/modules/litefm.md
index 346eef2e..24beb4e1 100644
--- a/docs/modules/litefm.md
+++ b/docs/modules/litefm.md
@@ -26,12 +26,23 @@ In-panel file manager and file editor for customer server homes.
- remote file listings
- read/write operations
+Current listing flow uses `remote_dirlistfm`, where agent filenames are treated as exact values and preserved by the Panel.
+
## User Workflow
- browse files
- upload/download
- edit common configs
+Special-character names are supported end-to-end in browse/navigation/actions, including:
+
+- `@3739421199`
+- `@CF`
+- `My Mod Folder`
+- `[Server] Configs`
+- `profile.backup`
+- `mod+keys`
+
## Admin Workflow
- set file management permissions
@@ -43,9 +54,22 @@ In-panel file manager and file editor for customer server homes.
- protected control files
- shared secret exposure
+Path safety model:
+
+- Preserve exact filename display and action values (no destructive filename stripping).
+- Escape output for HTML rendering only.
+- Encode filename tokens in links using `rawurlencode`; decode with `rawurldecode`.
+- Reject invalid path components (`..`, path separators, NUL byte).
+- Normalize relative paths and verify final resolved paths remain inside the assigned game home.
+- Distinguish user-facing errors:
+ - `Server files have not been installed yet.` (home missing)
+ - `Directory not found: ` (missing subdirectory)
+ - `Directory exists but cannot be opened. Check permissions.` (listing/access failure)
+ - `Path decode failed` (invalid filename token)
+
## Known Issues
-- should be hardened around safe roots and backups
+- very large uploads/downloads still depend on chunking/browser limits
## Missing Functionality
| |