From 9940e0e56b76ec41bf12a639d321d5afe094aa4f Mon Sep 17 00:00:00 2001 From: Karl Grindley Date: Fri, 8 May 2020 07:25:17 -0400 Subject: [PATCH 01/15] added proxy support for URL downloads --- tinyfilemanager.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tinyfilemanager.php b/tinyfilemanager.php index 4edb2bbc..55ef942c 100644 --- a/tinyfilemanager.php +++ b/tinyfilemanager.php @@ -163,6 +163,10 @@ 'htaccess' => 'apache_conf', ); +// Proxy for URL Download Support (hostname:port) +// Note: configure the proxy for the URLs the server is allowed to reach. +//$proxyServer = 'proxy.url.tld:8080'; + // if User has the external config file, try to use it to override the default config above [config.php] // sample config - https://tinyfilemanager.github.io/config-sample.txt $config_file = __DIR__ . '/config.php'; @@ -674,7 +678,12 @@ function get_file_path() $fileinfo->size = $curl_info["size_download"]; $fileinfo->type = $curl_info["content_type"]; } else { - $ctx = stream_context_create(); + if (isset($proxyServer)) { + $opts = array('http' => array('proxy' => 'tcp://' . $proxyServer, 'request_fulluri' => true)); + $ctx = stream_context_create($opts); + } else { + $ctx = stream_context_create(); + } @$success = copy($url, $temp_file, $ctx); if (!$success) { $err = error_get_last(); From 8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7 Mon Sep 17 00:00:00 2001 From: "Karl A. Grindley" Date: Tue, 27 Feb 2024 14:47:45 -0500 Subject: [PATCH 02/15] added AFS support --- afs.php | 967 ++++++++++++++++++++++++++++++++++++++++++++ tinyfilemanager.php | 173 +++++++- 2 files changed, 1125 insertions(+), 15 deletions(-) create mode 100644 afs.php diff --git a/afs.php b/afs.php new file mode 100644 index 00000000..baf04def --- /dev/null +++ b/afs.php @@ -0,0 +1,967 @@ +Couldn't load necessary posix function

\n"; + exit( 1 ); +} + +/* +if ( !extension_loaded( 'filedrawers' )) { + error_log( "Couldn't load Filedrawers PECL extension" ); + echo "

Couldn't load necessary Filedrawers extension

\n"; + exit( 1 ); +} +*/ + +// remember this class needs to have $this->path set +class Afs +{ + protected $selectedItems; + protected $afsUtils = '/usr/bin'; + public $confirmMsg = ''; + public $errorMsg = ''; + public $notifyMsg = ''; + public $parPath; // Path to the parent of current path + public $filename = ''; + public $adminPriv = 0; + public $deletePriv = 0; + public $insertPriv = 0; + public $lookupPriv = 0; + public $readPriv = 0; + public $writePriv = 0; + public $path = ''; + public $sid = ''; + public $type = ''; + public $mimetype = ''; + public $formKey = ''; + private $uniqname = ''; + protected $afsStat; + protected $newName = ''; + protected $startCWD = ''; + + public function __construct( $path="" ) + { + $this->uniqname = $_SERVER['REMOTE_USER']; + $this->startCWD = getcwd(); + $this->afsStat = stat('/afs/'); + + // Bug 2634811 Fixed: Make sure /afs isn't on the local filesystem + $rootStat = stat( '/' ); + + if ( $this->afsStat['dev'] == $rootStat['dev'] ) { + error_log( "/afs has same device ID as / " . + "(is afs actually mounted?): $this->uniqname, " . + "$this->errorMsg " . __FILE__ ); + //header( 'Location: /missinghomedir.php' ); + $this->errorMsg = 'Missing home directory.'; + return false; + } + + // Bug 1975875 Fixed: Don't trim whitespaces from path + $this->setPath( $path ); + + // Generate the path of the folder one level above the current + if ( !preg_match( "/(.*\/)([^\/]+)\/?$/", $this->path, $Matches )) { + error_log( "missing homedir: [$this->path] $this->uniqname, " . + "$this->errorMsg " . __FILE__ ); + //header( 'Location: /missinghomedir.php' ); + $this->errorMsg = 'Missing home directory.'; + return false; + } + $this->parPath = $Matches[1]; + $this->filename = $Matches[2]; + + if ( !isset( $_SESSION['formKey'] )) { + $_SESSION['formKey'] = md5( uniqid( rand(), true )); + } + + $this->formKey = $_SESSION['formKey']; + $this->sid = md5( uniqid( rand(), true )); + ////$this->type = $this->getType(); + + $this->processCommand(); + $this->getACLAccess( $this->path ); + } + + + // Symlink safe method to determine file type + public function getType() + { + if ( !$this->makePathAFSlocal( dirname( $this->path ))) { + return false; + } + + clearstatcache(); + if ( @filetype( basename( $this->path )) == 'dir' ) { + @chdir( $this->startCWD ); + return 'dir'; + } else { + clearstatcache(); + $type = @filetype( basename( $this->path )); + + if ( $type == 'file' ) { + $this->mimetype = Mime::getMimeType( basename( $this->path )); + @chdir( $this->startCWD ); + return $type; + } else { + @chdir( $this->startCWD ); + return 'none'; + } + } + + @chdir( $this->startCWD ); + } + + + public function processCommand() + { + if ( !isset( $_POST['command'] ) || $this->formKey != $_POST['formKey'] ) { + return false; + } + + $this->setSelectedItems(); + + switch ( $_POST['command'] ) { + case 'newfolder': + $this->createFolder(); + break; + case 'rename': + $this->setNewItemName(); + $this->afsRename(); + break; + case 'cut': + $this->setOriginPath(); + $this->moveFiles(); + break; + case 'copy': + $this->setOriginPath(); + $this->copyFiles(); + break; + case 'delete': + $this->deleteFiles(); + break; + default: + break; + } + } + + + /* + * This function sets the "target" of an operation + * (what file(s) or folder(s) + * to perform the selected action on. + */ + protected function setSelectedItems() + { + if ( isset( $_POST['selectedItems'] ) + && is_array( $_POST['selectedItems'] )) { + $this->selectedItems = array(); + + foreach ( $_POST['selectedItems'] as $key=>$item ) { + $this->selectedItems[$key] = $item; + } + } else if ( isset( $_POST['selectedItems'] )) { + $this->selectedItems = $_POST['selectedItems']; + } + } + + + // Some functions like cut or paste need to know where a file is coming from + // in addition to where it is going + public function setOriginPath() + { + if ( isset( $_POST['originPath'] )) { + $this->originPath = $this->pathSecurity( $_POST['originPath'] ); + } + } + + + public function setNewItemName() + { + if ( isset( $_POST['newName'] )) { + $this->newName = $_POST['newName']; + } + } + + + public function createFolder() + { + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( $this->selectedItems != 'Please enter a name for your new folder.' ) { + if ( $this->linkSafeFileExists( basename( $this->selectedItems ))) { + $this->errorMsg = "The folder \'$this->selectedItems\' " . + "already exists. Please select a different name."; + @chdir( $this->startCWD ); + return false; + } + + if ( !mkdir( trim( basename( $this->selectedItems )), 0644, true )) { + $this->errorMsg = 'Unable to create folder.'; + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + } + + + // Remove an existing folder + // jackylee at eml dot cc + public function removeFolder( $folderPath ) + { + if ( !$this->makePathAFSlocal( $folderPath )) { + return false; + } + + if ( !$handle = @opendir( '.' )) { + $this->errorMsg = 'Unable to remove the folder because ' . + 'it no longer exists.'; + @chdir( $this->startCWD ); + return false; + } + + while ( false !== ( $item = readdir( $handle ))) { + + if ( $item == "." || $item == ".." ) { + continue; + } + + $itemPath = $folderPath . '/' . $item; + + if ( is_dir( $itemPath ) && !is_link( $itemPath )) { + if ( !$this->removeFolder( $itemPath )) { + @chdir( $this->startCWD ); + return false; + } + } else { + if ( !$this->makePathAFSlocal( $folderPath )) { + @chdir( $this->startCWD ); + return false; + } + + unlink( basename( $item )); + @chdir( $this->startCWD ); + } + } + + closedir( $handle ); + + if ( !$this->makePathAFSlocal( $folderPath )) { + @chdir( $this->startCWD ); + return false; + } + + if ( rmdir( '../' . basename( getcwd()))) { + $this->notifyMsg = "Successfully deleted file(s)."; + @chdir( $this->startCWD ); + return true; + } + + @chdir( $this->startCWD ); + $this->errorMsg = 'Unable to remove the folder.'; + return false; + } + + + // Delete specified files + public function deleteFiles() + { + if ( ! $this->selectedItems ) { + return false; + } + + // XXX 0.5.0 should use a data structure that doesn't require splitting + // on a whitespace character + $files = explode( "\n", $this->selectedItems ); + + foreach ( $files as $file ) { + $file = preg_replace( "/[\r\n]/", '', $file ); + + if ( empty( $file )) { + continue; + } + + // Security checks are in Afs::removeFolder() + $itemPath = $this->path . '/' . $file; + + if ( is_dir( $itemPath ) && !is_link( $itemPath )) { + if ( !$this->removeFolder( $itemPath )) { + return false; + } + } else { + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( !@unlink( basename( $file ))) { + @chdir( $this->startCWD ); + $this->errorMsg = "Unable to delete $file."; + return false; + } else { + @chdir( $this->startCWD ); + $this->notifyMsg = "Successfully deleted file(s)."; + } + } + } + + @chdir( $this->startCWD ); + return true; + } + + + public function afsRename() + { + + if ( $this->selectedItems == $this->newName ) { + return false; + } + + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( is_link( basename( $this->selectedItems ))) { + $this->errorMsg = "Symbolic links cannot be renamed."; + @chdir( $this->startCWD ); + return false; + } + + $newName = trim( basename( $this->newName )); + + if ( $this->linkSafeFileExists( $newName )) { + $this->errorMsg = "The file or folder '" . $newName . + "' already exists. Please select a different name."; + @chdir( $this->startCWD ); + return false; + } + + if ( !@filedrawers_rename( basename( $this->selectedItems ), + $newName, '/afs' )) { + $this->errorMsg = 'Unable to rename this file or folder.'; + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + + /* + * Move files from one directory to another + * This will clobber an existing file with the same name + */ + function moveFiles() + { + $files = explode( CLIPSEPARATOR, $this->selectedItems ); + + foreach ( $files as $file ) { + if ( empty( $file )) { + continue; + } + + // Security checks are in filedrawers_rename + $sourcePath = $this->originPath . '/' . $file; + $destPath = $this->path . '/' . $file; + + if ( !@filedrawers_rename( $sourcePath, $destPath, '/afs' )) { + $this->errorMsg = "Unable to move: $file."; + return false; + } + + $this->notifyMsg = "Pasted the contents of the clipboard."; + } + + return true; + } + + // Copy file from one directory to another + function copyFiles() + { + $files = explode( CLIPSEPARATOR, $this->selectedItems ); + + foreach ( $files as $file ) { + if ( empty( $file )) { + continue; + } + + // Security checks are in Afs::copy() and Afs::copy_dirs + $sourcePath = $this->originPath . '/'. $file; + $destPath = $this->path . '/' . $file; + + if ( filetype( $sourcePath ) == 'dir' ) { + if ( !$this->copy_dirs( $sourcePath, $destPath )) { + $this->errorMsg = "Unable to copy $file."; + return false; + } + } else if ( !$this->copy( $sourcePath, $destPath )) { + $this->errorMsg = "Unable to copy $file."; + return false; + } + + $this->notifyMsg = "Pasted the contents of the clipboard."; + } + } + + + /* A helper function for copyFiles(). Copies an entire directory at once. + * Original author: swizec at swizec dot com, php.net + */ + public function copy_dirs( $source, $target ) + { + if ( !$this->makePathAFSlocal( dirname( $target ))) { + return false; + } + + $targetCheck = getcwd(); + + if ( !@mkdir( basename( $target ), 0755 )) { + @chdir( $this->startCWD ); + return false; + } + + if ( !$this->makePathAFSlocal( $source )) { + @chdir( $this->startCWD ); + return false; + } + + $destCheck = getcwd(); + + // Prevent copying directory inside of itself + if ( $targetCheck == $destCheck ) { + @chdir( $this->startCWD ); + return false; + } + + $dir = dir( '.' ); + + while ( false !== ( $entry = $dir->read())) { + if ( $entry == '.' || $entry == '..' ) { + continue; + } + + $sourcePath = $source . '/' . $entry; + $targetPath = $target . '/' . $entry; + + // Security checks are in Afs::copy() and Afs::copy_dirs + if ( filetype( $sourcePath ) == 'dir' ) { + if ( !$this->copy_dirs( $sourcePath, $targetPath )) { + @chdir( $this->startCWD ); + return false; + } + } else if ( !$this->copy( $sourcePath, $targetPath )) { + @chdir( $this->startCWD ); + return false; + } + } + + $dir->close(); + @chdir( $this->startCWD ); + return true; + } + + + /* An AFS safe version of the PHP copy builtin - this will only copy + * a file with a source and destination in AFS. If we relied on the + * copy builtin, there is a small possibility of a race condition where + * the copy could be symlink'ed out of AFS. This function works on file + * handles only after making sure the source and destination are in AFS. + */ + public function copy( $source, $dest ) + { + if ( is_link( $source )) { + if ( !$this->makePathAFSlocal( dirname( $source ))) { + return false; + } + + $name = basename( $source ); + $target = readlink( $name ); + + if ( !$this->makePathAFSlocal( dirname( $dest ))) { + @chdir( $this->startCWD ); + return false; + } + + if ( !symlink( $target, $name )) { + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + + if ( !( $sourceHdl = @fopen( $source, "rb" ))) { + @chdir( $this->startCWD ); + return false; + } + + $sourceStat = fstat( $sourceHdl ); + + if ( $sourceStat['dev'] != $this->afsStat['dev'] ) { + @chdir( $this->startCWD ); + return false; + } + + if ( !$this->makePathAFSlocal( dirname( $dest ))) { + @chdir( $this->startCWD ); + return false; + } + + // If you want copy to overwrite, then do unlink(basename($dest)) here + if ( !( $destHdl = @fopen( basename( $dest ), "xb" ))) { + @chdir( $this->startCWD ); + return false; + } + + while ( !feof( $sourceHdl )) { + $buffer = fread( $sourceHdl, 1024 * 1024 ); + fwrite( $destHdl, $buffer ); + } + + @fclose( $sourceHdl ); + @fclose( $destHdl ); + @chdir( $this->startCWD ); + + return true; + } + + + // A AFS safe version of the PHP readfile builtin - this will only + // read files which are hosted in AFS. + function readfile() + { + clearstatcache(); + + if ( $handle = @fopen( $this->path, "rb" )) { + $stat = fstat( $handle ); + if ( $stat['dev'] == $this->afsStat['dev'] ) { + while ( !feof( $handle )) { + $buffer = fread( $handle, 1024 * 1024 ); + echo $buffer; + } + } + + @fclose( $handle ); + } + } + + // Change the ACL for a given path + function changeAcl($entity, + $rights, + $path='', + $recursive=false, + $negative=false ) + { + $entity = escapeshellarg( $entity ); + $rights = escapeshellarg( trim( $rights )); + $path = ( $path ) ? $path : $this->path; + $neg = ( $negative ) ? ' -negative' : ''; + $cmd = "$this->afsUtils/fs sa $neg " . escapeshellarg( $path ) . + " $entity $rights"; + $cmdRecur = "find " . escapeshellarg( $path ) . " -type d -exec " . + "$this->afsUtils/fs sa $neg {} $entity $rights \\;"; + $cmd = ( $recursive ) ? $cmdRecur : $cmd; + + if ( !$path ) { + return false; + } + + if ( strpos( shell_exec( $cmd . " 2>&1" ), 'fs:' ) !== false ) { + $this->errorMsg = + "Warning: Unable to modify the access control list."; + return false; + } + + return true; + } + + // Return an array of ACL rights for the current path + function readAcl( $path='' ) + { + $path = ( $path ) ? $path : $this->path; + $cmd = "$this->afsUtils/fs listacl " . escapeshellarg( $path ); + $result = shell_exec( $cmd . " 2>&1" ); + $rights = array( 'l', 'r', 'w', 'i', 'd', 'k', 'a' ); + + if ( !$path ) { + return false; + } + + if ( preg_match( '/^fs:/', $result )) { + $this->errorMsg = + "Warning: Unable to read the access control list."; + return false; + } + + $result = preg_replace( "/(.*)is\n(.*)rights:\n/", "", $result ); + $result = explode( "\nNegative rights:\n", $result ); + + if ( isset( $result[0] )) { + $normal = explode( "\n", trim( $result[0] )); + if ( is_array( $normal )) { + foreach ( $normal as $item ) { + $perm = explode( ' ', trim( $item )); + $setRights = $perm[1]; + foreach ( $rights as $right ) { + if ( strpos( $setRights, $right ) !== false ) { + $result['normal'][$perm[0]][$right] = true; + } else { + $result['normal'][$perm[0]][$right] = false; + } + } + } + } + } + + if ( isset( $result[1] )) { + $negative = explode( "\n", trim( $result[1] )); + if ( is_array( $negative )) { + foreach ( $negative as $item ) { + $perm = explode( ' ', trim( $item )); + $setRights = $perm[1]; + foreach ( $rights as $right ) { + if ( strpos( $setRights, $right ) !== false ) { + $result['negative'][$perm[0]][$right] = true; + } else { + $result['negative'][$perm[0]][$right] = false; + } + } + } + } + } + + return $result; + } + + function getACLAccess( $path ) + { + if ( empty( $path )) { + return false; + } + + $cmd = "$this->afsUtils/fs getcalleraccess " . escapeshellarg( $path ); + $result = shell_exec( $cmd . " 2>&1" ); + + $acls = ''; + if ( preg_match( "/^Callers access to .* is (\w{1,7})$/", + $result, $Matches )) { + $acls = strtolower( $Matches[1] ); + + if ( strpos( $acls, 'l' ) !== false ) { + $this->lookupPriv = 1; + if ( strpos( $acls, 'a' ) !== false ) { + $this->adminPriv = 1; + } + if ( strpos( $acls, 'd' ) !== false ) { + $this->deletePriv= 1; + } + if ( strpos( $acls, 'i' ) !== false ) { + $this->insertPriv = 1; + } + if ( strpos( $acls, 'r' ) !== false ) { + $this->readPriv = 1; + } + if ( strpos( $acls, 'w' ) !== false ) { + $this->writePriv = 1; + } + } + } + return $acls; + } + + /* + * List the contents of a folder as a set of javascript + * variable declarations. + * + */ + public function get_foldercontents_js( $showHidden=false ) + { + $id = 0; + $files = ''; + + if ( is_file( $this->path )) { + $path = dirname( $this->path ); + } else { + $path = $this->path; + } + + if ( !$this->makePathAFSlocal( $path )) { + $this->errorMsg = "Unable to view: $this->path."; + return false; + } + + if ( !@is_dir( '.' )) { + @chdir( $this->startCWD ); + return false; + } + + // Open the path and read its contents + if ( !$dh = @opendir( '.' )) { + $this->errorMsg = "Unable to view: $this->path."; + @chdir( $this->startCWD ); + return false; + } + + while ( $filename = readdir( $dh )) { + clearstatcache(); + if ( !$fileStats = @lstat( $filename )) { + $modTime = ''; + $size = 0; + } else { + $modTime = $fileStats['mtime']; + $size = $fileStats['size']; + } + + //$mimeType = Mime::getMimeType( $filename ); + //$mimeIcon = Mime::getIcon( $mimeType, $filename ); + $filename = $this->escape_js( $filename ); + + $viewable = 0; + + //if ( Mime::getPreviewType( $mimeType ) || @is_dir( $filename )) { + //$viewable = 1; + //} + + if ( $showHidden || strpos( $filename, '.' ) !== 0 ) { + $files .= "files[$id]=new File('$filename', '$modTime', $size, " + . "'', '$mimeIcon', $viewable);\n"; + } + + $id++; + } + + closedir( $dh ); + @chdir( $this->startCWD ); + return $files; + } + + function get_foldername() + { + return basename( $this->path ); + } + + function get_returnToURI() + { + return ( 'https://' . + $_SERVER['HTTP_HOST'] . + $_SERVER['PHP_SELF'] . + "?path=" . + urlencode($this->path) . + "&" . + "finishid=" . + $this->sid ); + } + + /* + * Return a string escaped for a javascript string literal. + */ + function escape_js( $string ) + { + $output = ""; + + $length = strlen( $string ); + for( $i=0; $i<$length; $i++ ) + { + $c = $string[$i]; + switch( $c ) + { + case '\'': + $output .= '\\\''; + break; + case '\\': + $output .= '\\\\'; + break; + case "\n": + $output .= '\\n'; + break; + case "\r": + $output .= '\\r'; + break; + default: + $output .= $c; + break; + } + } + + return $output; + } + + /* An initial check to make sure the path is in AFS. This is an initial + * check only. To avoid race conditions, other precaustions must be used. + * CAUTION: This method will be removed in the next major release. + */ + private function pathSecurity( $path='' ) + { + if ( empty( $path )) { + return false; + } + + /* The path is only safe if we're in AFS at the end of it. + * This test is raceable - so we should check again before sending + * anything to the client. + */ + clearstatcache(); + + if ( !$pathStat = @stat( $path )) { + return false; + } + + if ( $this->afsStat["dev"] != $pathStat["dev"] ) { + return false; + } + + // Remove the final / in the target path if it exists + return preg_replace( '/\/$/', '', $path ); + } + + + public function makePathAFSlocal( $path ) + { + if ( !@chdir( $path )) { + $this->errorMsg = "Couldn't change directory"; + return false; + } + + clearstatcache(); + $stat = stat( '.' ); + if ( $this->afsStat["dev"] != $stat["dev"] ) { + $this->errorMsg = "Path not in AFS"; + @chdir( $this->startCWD ); + return false; + } + + return true; + } + + + // Checks to see if there is a folder at the current path + function folderExists( $path='' ) + { + $path = ( $path ) ? $path : $this->path; + return is_dir( $path ); + } + + + public function linkSafeFileExists( $path ) + { + clearstatcache(); + + if ( is_array( @lstat( $path ))) { + return true; + } else { + return false; + } + } + + + // Set the afs path used inside the class + function setPath( $path='' ) + { + if ( $path == '/afs' || $path == '/afs/' ) { + $path = null; + } + + if ( !empty( $path )) { + if ( !( $this->path = $this->pathSecurity( $path ))) { + // Can't give this warning due to the current filedrawers + // design. This should be fixed in the next release. + // If a user navigates to a path that doesn't exist, they will + // continue to get the warning until the url changes + //$this->errorMsg = "The specified path does not exist. ($path)" + $this->path = null; + } + } + + // Make sure the specified path was accepted + if ( empty( $this->path )) { + //GetHomeDir( $this->uniqname, $this->path, $this->errorMsg ); + $this->path = $this->pathSecurity( $this->path ); + } + + } + + // Makes each piece of a file path clickable + function pathDisplay() + { + if ( empty( $this->path )) { + return ''; + } + + $path = preg_replace( '/^\/afs\//', '', $this->path ); + $path = explode( '/', $path ); + $lastItem = array_pop( $path ); + $pathDisp = '/afs'; + $pathURI = '/afs'; + $lastDisp = ''; + $lastURI = ''; + + foreach ( $path as $piece ) { + $pathURI .= "/$piece"; + $pathDisp .= "/" + . htmlentities( $piece ) . ""; + } + + $pathURI .= $lastURI; + $pathDisp .= $lastDisp; + + return $pathDisp . '/' . htmlentities( $lastItem ); + } + + // Make smarty template variable assignments + function make_smarty_assignments(&$smart) + { + $smart->assign( 'path_url', urlencode($this->path)); + $smart->assign( 'parentPath', urlencode($this->parPath )); + $smart->assign( 'location', $this->pathDisplay()); + } + + function get_js_declarations() + { + $retstr = ""; + + $retstr .= $this->js_var( "path", $this->path ); + $retstr .= $this->js_var( "foldername", $this->get_foldername( )); + $retstr .= $this->js_var( "folderIcon", "" ); + $retstr .= $this->js_var( "homepath", $this->path ); + $retstr .= $this->js_var( "sid", $this->sid ); + $retstr .= $this->js_var( "returnToURI", $this->get_returnToURI( )); + $retstr .= $this->js_var( "adminPriv", $this->adminPriv); + $retstr .= $this->js_var( "deletePriv", $this->deletePriv); + $retstr .= $this->js_var( "insertPriv", $this->insertPriv ); + $retstr .= $this->js_var( "readPriv", $this->readPriv ); + $retstr .= $this->js_var( "lookupPriv", $this->lookupPriv ); + $retstr .= $this->js_var( "writePriv", $this->writePriv ); + $retstr .= "files = new Array();\n"; + $retstr .= $this->get_foldercontents_js( true ); + + return $retstr; + } + + private function js_var( $varname, $contents ) + { + $retstr = ""; + $retstr .= "var $varname = " . + ( is_string( $contents ) ? + "'" . $this->escape_js( $contents ) . "'" + : $contents ) + . ";\n"; + return $retstr; + } + +} + diff --git a/tinyfilemanager.php b/tinyfilemanager.php index 55ef942c..090f678e 100644 --- a/tinyfilemanager.php +++ b/tinyfilemanager.php @@ -167,6 +167,9 @@ // Note: configure the proxy for the URLs the server is allowed to reach. //$proxyServer = 'proxy.url.tld:8080'; +// OpenAFS / AuriStor support. Enable this in config.php on an AFS-backed host. +$afsSupport = false; + // if User has the external config file, try to use it to override the default config above [config.php] // sample config - https://tinyfilemanager.github.io/config-sample.txt $config_file = __DIR__ . '/config.php'; @@ -174,6 +177,10 @@ @include($config_file); } +if ($afsSupport) { + require_once __DIR__ . '/afs.php'; +} + define('ACE_FONTSIZE', isset($ace_fontsize) ? $ace_fontsize : 12); define('ACE_THEME', isset($ace_theme) ? $ace_theme : 'textmate'); @@ -1296,8 +1303,8 @@ function get_file_path() fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } -// Change Perms (not for Windows) -if (isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { +// Change POSIX permissions (not for Windows or AFS) +if (!$afsSupport && isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { if (!verifyToken($_POST['token'])) { fm_set_msg(lng("Invalid Token."), 'error'); @@ -1357,6 +1364,41 @@ function get_file_path() fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } +// Change AFS ACLs (not for Windows) +if ($afsSupport && isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { + if (!verifyToken($_POST['token'])) { + fm_set_msg(lng('Invalid Token.'), 'error'); + die('Invalid Token.'); + } + + $path = FM_ROOT_PATH; + if (FM_PATH != '') { + $path .= '/' . FM_PATH; + } + + $file = fm_clean_path($_POST['chmod']); + $file = str_replace('/', '', $file); + if ($file == '' || (!is_file($path . '/' . $file) && !is_dir($path . '/' . $file))) { + fm_set_msg(lng('File not found'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $ret = true; + if (isset($_POST['normal']) && is_array($_POST['normal'])) { + foreach ($_POST['normal'] as $user => $perms) { + $afs = new Afs($path . '/' . $file); + unset($perms['acl']); + $newAcl = empty($perms) ? 'none' : implode('', array_keys($perms)); + $ret = $ret && $afs->changeAcl($user, $newAcl, $path . '/' . $file); + } + } + + fm_set_msg(lng($ret ? 'Permissions changed' : 'Permissions not changed'), $ret ? 'ok' : 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); +} + /*************************** ACTIONS ***************************/ // get current path @@ -2061,8 +2103,8 @@ class="edit-file"> readAcl($file_path); + $normal_acl = is_array($mode) && isset($mode['normal']) ? $mode['normal'] : array(); + $negative_acl = is_array($mode) && isset($mode['negative']) ? $mode['negative'] : array(); +?> +
+
+
+
+

+ + :
+

+
+ + + + + + + + + + + + + + + + $perms) { $encoded_user = fm_enc($user); ?> + + + + + + + + + + + + + $perms) { $encoded_user = fm_enc($user); ?> + + + + + + + + + + + +
/
+

+   + +

+
+
+
+
+
@@ -2166,7 +2293,8 @@ class="edit-file"> - + + @@ -2182,7 +2310,7 @@ class="edit-file"> - + '?'); + $owner = array('name' => '?'); $group = array('name' => '?'); - if (function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { + if ($afsSupport && !FM_IS_WIN && !$hide_Cols) { + $afs = new Afs($path . '/' . $f); + $perms = $afs->getACLAccess($path . '/' . $f); + } elseif (!$afsSupport && function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { try { $owner_id = fileowner($path . '/' . $f); if ($owner_id != 0) { @@ -2241,9 +2372,9 @@ class="edit-file"> - + - + @@ -2269,9 +2400,12 @@ class="edit-file"> '?'); + $owner = array('name' => '?'); $group = array('name' => '?'); - if (function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { + if ($afsSupport && !FM_IS_WIN && !$hide_Cols) { + $afs = new Afs($path . '/' . $f); + $perms = $afs->getACLAccess($path . '/' . $f); + } elseif (!$afsSupport && function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { try { $owner_id = fileowner($path . '/' . $f); if ($owner_id != 0) { @@ -2319,7 +2453,7 @@ class="edit-file"> - + @@ -2341,14 +2475,14 @@ class="edit-file"> - + - + ' . fm_get_filesize($all_files_size) . '' ?> ' . $num_files . '' ?> ' . $num_folders . '' ?> @@ -5561,6 +5695,15 @@ function lng($txt) $tr['en']['DirectLink'] = 'Direct link'; $tr['en']['UploadingFiles'] = 'Upload Files'; $tr['en']['ChangePermissions'] = 'Change Permissions'; + $tr['en']['lookup'] = 'lookup'; + $tr['en']['read'] = 'read'; + $tr['en']['write'] = 'write'; + $tr['en']['insert'] = 'insert'; + $tr['en']['delete'] = 'delete'; + $tr['en']['lock'] = 'lock'; + $tr['en']['admin'] = 'admin'; + $tr['en']['normalRights'] = 'Normal Rights'; + $tr['en']['negativeRights'] = 'Negative Rights'; $tr['en']['Copying'] = 'Copying'; $tr['en']['CreateNewItem'] = 'Create New Item'; $tr['en']['Name'] = 'Name'; From 7ea1040cd3d7c6c2b12c5949c8f4604bf72a87b0 Mon Sep 17 00:00:00 2001 From: "Karl A. Grindley" Date: Mon, 17 Aug 2026 20:18:44 -0400 Subject: [PATCH 03/15] Harden AFS ACL and helper failure handling --- afs.php | 458 +++++++++++++++++++++++++++++++------------- tinyfilemanager.php | 112 +++++++++-- 2 files changed, 426 insertions(+), 144 deletions(-) diff --git a/afs.php b/afs.php index baf04def..4af3fd2e 100644 --- a/afs.php +++ b/afs.php @@ -28,6 +28,7 @@ class Afs { protected $selectedItems; protected $afsUtils = '/usr/bin'; + protected $afsRoot = '/afs'; public $confirmMsg = ''; public $errorMsg = ''; public $notifyMsg = ''; @@ -36,6 +37,7 @@ class Afs public $adminPriv = 0; public $deletePriv = 0; public $insertPriv = 0; + public $lockPriv = 0; public $lookupPriv = 0; public $readPriv = 0; public $writePriv = 0; @@ -46,37 +48,44 @@ class Afs public $formKey = ''; private $uniqname = ''; protected $afsStat; + protected $afsAvailable = false; + protected $lastFsStatus = 0; protected $newName = ''; + protected $originPath = ''; protected $startCWD = ''; public function __construct( $path="" ) { - $this->uniqname = $_SERVER['REMOTE_USER']; + $this->uniqname = isset( $_SERVER['REMOTE_USER'] ) + ? $_SERVER['REMOTE_USER'] : ''; $this->startCWD = getcwd(); - $this->afsStat = stat('/afs/'); + $this->afsStat = @stat( $this->afsRoot ); // Bug 2634811 Fixed: Make sure /afs isn't on the local filesystem - $rootStat = stat( '/' ); + $rootStat = @stat( '/' ); - if ( $this->afsStat['dev'] == $rootStat['dev'] ) { - error_log( "/afs has same device ID as / " . + if ( !is_array( $this->afsStat ) || !is_array( $rootStat ) + || $this->afsStat['dev'] == $rootStat['dev'] ) { + error_log( "$this->afsRoot is unavailable or has the same device ID as / " . "(is afs actually mounted?): $this->uniqname, " . "$this->errorMsg " . __FILE__ ); - //header( 'Location: /missinghomedir.php' ); - $this->errorMsg = 'Missing home directory.'; - return false; + $this->errorMsg = 'AFS is not mounted.'; + return; } + $this->afsAvailable = true; + // Bug 1975875 Fixed: Don't trim whitespaces from path - $this->setPath( $path ); + if ( !$this->setPath( $path )) { + return; + } // Generate the path of the folder one level above the current if ( !preg_match( "/(.*\/)([^\/]+)\/?$/", $this->path, $Matches )) { error_log( "missing homedir: [$this->path] $this->uniqname, " . "$this->errorMsg " . __FILE__ ); - //header( 'Location: /missinghomedir.php' ); $this->errorMsg = 'Missing home directory.'; - return false; + return; } $this->parPath = $Matches[1]; $this->filename = $Matches[2]; @@ -87,10 +96,6 @@ public function __construct( $path="" ) $this->formKey = $_SESSION['formKey']; $this->sid = md5( uniqid( rand(), true )); - ////$this->type = $this->getType(); - - $this->processCommand(); - $this->getACLAccess( $this->path ); } @@ -110,7 +115,9 @@ public function getType() $type = @filetype( basename( $this->path )); if ( $type == 'file' ) { - $this->mimetype = Mime::getMimeType( basename( $this->path )); + $this->mimetype = function_exists( 'fm_get_mime_type' ) + ? fm_get_mime_type( basename( $this->path )) + : 'application/octet-stream'; @chdir( $this->startCWD ); return $type; } else { @@ -208,7 +215,7 @@ public function createFolder() return false; } - if ( !mkdir( trim( basename( $this->selectedItems )), 0644, true )) { + if ( !mkdir( trim( basename( $this->selectedItems )), 0755, true )) { $this->errorMsg = 'Unable to create folder.'; @chdir( $this->startCWD ); return false; @@ -243,7 +250,7 @@ public function removeFolder( $folderPath ) $itemPath = $folderPath . '/' . $item; - if ( is_dir( $itemPath ) && !is_link( $itemPath )) { + if ( is_dir( $itemPath ) && !is_link( $itemPath )) { if ( !$this->removeFolder( $itemPath )) { @chdir( $this->startCWD ); return false; @@ -334,7 +341,7 @@ public function afsRename() if ( !$this->makePathAFSlocal( $this->path )) { return false; } - + if ( is_link( basename( $this->selectedItems ))) { $this->errorMsg = "Symbolic links cannot be renamed."; @chdir( $this->startCWD ); @@ -350,8 +357,14 @@ public function afsRename() return false; } + if ( !function_exists( 'filedrawers_rename' )) { + $this->errorMsg = 'AFS-safe rename support is unavailable.'; + @chdir( $this->startCWD ); + return false; + } + if ( !@filedrawers_rename( basename( $this->selectedItems ), - $newName, '/afs' )) { + $newName, $this->afsRoot )) { $this->errorMsg = 'Unable to rename this file or folder.'; @chdir( $this->startCWD ); return false; @@ -378,7 +391,12 @@ function moveFiles() $sourcePath = $this->originPath . '/' . $file; $destPath = $this->path . '/' . $file; - if ( !@filedrawers_rename( $sourcePath, $destPath, '/afs' )) { + if ( !function_exists( 'filedrawers_rename' )) { + $this->errorMsg = 'AFS-safe move support is unavailable.'; + return false; + } + + if ( !@filedrawers_rename( $sourcePath, $destPath, $this->afsRoot )) { $this->errorMsg = "Unable to move: $file."; return false; } @@ -423,6 +441,19 @@ function copyFiles() */ public function copy_dirs( $source, $target ) { + $sourceReal = @realpath( $source ); + $targetParentReal = @realpath( dirname( $target )); + if ( $sourceReal === false || $targetParentReal === false ) { + return false; + } + + $sourcePrefix = rtrim( $sourceReal, '/' ) . '/'; + $targetParentPrefix = rtrim( $targetParentReal, '/' ) . '/'; + if ( $targetParentReal === $sourceReal + || strpos( $targetParentPrefix, $sourcePrefix ) === 0 ) { + return false; + } + if ( !$this->makePathAFSlocal( dirname( $target ))) { return false; } @@ -483,6 +514,10 @@ public function copy_dirs( $source, $target ) */ public function copy( $source, $dest ) { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + return false; + } + if ( is_link( $source )) { if ( !$this->makePathAFSlocal( dirname( $source ))) { return false; @@ -496,7 +531,7 @@ public function copy( $source, $dest ) return false; } - if ( !symlink( $target, $name )) { + if ( !symlink( $target, basename( $dest ))) { @chdir( $this->startCWD ); return false; } @@ -512,32 +547,60 @@ public function copy( $source, $dest ) $sourceStat = fstat( $sourceHdl ); - if ( $sourceStat['dev'] != $this->afsStat['dev'] ) { + if ( !is_array( $sourceStat ) + || $sourceStat['dev'] != $this->afsStat['dev'] ) { + @fclose( $sourceHdl ); @chdir( $this->startCWD ); return false; } if ( !$this->makePathAFSlocal( dirname( $dest ))) { + @fclose( $sourceHdl ); @chdir( $this->startCWD ); return false; } // If you want copy to overwrite, then do unlink(basename($dest)) here if ( !( $destHdl = @fopen( basename( $dest ), "xb" ))) { + @fclose( $sourceHdl ); @chdir( $this->startCWD ); return false; } + $copied = true; while ( !feof( $sourceHdl )) { $buffer = fread( $sourceHdl, 1024 * 1024 ); - fwrite( $destHdl, $buffer ); + if ( $buffer === false ) { + $copied = false; + break; + } + + $written = 0; + $length = strlen( $buffer ); + while ( $written < $length ) { + $bytes = fwrite( $destHdl, substr( $buffer, $written )); + if ( $bytes === false || $bytes === 0 ) { + $copied = false; + break 2; + } + $written += $bytes; + } } + if ( !@fflush( $destHdl )) { + $copied = false; + } @fclose( $sourceHdl ); - @fclose( $destHdl ); + if ( !@fclose( $destHdl )) { + $copied = false; + } + + if ( !$copied ) { + @unlink( basename( $dest )); + } @chdir( $this->startCWD ); - return true; + return $copied; } @@ -545,19 +608,31 @@ public function copy( $source, $dest ) // read files which are hosted in AFS. function readfile() { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + return false; + } + clearstatcache(); if ( $handle = @fopen( $this->path, "rb" )) { $stat = fstat( $handle ); - if ( $stat['dev'] == $this->afsStat['dev'] ) { + if ( is_array( $stat ) && $stat['dev'] == $this->afsStat['dev'] ) { while ( !feof( $handle )) { $buffer = fread( $handle, 1024 * 1024 ); + if ( $buffer === false ) { + @fclose( $handle ); + return false; + } echo $buffer; } + @fclose( $handle ); + return true; } @fclose( $handle ); } + + return false; } // Change the ACL for a given path @@ -567,23 +642,94 @@ function changeAcl($entity, $recursive=false, $negative=false ) { - $entity = escapeshellarg( $entity ); - $rights = escapeshellarg( trim( $rights )); - $path = ( $path ) ? $path : $this->path; - $neg = ( $negative ) ? ' -negative' : ''; - $cmd = "$this->afsUtils/fs sa $neg " . escapeshellarg( $path ) . - " $entity $rights"; - $cmdRecur = "find " . escapeshellarg( $path ) . " -type d -exec " . - "$this->afsUtils/fs sa $neg {} $entity $rights \\;"; - $cmd = ( $recursive ) ? $cmdRecur : $cmd; + $path = ( $path ) ? $path : $this->path; + $path = $this->pathSecurity( $path ); + $rights = trim( $rights ); - if ( !$path ) { + if ( !$path || empty( $entity ) + || !preg_match( '/^(none|[lrwidkaA-H]{1,15})$/', $rights )) { + $this->errorMsg = + 'Warning: Invalid access control list request.'; return false; } - if ( strpos( shell_exec( $cmd . " 2>&1" ), 'fs:' ) !== false ) { + if ( !$recursive ) { + return $this->changeAclEntries( + array( $entity => $rights ), $path, $negative ); + } + + $paths = array( $path ); + if ( $recursive && is_dir( $path )) { + $flags = FilesystemIterator::SKIP_DOTS; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, $flags ), + RecursiveIteratorIterator::SELF_FIRST ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() && !$item->isLink()) { + $safePath = $this->pathSecurity( $item->getPathname()); + if ( !$safePath ) { + return false; + } + $paths[] = $safePath; + } + } + } + + foreach ( $paths as $aclPath ) { + $arguments = array( 'sa' ); + if ( $negative ) { + $arguments[] = '-negative'; + } + $arguments[] = $aclPath; + $arguments[] = $entity; + $arguments[] = $rights; + + $result = $this->runFs( $arguments ); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { + $this->errorMsg = + 'Warning: Unable to modify the access control list.'; + return false; + } + } + + return true; + } + + // Change multiple ACL entries in one fs invocation to avoid per-ACE + // partial updates and subprocess overhead. + function changeAclEntries( $entries, $path='', $negative=false ) + { + $path = ( $path ) ? $path : $this->path; + $path = $this->pathSecurity( $path ); + if ( !$path || !is_array( $entries ) || empty( $entries )) { + return false; + } + + $arguments = array( 'sa' ); + if ( $negative ) { + $arguments[] = '-negative'; + } + $arguments[] = $path; + + foreach ( $entries as $entity => $rights ) { + $rights = trim( $rights ); + if ( $entity === '' + || !preg_match( '/^(none|[lrwidkaA-H]{1,15})$/', $rights )) { + $this->errorMsg = + 'Warning: Invalid access control list request.'; + return false; + } + $arguments[] = $entity; + $arguments[] = $rights; + } + + $result = $this->runFs( $arguments ); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { $this->errorMsg = - "Warning: Unable to modify the access control list."; + 'Warning: Unable to modify the access control list.'; return false; } @@ -594,96 +740,149 @@ function changeAcl($entity, function readAcl( $path='' ) { $path = ( $path ) ? $path : $this->path; - $cmd = "$this->afsUtils/fs listacl " . escapeshellarg( $path ); - $result = shell_exec( $cmd . " 2>&1" ); - $rights = array( 'l', 'r', 'w', 'i', 'd', 'k', 'a' ); - + $path = $this->pathSecurity( $path ); if ( !$path ) { return false; } - if ( preg_match( '/^fs:/', $result )) { - $this->errorMsg = - "Warning: Unable to read the access control list."; + $result = $this->runFs( array( 'listacl', $path )); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { + $this->errorMsg = + 'Warning: Unable to read the access control list.'; return false; } - $result = preg_replace( "/(.*)is\n(.*)rights:\n/", "", $result ); - $result = explode( "\nNegative rights:\n", $result ); + return $this->parseAclOutput( $result ); + } - if ( isset( $result[0] )) { - $normal = explode( "\n", trim( $result[0] )); - if ( is_array( $normal )) { - foreach ( $normal as $item ) { - $perm = explode( ' ', trim( $item )); - $setRights = $perm[1]; - foreach ( $rights as $right ) { - if ( strpos( $setRights, $right ) !== false ) { - $result['normal'][$perm[0]][$right] = true; - } else { - $result['normal'][$perm[0]][$right] = false; - } - } + public function parseAclOutput( $result ) + { + if ( !is_string( $result )) { + return false; + } + + $rights = array( 'l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H' ); + $acl = array( + 'normal' => array(), + 'negative' => array(), + 'inherited' => preg_match( + '/^Access list \(inherited\) for /mi', $result ) === 1 + ); + $section = ''; + $sawHeader = false; + $sawNormal = false; + $lines = preg_split( '/\r?\n/', $result ); + + foreach ( $lines as $line ) { + $line = trim( $line ); + if ( $line === '' ) { + continue; + } + if ( preg_match( '/^Access list(?: \(inherited\))? for .+ is$/i', $line )) { + $sawHeader = true; + continue; + } + if ( preg_match( '/^Normal rights:$/i', $line )) { + $section = 'normal'; + $sawNormal = true; + continue; + } + if ( preg_match( '/^Negative rights:$/i', $line )) { + if ( !$sawNormal ) { + return false; } + $section = 'negative'; + continue; + } + if ( !$section || !preg_match( '/^(\S+)\s+(\S+)$/', $line, $matches )) { + return false; + } + if ( $matches[2] !== 'none' + && !preg_match( '/^[lrwidkaA-H]{1,15}$/', $matches[2] )) { + return false; } - } - if ( isset( $result[1] )) { - $negative = explode( "\n", trim( $result[1] )); - if ( is_array( $negative )) { - foreach ( $negative as $item ) { - $perm = explode( ' ', trim( $item )); - $setRights = $perm[1]; - foreach ( $rights as $right ) { - if ( strpos( $setRights, $right ) !== false ) { - $result['negative'][$perm[0]][$right] = true; - } else { - $result['negative'][$perm[0]][$right] = false; - } - } + $seenRights = array(); + foreach ( str_split( $matches[2] ) as $setRight ) { + if ( isset( $seenRights[$setRight] )) { + return false; } + $seenRights[$setRight] = true; + } + + foreach ( $rights as $right ) { + $acl[$section][$matches[1]][$right] = + strpos( $matches[2], $right ) !== false; } } - return $result; + if ( !$sawHeader || !$sawNormal ) { + return false; + } + + return $acl; } - function getACLAccess( $path ) + function getACLAccess( $path ) { - if ( empty( $path )) { - return false; + $this->lookupPriv = 0; + $this->readPriv = 0; + $this->writePriv = 0; + $this->insertPriv = 0; + $this->deletePriv = 0; + $this->lockPriv = 0; + $this->adminPriv = 0; + + $path = $this->pathSecurity( $path ); + if ( !$path ) { + return ''; } - $cmd = "$this->afsUtils/fs getcalleraccess " . escapeshellarg( $path ); - $result = shell_exec( $cmd . " 2>&1" ); + $result = $this->runFs( array( 'getcalleraccess', $path )); + if ( $result === false || $this->lastFsStatus !== 0 ) { + return ''; + } $acls = ''; - if ( preg_match( "/^Callers access to .* is (\w{1,7})$/", + if ( preg_match( '/^Callers access to .* is ([lrwidkaA-H]{1,15})$/m', $result, $Matches )) { - $acls = strtolower( $Matches[1] ); - - if ( strpos( $acls, 'l' ) !== false ) { - $this->lookupPriv = 1; - if ( strpos( $acls, 'a' ) !== false ) { - $this->adminPriv = 1; - } - if ( strpos( $acls, 'd' ) !== false ) { - $this->deletePriv= 1; - } - if ( strpos( $acls, 'i' ) !== false ) { - $this->insertPriv = 1; - } - if ( strpos( $acls, 'r' ) !== false ) { - $this->readPriv = 1; - } - if ( strpos( $acls, 'w' ) !== false ) { - $this->writePriv = 1; - } - } + $acls = $Matches[1]; + $this->lookupPriv = strpos( $acls, 'l' ) !== false ? 1 : 0; + $this->readPriv = strpos( $acls, 'r' ) !== false ? 1 : 0; + $this->writePriv = strpos( $acls, 'w' ) !== false ? 1 : 0; + $this->insertPriv = strpos( $acls, 'i' ) !== false ? 1 : 0; + $this->deletePriv = strpos( $acls, 'd' ) !== false ? 1 : 0; + $this->lockPriv = strpos( $acls, 'k' ) !== false ? 1 : 0; + $this->adminPriv = strpos( $acls, 'a' ) !== false ? 1 : 0; } return $acls; } + protected function runFs( $arguments ) + { + if ( !is_array( $arguments ) || empty( $arguments )) { + return false; + } + + $command = 'LC_ALL=C ' . escapeshellarg( $this->afsUtils . '/fs' ); + foreach ( $arguments as $argument ) { + $command .= ' ' . escapeshellarg( $argument ); + } + + if ( !function_exists( 'exec' )) { + $this->lastFsStatus = 126; + return false; + } + + $output = array(); + $status = 0; + exec( $command . ' 2>&1', $output, $status ); + $this->lastFsStatus = $status; + return implode( "\n", $output ); + } + /* * List the contents of a folder as a set of javascript * variable declarations. @@ -699,7 +898,7 @@ public function get_foldercontents_js( $showHidden=false ) } else { $path = $this->path; } - + if ( !$this->makePathAFSlocal( $path )) { $this->errorMsg = "Unable to view: $this->path."; return false; @@ -805,9 +1004,10 @@ function escape_js( $string ) * check only. To avoid race conditions, other precaustions must be used. * CAUTION: This method will be removed in the next major release. */ - private function pathSecurity( $path='' ) + protected function pathSecurity( $path='' ) { - if ( empty( $path )) { + if ( !$this->afsAvailable || empty( $path ) + || !is_array( $this->afsStat )) { return false; } @@ -826,25 +1026,30 @@ private function pathSecurity( $path='' ) } // Remove the final / in the target path if it exists - return preg_replace( '/\/$/', '', $path ); + return rtrim( $path, '/' ); } public function makePathAFSlocal( $path ) { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + $this->errorMsg = 'AFS is not mounted.'; + return false; + } + if ( !@chdir( $path )) { $this->errorMsg = "Couldn't change directory"; return false; } clearstatcache(); - $stat = stat( '.' ); - if ( $this->afsStat["dev"] != $stat["dev"] ) { + $stat = @stat( '.' ); + if ( !is_array( $stat ) || $this->afsStat['dev'] != $stat['dev'] ) { $this->errorMsg = "Path not in AFS"; @chdir( $this->startCWD ); return false; } - + return true; } @@ -872,27 +1077,20 @@ public function linkSafeFileExists( $path ) // Set the afs path used inside the class function setPath( $path='' ) { - if ( $path == '/afs' || $path == '/afs/' ) { - $path = null; - } - - if ( !empty( $path )) { - if ( !( $this->path = $this->pathSecurity( $path ))) { - // Can't give this warning due to the current filedrawers - // design. This should be fixed in the next release. - // If a user navigates to a path that doesn't exist, they will - // continue to get the warning until the url changes - //$this->errorMsg = "The specified path does not exist. ($path)" - $this->path = null; - } + $safePath = $this->pathSecurity( $path ); + if ( !$safePath ) { + $this->path = ''; + $this->errorMsg = 'Path not in AFS'; + return false; } - // Make sure the specified path was accepted - if ( empty( $this->path )) { - //GetHomeDir( $this->uniqname, $this->path, $this->errorMsg ); - $this->path = $this->pathSecurity( $this->path ); - } + $this->path = $safePath; + return true; + } + public function isAvailable() + { + return $this->afsAvailable; } // Makes each piece of a file path clickable @@ -943,6 +1141,7 @@ function get_js_declarations() $retstr .= $this->js_var( "adminPriv", $this->adminPriv); $retstr .= $this->js_var( "deletePriv", $this->deletePriv); $retstr .= $this->js_var( "insertPriv", $this->insertPriv ); + $retstr .= $this->js_var( "lockPriv", $this->lockPriv ); $retstr .= $this->js_var( "readPriv", $this->readPriv ); $retstr .= $this->js_var( "lookupPriv", $this->lookupPriv ); $retstr .= $this->js_var( "writePriv", $this->writePriv ); @@ -956,12 +1155,11 @@ private function js_var( $varname, $contents ) { $retstr = ""; $retstr .= "var $varname = " . - ( is_string( $contents ) ? + ( is_string( $contents ) ? "'" . $this->escape_js( $contents ) . "'" - : $contents ) + : $contents ) . ";\n"; return $retstr; } } - diff --git a/tinyfilemanager.php b/tinyfilemanager.php index 090f678e..3e804c0c 100644 --- a/tinyfilemanager.php +++ b/tinyfilemanager.php @@ -1385,13 +1385,61 @@ function get_file_path() } $ret = true; - if (isset($_POST['normal']) && is_array($_POST['normal'])) { - foreach ($_POST['normal'] as $user => $perms) { - $afs = new Afs($path . '/' . $file); - unset($perms['acl']); - $newAcl = empty($perms) ? 'none' : implode('', array_keys($perms)); - $ret = $ret && $afs->changeAcl($user, $newAcl, $path . '/' . $file); + $aclPath = $path . '/' . $file; + $afs = new Afs($aclPath); + $currentAcl = $afs->readAcl($aclPath); + if (!is_array($currentAcl)) { + fm_set_msg(lng('Unable to read the current AFS ACL'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + if (!empty($currentAcl['inherited'])) { + fm_set_msg(lng('Inherited AuriStor ACLs are read-only here'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $allowedRights = array('l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'); + $aclSets = array('normal' => false, 'negative' => true); + if ((!isset($_POST['normal']) || !is_array($_POST['normal'])) + && (!isset($_POST['negative']) || !is_array($_POST['negative']))) { + fm_set_msg(lng('Permissions not changed'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $aclBatches = array('normal' => array(), 'negative' => array()); + foreach ($aclSets as $setName => $negative) { + if (!isset($_POST[$setName]) || !is_array($_POST[$setName])) { + continue; } + + foreach ($_POST[$setName] as $user => $perms) { + if (!is_array($perms)) { + $ret = false; + continue; + } + + $newAcl = ''; + foreach ($allowedRights as $right) { + if (isset($perms[$right])) { + $newAcl .= $right; + } + } + $newAcl = $newAcl == '' ? 'none' : $newAcl; + $aclBatches[$setName][$user] = $newAcl; + } + + if (!empty($aclBatches[$setName])) { + $changed = $afs->changeAclEntries( + $aclBatches[$setName], $aclPath, $negative); + $ret = $changed && $ret; + } + } + + if (empty($aclBatches['normal']) && empty($aclBatches['negative'])) { + $ret = false; } fm_set_msg(lng($ret ? 'Permissions changed' : 'Permissions not changed'), $ret ? 'ok' : 'error'); @@ -2193,8 +2241,11 @@ class="edit-file"> readAcl($file_path); - $normal_acl = is_array($mode) && isset($mode['normal']) ? $mode['normal'] : array(); - $negative_acl = is_array($mode) && isset($mode['negative']) ? $mode['negative'] : array(); + $acl_readable = is_array($mode); + $normal_acl = $acl_readable && isset($mode['normal']) ? $mode['normal'] : array(); + $negative_acl = $acl_readable && isset($mode['negative']) ? $mode['negative'] : array(); + $acl_inherited = $acl_readable && !empty($mode['inherited']); + $acl_readonly = !$acl_readable || $acl_inherited; ?>
@@ -2204,10 +2255,16 @@ class="edit-file"> :

+ +
+ +
+ + > @@ -2218,8 +2275,16 @@ class="edit-file"> + + + + + + + + - + $perms) { $encoded_user = fm_enc($user); ?> @@ -2228,27 +2293,44 @@ class="edit-file"> - + + + + + + + + + - + $perms) { $encoded_user = fm_enc($user); ?> - + - + + + + + + + + +
/ ABCDEFGH
+

  - +

@@ -5704,6 +5786,8 @@ function lng($txt) $tr['en']['admin'] = 'admin'; $tr['en']['normalRights'] = 'Normal Rights'; $tr['en']['negativeRights'] = 'Negative Rights'; + $tr['en']['Unable to read the current AFS ACL'] = 'Unable to read the current AFS ACL'; + $tr['en']['Inherited AuriStor ACLs are read-only here'] = 'Inherited AuriStor ACLs are read-only here'; $tr['en']['Copying'] = 'Copying'; $tr['en']['CreateNewItem'] = 'Create New Item'; $tr['en']['Name'] = 'Name'; From ab4ca69009ecbb5a9bd73225d25f97065bcd60b9 Mon Sep 17 00:00:00 2001 From: "Karl A. Grindley" Date: Mon, 17 Aug 2026 20:23:17 -0400 Subject: [PATCH 04/15] Add AFS rebase evidence and offline audits --- docs/AFS_FEATURE_INVENTORY.md | 390 ++++++++++++++++++++++++++++++++++ docs/AFS_REBASE_NOTES.md | 164 ++++++++++++++ docs/LIVE_AFS_TEST_PLAN.md | 204 ++++++++++++++++++ tests/afs_io_path_audit.php | 380 +++++++++++++++++++++++++++++++++ tests/afs_regression.php | 241 +++++++++++++++++++++ tests/afs_static.php | 239 +++++++++++++++++++++ 6 files changed, 1618 insertions(+) create mode 100644 docs/AFS_FEATURE_INVENTORY.md create mode 100644 docs/AFS_REBASE_NOTES.md create mode 100644 docs/LIVE_AFS_TEST_PLAN.md create mode 100644 tests/afs_io_path_audit.php create mode 100644 tests/afs_regression.php create mode 100644 tests/afs_static.php diff --git a/docs/AFS_FEATURE_INVENTORY.md b/docs/AFS_FEATURE_INVENTORY.md new file mode 100644 index 00000000..9a971fc8 --- /dev/null +++ b/docs/AFS_FEATURE_INVENTORY.md @@ -0,0 +1,390 @@ +# AFS Feature Inventory + +This document records the AFS-related behavior present at the pre-rebase fork tip +`194b4d034e99e6ad20c99bb31ea512f12a9a916b`. It is an inventory, not a statement +that the behavior is correct or has been validated against a live OpenAFS or +AuriStor mount. Post-rebase fixes are deliberately not folded into this historical +inventory; see `docs/AFS_REBASE_NOTES.md` for the replay mapping, hardening layer, +test results, and current claim boundary. + +Evidence references below use the form `commit:file:line`. Line numbers refer to +the old AFS tip so that the inventory remains stable after rebasing. + +## Classification + +- **Actively wired** means Tiny File Manager calls the code from its normal + request flow when `$afsSupport` is enabled. +- **Latent helper** means `afs.php` contains an implementation, but Tiny File + Manager does not call it or produce the legacy form fields needed to reach it. +- **Generic bypass** means Tiny File Manager continues to use its ordinary PHP + data-plane path without the AFS device and opened-handle checks in `afs.php`. + +This distinction is important: the old fork actively wires AFS ACL display and +editing, but it does not route most file operations through the AFS-safe helper +methods. + +## Provenance + +Fork repository: `https://github.com/karlg100/tinyfilemanager.git` + +Canonical upstream: `https://github.com/prasathmani/tinyfilemanager.git` + +The old AFS tip is exactly two fork-local commits after merge base +`2f357ee3d524f1085a7ca2707776c0f33ef85835` (`Fix translation error (#349)`). +The canonical upstream tip fetched for the rebase was +`41491439a6b243c55502581e53fad20bc4c6e777`. + +| Commit | Subject | Fork-local behavior | +| --- | --- | --- | +| `da98b2aa88d9ba2df7c2d67578710faec4431c3e` | `added proxy support for URL downloads` | Adds optional `$proxyServer` configuration and an HTTP stream context for the non-cURL URL-upload path. It contains no AFS logic. | +| `194b4d034e99e6ad20c99bb31ea512f12a9a916b` | `added AFS support` | Adds all 967 lines of `afs.php` and modifies Tiny File Manager to load it, display caller access, and read/write ACLs. It also contains unrelated local configuration and UI changes. | + +The added `afs.php` carries a University of Michigan copyright notice and says +“See COPYRIGHT” (`194b4d0:afs.php:2-5`), but the old tree contains `LICENSE` and +no `COPYRIGHT` file. That source/license provenance should be resolved before +redistribution. + +## Actively wired AFS behavior + +### Bootstrap and platform assumptions + +- `$afsSupport` defaults to `true`, and Tiny File Manager conditionally loads + `afs.php` (`194b4d0:tinyfilemanager.php:134-146`). +- Loading `afs.php` requires the PHP `posix` extension. If it is absent, the + include prints an error and terminates the request + (`194b4d0:afs.php:12-16`). +- Tiny File Manager's configured root remains `$_SERVER['DOCUMENT_ROOT']`; AFS + enablement does not change it to `/afs` or require it to be within AFS + (`194b4d0:tinyfilemanager.php:56-62`). +- Application login is disabled by default in the AFS commit + (`194b4d0:tinyfilemanager.php:20-30`). As a result, `FM_READONLY` is false for + this configuration. The code appears to assume external authentication and + filesystem enforcement, but does not establish either. +- `Afs::__construct()` reads `$_SERVER['REMOTE_USER']` without checking that it + exists, records the initial working directory, stats `/afs/`, compares its + device with `/`, validates the supplied path, initializes legacy form state, + invokes the legacy command dispatcher, and calls `getACLAccess()` + (`194b4d0:afs.php:52-94`). `REMOTE_USER` is used only in error logging; it is + not used to acquire credentials. + +No `aklog`, `klog`, token, PAG, `setpag`, or equivalent caller-credential setup +exists in either fork-local commit. `/usr/bin/fs` and all filesystem operations +inherit the web-server process's effective credentials. + +### Permission display and `getcalleraccess` + +When AFS support is enabled and permission columns are visible, Tiny File +Manager replaces the POSIX mode string with the output of +`Afs::getACLAccess()` for both folders and files. It also removes the POSIX +owner/group column and adjusts table colspans +(`194b4d0:tinyfilemanager.php:1981-1986,2010-2049,2063-2124,2142-2156`). + +`Afs::getACLAccess($path)`: + +1. Runs `/usr/bin/fs getcalleraccess `. +2. Accepts only output matching the exact English form + `Callers access to ... is `. +3. Lowercases and returns the rights string. +4. Maps `l`, `a`, `d`, `i`, `r`, and `w` to public privilege flags, but only + enters the mapping block if lookup (`l`) is present. + +The implementation is at `194b4d0:afs.php:651-685`. + +For every displayed item, Tiny File Manager constructs `Afs`, whose constructor +already invokes `getACLAccess()`, and then explicitly calls `getACLAccess()` a +second time (`194b4d0:tinyfilemanager.php:2015-2017,2072-2074`). Therefore a +normal directory listing performs two shell commands per displayed entry. + +The resulting privilege flags do not authorize or hide actions. The delete, +rename, copy, edit, upload, and download controls continue to be governed by +`FM_READONLY`, not AFS rights. `Afs::get_js_declarations()` can expose the flags +to JavaScript, but Tiny File Manager never calls it +(`194b4d0:afs.php:933-952`). + +### ACL reading + +`Afs::readAcl($path)` runs `/usr/bin/fs listacl ` and parses normal and +negative ACL entries into boolean maps for these AFS rights: + +| Right | Meaning used by the UI | +| --- | --- | +| `l` | lookup | +| `r` | read | +| `w` | write | +| `i` | insert | +| `d` | delete | +| `k` | lock | +| `a` | administer | + +The parser is at `194b4d0:afs.php:593-649`. It depends on the exact English +headings emitted by `fs listacl`, splits on `Negative rights:`, and recognizes +an error only if output starts with `fs:`. It does not force a stable locale. + +The AFS permissions page calls this method and renders the currently returned +normal and negative entries (`194b4d0:tinyfilemanager.php:1865-1937`). It does +not provide a control to add a new principal. + +Known UI defects in the old tip include: + +- Both normal and negative lock checkboxes test `$perms['l']` instead of + `$perms['k']` (`194b4d0:tinyfilemanager.php:1919,1934`). +- ACL principal names and the full path are not consistently escaped in the + generated HTML. +- The page is offered for regular files as well as directories. Actual + `fs setacl` behavior for regular-file paths remains a live-AFS validation + requirement. + +### ACL writing + +`Afs::changeAcl()` supports normal or negative ACLs and optionally recursive +changes. It shell-quotes the entity, rights, and path, then executes either: + +```text +/usr/bin/fs sa [ -negative] +``` + +or an unqualified `find ... -type d -exec /usr/bin/fs sa ...` command for +recursive operation (`194b4d0:afs.php:563-591`). It treats any output containing +`fs:` as failure. + +Tiny File Manager disables the POSIX chmod GET and POST flows while AFS support +is enabled and substitutes ACL flows +(`194b4d0:tinyfilemanager.php:1025-1112,1793-1863`). The AFS POST handler: + +- handles only `$_POST['normal']`; +- removes a synthetic `acl` field, concatenates checked right names, and sends + `none` when a principal has no checked rights; +- constructs a new `Afs` object for every normal principal; +- short-circuits after the first failed update, so earlier ACL changes can + remain applied while later principals are skipped; and +- leaves its redirect commented out. + +Although the GET page renders negative rights, the POST handler ignores +`$_POST['negative']`. The UI also exposes neither recursive changes nor the +latent `negative=true` argument. These are incomplete features, not merely +untested ones. + +`readAcl()`, `changeAcl()`, and `getACLAccess()` accept arbitrary path arguments +and do not independently call `pathSecurity()` or `makePathAFSlocal()`. An +`Afs` constructor that rejects its path still produces an object, and Tiny File +Manager then calls `getACLAccess()` on the original path. Consequently, enabling +AFS does not prevent ACL commands from being attempted on a non-AFS Tiny File +Manager root. + +## Latent AFS helpers + +The following behavior exists in `afs.php`, but it is not connected to Tiny +File Manager's forms or action handlers. + +### Path and mount confinement primitives + +`Afs` uses the device number returned by `stat('/afs/')` as its definition of +“in AFS.” + +- `pathSecurity($path)` follows the path with `stat()`, accepts it only when its + device matches `/afs`, and strips a trailing slash. Its own comment calls it + a raceable initial check (`194b4d0:afs.php:804-830`). +- `makePathAFSlocal($path)` changes into a directory, stats `.`, and requires + the same AFS device before later code operates on basenames + (`194b4d0:afs.php:833-849`). +- `linkSafeFileExists($path)` uses `lstat()`, so a broken symlink counts as an + existing entry (`194b4d0:afs.php:860-869`). +- `setPath()` treats literal `/afs` and `/afs/` as null, then attempts to run + `pathSecurity()` on the null path. The AFS root therefore cannot be + represented correctly (`194b4d0:afs.php:872-896`). + +`Afs::__construct()` attempts to detect a missing AFS mount by rejecting the +case where `/afs` and `/` have the same device number +(`194b4d0:afs.php:56-68`). Failure of `stat('/afs/')` itself is not checked +before array access. This heuristic also assumes that all paths intended to be +managed share `/afs`'s device number. + +There is no explicit OpenAFS/AuriStor volume-mount-point API. Real nested mounts +with another device are rejected by the helper checks. Whether OpenAFS and +AuriStor volume mount points present the expected `stat()` and `is_link()` +semantics must be established on live mounts. + +### Legacy dispatcher + +The constructor creates a session `formKey` and calls `processCommand()`. That +dispatcher requires `$_POST['command']` plus a matching form key and routes +these legacy commands: + +| Command | Helper | +| --- | --- | +| `newfolder` | `Afs::createFolder()` | +| `rename` | `Afs::afsRename()` | +| `cut` | `Afs::moveFiles()` | +| `copy` | `Afs::copyFiles()` | +| `delete` | `Afs::deleteFiles()` | + +See `194b4d0:afs.php:84-93,126-156`. No Tiny File Manager form or handler emits +`command`, `formKey`, `selectedItems`, `originPath`, or `newName`; those field +names occur only in `afs.php`. The dispatcher is therefore unreachable from the +Tiny File Manager UI as committed. + +### Latent operation behavior + +| Helper | Intended AFS behavior | Important limitations | +| --- | --- | --- | +| `Afs::createFolder()` (`afs.php:197-220`) | Changes into a verified AFS directory, reduces the requested value to a basename, uses `lstat()` collision detection, and creates the directory. | Uses mode `0644`, which lacks directory execute/search bits. | +| `Afs::removeFolder()` / `deleteFiles()` (`afs.php:223-324`) | Recursively checks directory devices, avoids descending through symlinked directories, and unlinks entries relative to a checked working directory. | Some unlink results are ignored; no Tiny delete flow calls these methods. | +| `Afs::afsRename()` (`afs.php:327-362`) | Rejects symlink rename, checks destination with `lstat()`, and delegates to a root-confined rename helper. | Calls undefined `filedrawers_rename()`. The `filedrawers` extension check is commented out at `afs.php:18-24`, and no implementation exists in the repository. | +| `Afs::moveFiles()` (`afs.php:364-390`) | Delegates moves to `filedrawers_rename(source,destination,'/afs')`. | Also depends on the missing function and is unreachable from Tiny forms. | +| `Afs::copyFiles()` / `copy_dirs()` (`afs.php:392-475`) | Recursively checks source and destination directories against the AFS device and dispatches files and links to `Afs::copy()`. | It creates the target directory before its self-copy equality check, can leave a directory after failure, and does not explicitly preserve ACLs. | +| `Afs::copy()` (`afs.php:478-541`) | For regular files, opens the source, verifies the opened handle's device, verifies the destination parent, opens the destination with exclusive `xb`, and copies in 1 MiB chunks. For symlinks, it reproduces the link without dereferencing it after checking both parent directories. | It preserves links whose target may resolve outside AFS; uses the source basename instead of the requested destination basename for link creation; does not check read/write results; and can report success after a short write. | +| `Afs::readfile()` (`afs.php:544-561`) | Opens the configured path, verifies the opened handle's device, and only then streams it to the client. | No Tiny download, view, or direct-link path calls it. | + +Other latent helpers include symlink-aware type detection, JavaScript folder +listing, a breadcrumb rooted at `/afs`, Smarty assignments, and privilege/entry +JavaScript declarations (`194b4d0:afs.php:97-123,687-802,898-963`). They are +legacy FileDrawers-style code rather than Tiny File Manager integration. +`getType()` references an absent `Mime` class, `get_foldercontents_js()` uses a +commented-out MIME icon assignment, and `originPath` is created as an undeclared +dynamic property. + +## Generic Tiny File Manager data-plane paths + +The table below describes the operations actually reached from Tiny File +Manager. None of them invokes the corresponding AFS-safe helper. + +| Operation | Active Tiny File Manager path at `194b4d0` | AFS and symlink coverage | +| --- | --- | --- | +| Root and navigation | `FM_ROOT_PATH` remains the configured document root; `FM_PATH` is lexically cleaned (`tinyfilemanager.php:347-386,2449-2459`). | No requirement that the root or resolved path be on the `/afs` device. Lexical cleanup does not establish a resolved-path device boundary. | +| Create file/folder | Ordinary `fopen()` and `fm_mkdir()` (`tinyfilemanager.php:600-633,2353-2364`). | No AFS device validation. `fm_mkdir()` creates recursively with mode `0777` subject to umask. | +| Copy/duplicate | `fm_rcopy()` and `fm_copy()` (`tinyfilemanager.php:635-704,2323-2387`). | Uses `is_dir()`, `scandir()`, and PHP `copy()`. Because `is_dir()` is tested before `is_link()`, a symlink to a directory is followed and can traverse outside AFS. | +| Move/rename | `fm_rename()` and PHP `rename()` (`tinyfilemanager.php:635-765,767-793,2306-2313`). | No device check and no AFS-specific link handling. Unlike latent `afsRename()`, symlinks are not categorically rejected. | +| Delete | Single and mass handlers call `fm_rdelete()` (`tinyfilemanager.php:578-598,890-918,2229-2250`). | `fm_rdelete()` checks `is_link()` first and unlinks the link instead of recursing through it, but applies no AFS device boundary to real directories or nested mounts. | +| Browser upload | Uses `$_REQUEST['fullpath']`, recursive `mkdir(0777)`, and `move_uploaded_file()` (`tinyfilemanager.php:823-888`). | No AFS resolved-path or device validation. No helper confines the requested full path to AFS. | +| URL upload | Downloads to `sys_get_temp_dir()` and then calls ordinary `rename()` into the destination (`tinyfilemanager.php:495-573`). | No AFS destination check. Moving a local temporary file into AFS is cross-filesystem-sensitive. The proxy commit affects only the HTTP fetch, not destination safety. | +| Download | Uses `is_file()`, `filesize()`, and built-in `readfile()` (`tinyfilemanager.php:795-821`). | Does not use opened-handle device verification and follows file symlinks. | +| View/quick view | Uses MIME/file inspection and `file_get_contents()` for text; media URLs point at the web-visible file URL (`tinyfilemanager.php:1502-1696`). | No AFS helper or device check. File symlinks are followed. Media and “Open” links move the read into the web server. | +| Edit/save/backup | Editors use `fopen(...,'w')`; AJAX save and backup use ordinary `fopen()`/`copy()` (`tinyfilemanager.php:398-445,1698-1789`). | No AFS device or opened-handle confinement. Symlink targets can be read or modified through normal PHP resolution. | +| Archive creation | Changes into the current path and calls `FM_Zipper` or `FM_Zipper_Tar` (`tinyfilemanager.php:920-967,3011-3185`). | Recursive archive creation uses `is_dir()`/`scandir()` without an AFS boundary and can follow symlinked directories. | +| Archive extraction | Calls `ZipArchive::extractTo()` or `PharData::extractTo()` (`tinyfilemanager.php:969-1023,3056-3067`). | The fork adds no AFS device validation or archive-entry confinement before extraction. | +| Direct link | Folder/file rows and the viewer emit `FM_ROOT_URL` links (`tinyfilemanager.php:1608-1609,2055,2133`). | Bypasses PHP and `Afs::readfile()` entirely. Authentication, token use, symlink resolution, and confinement become web-server concerns. | + +Search, image preview, MIME probing, directory-size calculation, and other +ordinary Tiny File Manager reads likewise remain outside `Afs`. + +## Mount-point and symlink behavior summary + +### What the latent helpers attempt + +- Define AFS membership as equality with `/afs`'s `st_dev`. +- Reject `/afs` when it appears to be part of `/` rather than a separate mount. +- Change into a verified parent and operate on basenames to reduce path races. +- Verify opened regular-file handles before copy or download. +- Use `lstat()` for destination collision checks. +- Unlink symlinks during deletion rather than walking through them. +- Preserve symlinks during copy rather than copying their targets. + +### What the wired application actually does + +- Does not require `FM_ROOT_PATH` to resolve inside AFS. +- Does not use device checks for create, copy, move, delete, upload, download, + edit, archive, view, or direct-link paths. +- Follows directory symlinks during generic copy and archive creation. +- Follows file symlinks during generic download, view, and edit. +- Correctly unlinks a symlink rather than recursively deleting its target in + the generic delete helper, but does not stop at real filesystem boundaries. +- Has no explicit handling for OpenAFS/AuriStor volume mount points. + +AFS volume mount points, ordinary links within AFS, links out of AFS, broken +links, and any Unix mounts below the configured root require separate live +coverage. A test of one kind does not establish the behavior of the others. + +## Other fork-local behavior + +### Proxy commit `da98b2a` + +The first fork-local commit adds a commented `$proxyServer = 'host:port'` +setting (`194b4d0:tinyfilemanager.php:129-132`). In the non-cURL URL-upload +branch it creates an HTTP stream context with: + +```php +array('http' => array( + 'proxy' => 'tcp://' . $proxyServer, + 'request_fulluri' => true, +)); +``` + +See `194b4d0:tinyfilemanager.php:541-547`. The code hardcodes +`$use_curl = false` at line 496, so the proxy is effective for the committed +path. If a later upstream path selects cURL, this setting does not configure a +cURL proxy. + +### Non-AFS changes bundled into `194b4d0` + +These changes are independent of ACL or AFS confinement and should be resolved +deliberately rather than treated as incidental conflict noise: + +| Change | Old-tip evidence | +| --- | --- | +| Disable Tiny File Manager's built-in authentication | `tinyfilemanager.php:20-30` | +| Change date display from `d.m.y H:i` to `m/d/Y H:i:s` | `tinyfilemanager.php:70-72` | +| Disable the online office-document viewer | `tinyfilemanager.php:91-96` | +| Define `FM_EXCLUDE_ITEMS` only when the exclusion list is nonempty, and make an undefined constant mean allow-all | `tinyfilemanager.php:360-366,2480-2493` | +| Update the DataTables CDN from 1.10.20 to 1.10.21 | `tinyfilemanager.php:3692` | +| Add English AFS-right labels, including a duplicate `admin` assignment | `tinyfilemanager.php:4026-4033` | +| Change line endings on the first three lines | opening hunk of commit `194b4d0` | + +## Compatibility blockers and claim boundary + +The rebased result must not be described as AFS-compatible without resolving or +explicitly accepting the following points: + +1. **The active data plane bypasses AFS confinement.** Copy, move, delete, + upload, download, edit, archive, view, and direct-link behavior does not use + the `Afs` safety helpers. +2. **No live AFS evidence exists in this inventory.** Device-number assumptions, + ACL command output, volume mount points, symlinks, and actual kernel + enforcement remain unverified. +3. **Caller credentials are external and undocumented.** The code does not + establish an AFS/AuriStor token or PAG, and `REMOTE_USER` does not bind the + PHP process to that user. +4. **The root and utility locations are fixed or unconstrained.** AFS helpers + hardcode `/afs` and `/usr/bin/fs`, while Tiny File Manager's root remains the + document root and is not required to be in AFS. +5. **Mount and link semantics are incomplete.** The helpers rely on `st_dev` and + POSIX link predicates; generic Tiny paths can follow links outside AFS, and + AFS/AuriStor volume mount-point behavior is unknown. +6. **Legacy move and rename cannot run as committed.** They depend on missing + `filedrawers_rename()`. +7. **ACL editing is incomplete.** Negative changes are ignored, lock state is + rendered incorrectly, partial normal-ACL updates are possible, and regular + file ACL behavior has not been established. +8. **ACL command parsing and performance are fragile.** It assumes exact English + output, does not set a stable locale, has limited error detection, and runs + `getcalleraccess` twice per displayed entry. +9. **Some latent helpers have correctness defects.** Literal `/afs` path + handling, directory creation mode, link-copy destination naming, unchecked + stream writes, and self-copy cleanup all need decisions or fixes before use. +10. **Source provenance is incomplete.** The newly added file references a + missing `COPYRIGHT` document. + +At the old fork tip, it is accurate to claim only that Tiny File Manager has an +AFS-aware ACL display/editor and a collection of dormant AFS-oriented helper +methods. It is not accurate to claim that all managed paths are confined to AFS +or that all file operations are AFS-safe. + +## Live-AFS validation targets + +The following require a live OpenAFS/AuriStor environment and are intentionally +separate from mount-free static/regression tests: + +- `/afs` absent, `/afs` accidentally local, and a correctly mounted `/afs`; +- configured root outside AFS, at `/afs`, and below `/afs`; +- traversal across real AFS/AuriStor volume mount points; +- ordinary in-AFS symlinks, links to another AFS volume, links outside AFS, and + broken links for every read and mutation operation; +- `fs getcalleraccess`, `fs listacl`, and `fs setacl` output and exit behavior + under the deployment locale and AuriStor/OpenAFS client version; +- normal, negative, and lock ACL round trips, including failure partway through + a multi-principal update; +- file-versus-directory paths for ACL operations; +- copy, move, rename, delete, browser upload, URL upload, download, edit, + archive, view, and direct-link behavior under restricted AFS rights; +- URL-upload movement from local temporary storage into AFS; and +- confirmation that the web request runs in the intended user's credential + context and that direct web-server reads enforce the same policy. diff --git a/docs/AFS_REBASE_NOTES.md b/docs/AFS_REBASE_NOTES.md new file mode 100644 index 00000000..6ecead5d --- /dev/null +++ b/docs/AFS_REBASE_NOTES.md @@ -0,0 +1,164 @@ +# AFS rebase notes + +## Scope and provenance + +The AFS-enhanced fork was replayed onto the fetched canonical Tiny File Manager tip without rewriting any remote-tracking ref and without force-pushing. + +```text +origin https://github.com/karlg100/tinyfilemanager.git +upstream https://github.com/prasathmani/tinyfilemanager.git +branch kag/afs-rebase-upstream-20260817 +``` + +| Role | Commit | +| --- | --- | +| Historical merge base | `2f357ee3d524f1085a7ca2707776c0f33ef85835` | +| Historical proxy commit | `da98b2aa88d9ba2df7c2d67578710faec4431c3e` | +| Historical AFS tip | `194b4d034e99e6ad20c99bb31ea512f12a9a916b` | +| Rebase target (`upstream/master`) | `41491439a6b243c55502581e53fad20bc4c6e777` | +| Replayed proxy commit | `a2df5e893041a3e18134299058f7aa74ccda96d9` | +| Replayed AFS commit | `ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1` | +| Post-rebase AFS hardening | `be98d299ec262e34bb2b759b7742c3dfc18bd3af` | + +The authoritative old-to-new mapping is: + +```text +da98b2aa88d9ba2df7c2d67578710faec4431c3e -> a2df5e893041a3e18134299058f7aa74ccda96d9 +194b4d034e99e6ad20c99bb31ea512f12a9a916b -> ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1 +``` + +The pre-rebase AFS tip is retained at: + +```text +refs/heads/safety/afs-pre-rebase-194b4d0-20260817 +``` + +Useful provenance checks are: + +```sh +git range-diff --creation-factor=100 \ + 2f357ee3d524f1085a7ca2707776c0f33ef85835..194b4d034e99e6ad20c99bb31ea512f12a9a916b \ + 41491439a6b243c55502581e53fad20bc4c6e777..ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1 + +git diff --exit-code \ + 194b4d034e99e6ad20c99bb31ea512f12a9a916b:afs.php \ + ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1:afs.php +``` + +The second command is clean: `afs.php` in the replay commit is byte-for-byte the historical file. Any later `afs.php` hardening is intentionally a post-rebase change, not a rewritten historical commit. Because the proxy patch was adapted around substantial upstream changes, `git range-diff` may show it as an old deletion plus a new addition; the explicit mapping above records its provenance. + +## Semantic conflict inventory + +The three-way audit found one proxy configuration conflict and 16 conflict blocks while replaying the AFS integration. The blocks below are numbered so that every resolution is reviewable even where one conceptual decision covered several adjacent hunks. + +### Proxy configuration + +| ID | Conflict | Resolution | +| --- | --- | --- | +| P-01 | The old proxy declaration expected the end of the 2020 configuration section. Upstream inserted editor-language, external-resource, and `config.php` override support there. | Keep all current upstream configuration. Place the optional `$proxyServer` declaration immediately before `config.php` is loaded, allowing a deployment override. In the URL-upload path, retain upstream URL/port SSRF checks and destination handling; only add the proxy-enabled HTTP stream context. | + +The proxy is not a security boundary. A forward proxy performs its own DNS resolution and redirect handling and may be able to reach addresses that the application's hostname check did not anticipate. Its egress policy must therefore be independently restricted and tested. + +### Defaults, configuration, and bootstrap + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-01 | The old AFS commit carried a mixed-CRLF edit to the default JSON and the removed `calc_folder` setting, while upstream now stores `theme`. | Keep the upstream JSON and `theme`; normalize replayed lines to LF. The removed setting is not an AFS feature. | +| AFS-02 | The fork's site-specific date format overlapped upstream's current date format and new `path_display_mode`. | Keep both current upstream settings. Date formatting can still be changed through deployment configuration. | +| AFS-03 | The old import point collided with upstream editor mappings, `config.php`, ACE settings, and external-resource configuration. | Keep the whole upstream bootstrap. Define `$afsSupport = false` before `config.php`, load `config.php`, and then conditionally load `__DIR__ . '/afs.php'`. This makes AFS an explicit deployment opt-in and avoids breaking ordinary non-AFS Tiny File Manager installations. | +| AFS-04 | The fork conditionally omitted `FM_EXCLUDE_ITEMS`; upstream always defines it, serializes it for old PHP, and supports full-path exclusions. | Keep upstream's definition and behavior. Do not restore the fork's undefined-constant fallback. | + +The old commit also changed authentication to disabled and disabled the online viewer. Those are deployment preferences, not AFS semantics. The replay deliberately retains upstream's authentication-enabled default, global-readonly behavior, online-viewer default, current theme, and current date format. + +### Permission and ACL actions + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-05 | The fork split the POSIX chmod handler before upstream added CSRF verification. | Retain the upstream `token` requirement, `verifyToken()` failure behavior, validation, translated messages, and redirect. Add only `!$afsSupport` to select the POSIX branch. | +| AFS-06 | The old AFS ACL POST handler collided with the end of the upstream action section. It had no current CSRF flow, skipped path cleaning, and did not redirect after mutation. | Add a separate AFS branch with the same readonly, platform, CSRF, path-cleaning, existence, message, and post/redirect/get behavior as upstream. The replay preserves the historical normal-ACL update behavior; negative-ACL mutation is a documented follow-up requirement. | +| AFS-07 | The old AFS ACL form collided with the current POSIX form and main-view boundary. | Keep the current POSIX view for non-AFS mode. Add a Bootstrap-5-aware AFS view using current path-display policy, a hidden CSRF token, guarded ACL arrays, current footer flow, and encoded displayed principals. | + +### File-list metadata and table layout + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-08 | The old table header removed the Owner column with markup from an older Bootstrap/DataTables layout. | Keep the upstream table and suppress only Owner while AFS metadata is displayed. | +| AFS-09 | Folder permission lookup overlapped upstream's raw/sortable modification time and hardened POSIX owner/group lookup. | Preserve current sorting and POSIX error handling. In AFS mode only, display `fs getcalleraccess` output in the permissions column. | +| AFS-10 | The folder Owner cell conflicted independently with the new folder-row markup. | Retain current folder actions and hide only the Owner cell in AFS mode. | +| AFS-11 | File permission and owner lookup conflicted with current size/date sorting, preview, and owner fallback changes. | Preserve all current file-row behavior. Substitute AFS access text for POSIX mode bits and hide only Owner in AFS mode. | +| AFS-12 | The empty-table colspan was hard-coded for the old column set. | Compute visible metadata and content column counts once and keep the translated upstream empty-folder label. | +| AFS-13 | The summary-footer colspan and old badges no longer matched upstream's readonly and Bootstrap-5 layouts. | Use the computed total column count while retaining current summary text and badges. | + +The parent-directory row was adjusted with the same Owner-column rule; otherwise it would have remained misaligned even if the header and ordinary rows were correct. + +### Helpers, assets, and translations + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-14 | The old one-argument exclusion helper and undefined-constant guard conflicted with upstream's two-argument full-path exclusion helper. | Keep upstream's helper, call signature, serialization compatibility, and filename, extension, and full-path checks unchanged. | +| AFS-15 | A small historical DataTables version edit expanded into a wide footer/JavaScript conflict after upstream's Bootstrap, resource, CSP, and editor refactors. | Keep the complete upstream footer and configured external resources. Do not downgrade or hard-code DataTables. | +| AFS-16 | The old inline AFS translations collided with the relocated and expanded `lng()` function and ACE footer code. | Keep the current footer/ACE code. Add each AFS English fallback key once in the current `lng()` table; do not restore the duplicate `admin` entry or replace `translation.json`. | + +## Upstream security and behavior deliberately preserved + +The replay does not intentionally remove or bypass these post-2020 upstream changes: + +- authentication enabled by default, global readonly, per-user roots, and current session handling; +- CSRF tokens on the current mutation routes, including both POSIX and AFS permission changes; +- URL-upload localhost/loopback and known-port rejection before the optional proxy context; +- current path cleaning, archive-item cleaning, filename validation, and NUL-byte rejection; +- current filename and path output encoding and excluded-name, extension, and full-path checks; +- current file download token/session behavior, upload naming, and error handling; +- current Bootstrap, external-resource, translation, theme, editor, sorting, and responsive-table behavior. + +Preserving these controls is not a claim that upstream has complete symlink-safe confinement. The AFS-specific gaps below remain material. + +## Compatibility blockers at the replay boundary + +Do not claim general AFS/AuriStor compatibility from `ed6cc37` alone. + +1. Tiny File Manager calls only `Afs::changeAcl()`, `Afs::readAcl()`, and `Afs::getACLAccess()`. Its forms do not use the legacy `command`, `formKey`, `selectedItems`, or `originPath` protocol. The AFS-safe copy, recursive copy/delete, move, and read helpers in `afs.php` are therefore dormant. +2. Save, backup, create, copy/duplicate, move/rename, delete, upload/chunked upload/URL upload, download, view, direct links, and archive paths still use generic upstream I/O. A lexical `FM_PATH` check does not stop an in-root symlink from reaching an AFS path outside `FM_ROOT_PATH` or a local filesystem path. Direct links bypass PHP entirely. +3. `Afs::pathSecurity()` in the historical file compares device IDs with `/afs`; it does not prove containment below `FM_ROOT_PATH`. A same-device AFS path outside the configured root can pass, a local filesystem mounted at `/afs` can be misidentified, and an alternate AFS mount root is unsupported. +4. The replayed `afs.php` assumes a readable `/afs`, `/usr/bin/fs`, enabled `shell_exec`, the POSIX extension, `REMOTE_USER`, and exact English `fs listacl` and `fs getcalleraccess` output. It does not robustly handle all failures. +5. Dormant paths refer to `filedrawers_rename` and `Mime`, neither of which is supplied by this repository. The undeclared `originPath` property also causes modern-PHP compatibility concerns. +6. The replayed UI displays negative ACLs but the POST handler updates normal ACLs only. Both lock checkboxes read the `l` state instead of `k`, so their initial state can be wrong. A negative entry with all boxes cleared is not posted. +7. The historical constructor performs request processing and `getcalleraccess`, after which the listing calls `getcalleraccess` again. This produces two subprocesses per listed item and gives construction unexpected side effects. +8. OpenAFS ACLs are directory-oriented. The UI offers permission editing for files as well as directories; exact OpenAFS and AuriStor behavior must be established on the target client and server versions. +9. A proxy can change URL-upload name resolution and reachable networks. The retained upstream hostname check is not a substitute for proxy-side egress policy. + +## Post-rebase fixes and automated tests + +The two mapped commits above are the historical replay layer and should remain unchanged. Hardening and tests belong in one or more commits after `ed6cc37` so `git range-diff` continues to show what was replayed versus what was newly repaired. + +Commit `be98d299ec262e34bb2b759b7742c3dfc18bd3af` implements the separately reviewable production hardening: + +- fail-closed AFS availability, path, stat, command-execution, and parser handling; +- removal of constructor request/shell side effects and exactly one explicit access lookup per listed item; +- declared runtime state, all seven standard caller-access flags including `k`, and testable ACL parsing; +- case-preserving round trips for standard `lrwidka` and AuriStor auxiliary `A-H` rights, so uppercase `A`/`D` cannot be confused with lowercase admin/delete; +- negative ACL updates, correct `k` checkbox state, clearing ACL entries, and one `fs` invocation per positive or negative ACL set; +- inherited AuriStor ACL detection with read-only UI and server-side mutation rejection, avoiding accidental materialization of an inherited file ACL; +- strict ACL-output validation that rejects unknown rights, duplicate rights, missing headers, and malformed command output; +- safe missing-`filedrawers_rename` behavior, complete copy writes including flush/close failures, partial-destination cleanup, recursive-copy ancestry rejection, and correct symlink destination names; +- a test-overridable AFS root and command runner while retaining the production defaults `/afs` and `/usr/bin/fs`; +- explicit retention of the narrower ACL display/edit claim because generic data-plane routing was not changed. + +The case and inheritance handling follows the AuriStor [`fs listacl`](https://www.auristor.com/documentation/man/linux/1/fs_listacl.html) and [`fs setacl`](https://www.auristor.com/documentation/man/linux/1/fs_setacl.html) contracts: auxiliary rights are uppercase `A-H`, inherited file ACLs are marked in list output, and setting an ACL on such a file creates a file-specific ACL. + +The no-live-mount regression layer is intentionally separate as well: + +- `tests/afs_regression.php` exercises ACL parsing and command construction, case-sensitive auxiliary rights, inherited ACLs, caller-access flags, path/device rejection, handle-time copy/read checks, broken symlinks, and helper inventory without touching `/afs`. +- `tests/afs_static.php` checks default-off/config ordering, conditional `__DIR__` loading, retained upstream CSRF/URL-upload/exclusion controls, normal and negative ACL handling, all 15 rights, inherited-ACL gates, `k` mapping, batching, and one `getcalleraccess` call per listed item. +- `tests/afs_io_path_audit.php` inventories the generic endpoints. Until they are integrated, it records exact expected failures for save/backup, create, copy/duplicate, move/rename, delete, uploads, download/view/direct links, archives, symlink traversal, and mount-point traversal. An expected-failure audit must fail if the unsafe baseline changes unexpectedly; it must never silently convert an untested path into a pass. +- Run PHP lint on `tinyfilemanager.php`, `afs.php`, and every PHP test, followed by all three focused suites and any available upstream checks. + +The focused suite passed under PHP 7.4 and PHP 8.3: 46 regression assertions, 124 static assertions, and 80 I/O-path checks. The I/O audit reports four protected primitives, two fail-closed gates, 18 intentional expected failures, and zero unexpected failures. The PHP 7.4 run used the official `php:7.4-cli-alpine` image at digest `sha256:0d67d81f60f4a400f1b68e3a41e910c98c5e08f49e515f6855561a0f24d37852`. + +Static tests can validate dispatch, parsing, escaping, and fail-closed behavior, but they cannot validate PAG/token inheritance, AFS kernel behavior, ACL enforcement, mount points, volume boundaries, or the deployed `fs` output. Those claims require the disposable live plan in `docs/LIVE_AFS_TEST_PLAN.md`. + +## Remaining compatibility blockers + +The hardening commit repairs the actively integrated ACL surface and several dormant helpers; it does not wire Tiny File Manager's generic data plane into those helpers. A blanket AFS/AuriStor compatibility claim therefore remains blocked by all 18 expected-failure routes in `tests/afs_io_path_audit.php`, including direct links, archive operations, symlink traversal, and mount-point traversal. + +Additional live-only blockers are web-worker token/PAG identity, real OpenAFS and AuriStor `fs` output, file-versus-directory ACL semantics, same- and cross-volume behavior, unavailable/read-only mounts, and writeback failures. Positive and negative ACL sets require separate `fs` commands, so a failure between the two batches can still leave a partial cross-set update; that failure mode must be exercised and documented live. The upstream Dockerfile copies only `tinyfilemanager.php`, not `afs.php`, so its image is ordinary default-off Tiny File Manager rather than an AFS-capable deployment artifact. Data-plane wiring and AFS container packaging should be developed on distinct follow-on branches so the historical replay and ACL hardening remain reviewable. diff --git a/docs/LIVE_AFS_TEST_PLAN.md b/docs/LIVE_AFS_TEST_PLAN.md new file mode 100644 index 00000000..cd4b987a --- /dev/null +++ b/docs/LIVE_AFS_TEST_PLAN.md @@ -0,0 +1,204 @@ +# Disposable live AFS/AuriStor test plan + +## Objective and claim boundary + +This plan validates the AFS-enhanced Tiny File Manager against a real OpenAFS or AuriStor client. It is the required complement to static tests. A successful static run alone is not evidence that tokens reach the web worker, ACLs are enforced, symlinks are confined, or operations behave correctly across volume mount points. + +Run this plan only against disposable data and identities. The preferred fixture is a dedicated read-write test volume plus a second disposable volume for cross-volume tests. Never point `FM_ROOT_PATH` at a production volume, user home, shared project tree, or cell root. + +## Safety rules and stop conditions + +- Use an isolated VM or container host with the target AFS/AuriStor client version and a non-production web endpoint bound to loopback or a restricted test network. +- Use dedicated test principals. Do not copy production keytabs, long-lived tokens, cookies, configuration secrets, or ACLs into the evidence bundle. +- Create a unique run ID, a dedicated root, a sibling AFS escape target, and a local-filesystem escape target. Record their exact canonical paths before starting. +- Place a run-ID marker file at each test root. Destructive cleanup is permitted only after the operator verifies the marker, expected volume/FID, and exact path. +- Prefer a disposable volume snapshot/clone before destructive cases. Otherwise create a timestamped archive, file hash manifest, mount-point inventory, and complete ACL baseline. +- Stop immediately if an HTTP operation reads, changes, creates, renames, archives, downloads, or deletes anything outside the dedicated roots; if the web worker has an unexpected identity; or if a mount point resolves to a non-disposable volume. +- Treat a timeout, PHP warning, `fs` parse failure, or unexplained empty ACL as a failure, not as a skipped check. + +Suggested logical names are: + +```text +RUN_ID= +AFS_TEST_ROOT=/afs///tfm- +AFS_CROSS_VOLUME_ROOT=/afs///tfm- +AFS_ESCAPE_ROOT=/afs///outside- +LOCAL_ESCAPE_ROOT=/tmp/tfm-outside- +``` + +Substitute explicit, reviewed paths in commands. Do not use an unset variable, wildcard, cell root, `/afs`, `/`, home directory, or workspace root as a recursive-operation target. + +## Evidence manifest before mutation + +Create a timestamped evidence directory outside all test roots and record: + +- `git rev-parse HEAD`, `git status --short`, the old/new mapping, and the safety-ref object ID; +- container or VM image identity, web-server and PHP versions, loaded PHP extensions, and relevant PHP limits; +- AFS/AuriStor client and `fs` versions, mount configuration, cell name, cache-manager status, and server/volume identity; +- exact test-root, escape-root, mount-point, volume, FID, and canonical-path results; +- sanitized application configuration and checksums of deployed source files; +- process UID/GID/groups, SELinux/AppArmor state if applicable, and web service start command; +- token issuer, principal names, PAG identifiers, token expiry times, and `fs getcalleraccess` results, but never token material; +- recursive file inventory with types, sizes, timestamps, hashes for regular files, symlink targets, and ACLs for every directory; +- web access/error logs and PHP logs from a clean starting point. + +Keep command output in raw text as well as a short result table. Record the HTTP request, identity, expected result, actual result, and resulting filesystem/ACL delta for every case. + +## Identity, token, and PAG setup + +Use at least these dedicated identities: + +| Identity | Intended access | +| --- | --- | +| ACL administrator | `a` and the rights needed to seed and restore the fixture | +| Editor | lookup/read/write/insert/delete/lock as required by positive tests | +| Reader | lookup/read only | +| Denied principal | a positive grant plus a negative ACL used to prove denial | + +For OpenAFS, create a fresh PAG with the site's approved `pagsh`/Kerberos/`aklog` procedure. For AuriStor, use the site-approved equivalent isolated process credential/token procedure. Record the PAG and sanitized token listing before and after the test. + +The token must belong to the web worker, not merely the interactive shell. Start the disposable web server or PHP-FPM worker from the prepared PAG, or use the deployment's documented credential-injection mechanism. Then prove the effective identity from the same UID, process context, and mount namespace as PHP by comparing: + +1. the sanitized token/PAG view; +2. `fs getcalleraccess` for the test root; +3. a permitted and a denied HTTP filesystem action; and +4. the resulting server-side audit/access log identity. + +Repeat the authorization subset as Editor, Reader, and Denied principal using newly started workers or otherwise isolated credential contexts. Do not reuse a worker that might retain the prior identity. Include an expired/destroyed-token case and verify fail-closed behavior. + +## Deployment under test + +Deploy the exact candidate commit into the disposable web root. Back up the original test configuration, then set at least: + +```php +$afsSupport = true; +$root_path = '/afs/'; +``` + +Do not assume the upstream Dockerfile is the candidate deployment: it copies only `tinyfilemanager.php` and omits `afs.php`. If a container is used, build or mount an explicitly reviewed AFS-capable artifact and record both source-file checksums. + +Keep application authentication enabled unless the production design explicitly delegates authentication to the front-end server. Restrict the endpoint by network policy as well. Begin with URL proxying unset. If proxy behavior is in scope, test it later with a dedicated restricted proxy and record its DNS, redirect, and egress policy. + +Before using the browser, run PHP lint and the no-live-mount suites against the deployed source. Confirm the page loads without PHP warnings and that AFS mode is actually enabled. Verify that disabling `$afsSupport` restores ordinary upstream behavior without loading `afs.php`. + +## Fixture layout + +Create a deterministic fixture containing: + +- empty, small text, binary, zero-byte, large, Unicode-name, whitespace-name, dot, and allowed/disallowed-extension files; +- empty and nested directories, a deep tree, and a directory with enough entries to expose per-item command latency; +- same-volume and cross-volume destinations; +- a zip and tar archive with ordinary nested content; +- separately generated traversal archives containing `../`, absolute names, nested symlink entries, and names that collide with existing files; +- relative and absolute symlinks described below; +- a child-volume mount point and, when available, a read-only volume/mount point; +- sentinel files in both escape roots whose content, metadata, hashes, and ACLs are recorded. + +Seed normal and negative ACL entries with `fs` before opening the ACL editor so every row and right can be round-tripped. For AuriStor, include the auxiliary rights `A-H` as distinct case-sensitive rights in addition to standard `lrwidka`. Record `fs listacl`, `fs getcalleraccess`, volume/FID information, and expected effective rights for each identity. + +## ACL and caller-access matrix + +For both a directory and a regular file path, while recording whether the implementation applies an ACL to the file, its parent, or rejects the operation: + +1. Open the ACL editor and compare every normal and negative principal and each standard `l`, `r`, `w`, `i`, `d`, `k`, `a` and AuriStor auxiliary `A-H` checkbox with raw `fs listacl` output. Prove specifically that uppercase `A` is not decoded as lowercase admin `a`, uppercase `D` is not decoded as lowercase delete `d`, and `B`, `C`, `E`, `F`, `G`, and `H` survive unchanged. +2. Toggle each normal right individually, submit with a valid CSRF token, and confirm the exact raw ACL delta and a successful/denied operation that exercises the right. +3. Repeat for negative rights, including adding, changing, and clearing an entry to `none`. Negative ACL controls must not be reported as supported if the POST path ignores them. +4. Verify that the lock checkbox maps `k` to `k`; a principal with `l` but not `k`, and one with `k` but not `l`, must display differently. +5. Submit with a missing token, an invalid token, a stale token, a foreign-session token, and a readonly application account. The ACL must remain byte-for-byte unchanged. +6. Test principal names containing cell qualifiers and characters that exercise HTML/form encoding. Confirm that the displayed principal, submitted key, and `fs` argument are identical and that no markup executes. +7. Remove the token/PAG and repeat read and mutation attempts. Confirm a clear failure without partial ACL changes. +8. Compare the UI permission text with `fs getcalleraccess` under all four identities and across ordinary directories, child mount points, and the cross-volume root. +9. Measure listing time and count `fs getcalleraccess` executions for small and large directories. The expected optimized behavior is no constructor lookup and one explicit lookup per displayed item; record any remaining O(N) usability limit. +10. Capture raw OpenAFS and AuriStor CLI output separately if both implementations are supported. Parser success on one is not evidence for the other. +11. On AuriStor, create a file with an inherited ACL. Verify that the UI identifies it as inherited, disables submission, and that a crafted POST is rejected without converting it to a file-specific ACL. If explicit conversion is tested separately, record the exact command and use the client-supported ACL-removal operation to restore inheritance. +12. Populate multiple positive and negative ACEs and verify one `fs` invocation per set. Force the second set to fail after the first succeeds; record the partial-update behavior and restore the exact baseline before continuing. + +A negative ACE alone is not proof of denial when `anonymous` or `system:anyuser` grants the same right. Include authenticated and token-discarded anonymous requests, and treat the observed effective operation—not the checkbox—as the authorization result. + +Restore the ACL baseline after this matrix before starting data-plane tests. + +## Data-plane operation matrix + +Exercise each row as an allowed Editor, a Reader expected to be denied, and where meaningful the Denied principal. After every request, compare the full test-root and escape-root manifests. + +| Area | Cases to execute | Required evidence | +| --- | --- | --- | +| Listing/navigation | root and nested navigation, parent link, hidden items, exclusions, search, large directory | HTTP result, displayed names/access, raw listing, timing, `fs` call count | +| Create | new file, empty file, directory, nested directory, invalid/NUL/path-like name, existing target | status/message, type/mode/FID, no outside-root delta | +| Edit/save | plain editor, ACE/AJAX save, empty content, large content, failed write, backup | before/after hash and length, CSRF result, absence of partial data | +| Upload | single file, overwrite/collision, zero/large file, disallowed extension, nested folder upload, chunked upload and interrupted chunk cleanup | request/chunk log, final hash/FID, `.part` cleanup, destination confinement | +| URL upload | direct HTTP(S), redirects, rejected loopback/port, configured restricted proxy, failed transfer and temp cleanup | application and proxy logs, resolved destination, final hash, no internal-network reachability | +| View/download | text, binary, zero/large file, byte-range request, image/media preview, missing and denied file | status/headers, byte-for-byte hash, token behavior, session behavior | +| Direct link | regular file and directory under each identity | web-server authorization result; record that PHP cannot confine or authorize a direct link | +| Copy/duplicate | file/tree, existing target, same-directory duplicate, copy into a direct and deep descendant, large/partial-write case, quota/writeback failure, symlink cases | source/destination hashes/types/targets, error atomicity, partial cleanup, confinement | +| Move/rename | file/tree, same volume, cross volume, existing target, symlink, denied destination | source/destination state, expected cross-volume error or documented fallback, no loss | +| Delete | file, empty/non-empty tree, batch selection, symlink, broken link, mount point | exact removed objects, sentinel preservation, no traversal into target/mounted volume | +| Archive create | zip/tar one and many files, nested tree, symlink, child volume, denied member | member list, hashes, omissions/errors, no unexpected traversal | +| Archive extract | zip/tar normal, overwrite, `../`, absolute path, symlink entry, extraction through symlink or mount point | destination manifest and proof that both escape sentinels remain unchanged | + +Also exercise every single-item and batch route separately; they do not necessarily share implementation. A route that passes only because the kernel denied it is not equivalent to application-level confinement. Record both layers. + +## Symlink confinement matrix + +Create each link as both a file-facing and directory-facing case where possible: + +- relative link to an object inside `AFS_TEST_ROOT`; +- absolute link to an object inside `AFS_TEST_ROOT`; +- link to `AFS_ESCAPE_ROOT` on the same AFS device/cell; +- link to the cross-volume AFS root; +- link to `LOCAL_ESCAPE_ROOT` on the local filesystem; +- broken link; +- two-link chain and a loop. + +For every link, test list, navigate, view, edit/save, backup, upload through a linked directory, download, direct link, copy, duplicate, move/rename, single delete, batch delete, archive create, and archive extraction. The required confinement result is: + +- an operation may act on the link itself where that is the documented intent; +- no operation may follow a link outside `FM_ROOT_PATH` for read or write; +- recursive operations must detect loops and must not traverse an outside target; +- deletion of a link must not delete its target; +- direct-link exposure must be blocked by web-server configuration or documented as unsupported, because the PHP AFS wrapper cannot mediate it. + +Any outside-root read or write is a release blocker. Preserve the fixture and logs for diagnosis; do not continue destructive cases. + +## Volume mount-point and cross-volume matrix + +Use only disposable volumes. Record each mount point with the client tools, its target volume, read-write/read-only status, server, and FID before testing. + +1. Navigate and list a child-volume mount point inside the test root. +2. Compare ACL display and effective caller access on the parent, mount point, child-volume root, and descendants. +3. Copy files and trees into and out of the child volume and compare hashes, ACL effects, and mount-point preservation. +4. Attempt rename/move across the volume boundary. Require either a clear non-destructive failure or an explicitly implemented copy-and-delete fallback with complete verification. +5. Attempt recursive copy, delete, and archive creation at the mount-point object. Confirm whether the operation treats it as a boundary or traverses it; traversal is permitted only when explicitly intended and the entire target volume is disposable. +6. Exercise a read-only mount/volume. Writes must fail clearly and leave no partial files or stale upload chunks. +7. Test a symlink to a child-volume path and a symlink to an AFS path outside the configured root. +8. Remove or make the mount temporarily unavailable and confirm fail-closed behavior without PHP warnings, hangs, or fallback to a local `/afs` directory. + +Never use a production volume merely to test a read-only case. A supposedly read-only path can still expose sensitive data through view, download, direct-link, copy, or archive operations. + +## Rollback and teardown + +Rollback must be prepared before the first mutation and executed even after a failed test. + +1. Stop the disposable web service so no request races with restoration. +2. Preserve final logs, HTTP transcripts, screenshots where useful, raw ACL/access output, and final filesystem manifests. +3. Compare both escape-root sentinels and their ACLs with baseline. Escalate any difference before cleanup. +4. Restore the application configuration from its timestamped backup and verify its checksum. +5. Restore the fixture from the disposable volume snapshot/clone, or restore files and ACLs from the verified baseline. Re-run hashes, type/symlink inventories, mount-point inventory, and `fs listacl` comparisons. +6. If destroying the disposable roots or volumes, verify the run-ID marker, canonical path, cell, volume, and FID immediately before the exact deletion. Use the cell's recoverable volume-destruction procedure where available. +7. Destroy test tokens, exit PAGs, terminate credential-bearing workers, and verify that the old token is no longer accepted. +8. Remove the disposable container/VM and restricted proxy only after evidence is copied to its retained location. +9. Record what was removed, whether a recoverable volume snapshot remains, and the final restore verification result. + +## Exit criteria + +An AFS/AuriStor compatibility claim requires all of the following: + +- no PHP lint, static-test, upstream-check, warning, or parser failures; +- proven web-worker identity and token/PAG isolation for each authorization role; +- exact normal and negative ACL round trips for standard `lrwidka` and AuriStor `A-H`, preserved inherited ACLs, correct `k` handling, CSRF rejection, and enforcement evidence; +- every claimed I/O route tested in both allowed and denied cases with no unexplained partial state; +- no outside-root read or mutation through symlinks, archives, mount points, direct links, or cross-volume operations; +- documented, acceptable behavior for file ACL requests, read-only volumes, token expiry, unavailable mounts, and cross-volume moves; +- complete evidence and a verified rollback/teardown. + +If a generic endpoint remains intentionally unwired, report it as unsupported rather than converting its static expected failure into a compatibility pass. diff --git a/tests/afs_io_path_audit.php b/tests/afs_io_path_audit.php new file mode 100644 index 00000000..0b78da60 --- /dev/null +++ b/tests/afs_io_path_audit.php @@ -0,0 +1,380 @@ + $start, $label . ' end marker is missing or reordered')) { + return ''; + } + + return substr($source, $start, $end - $start); +} + +function afs_audit_unwired($source) +{ + $guardMarkers = array( + '$afsSupport', + 'new Afs', + 'makePathAFSlocal', + 'pathSecurity', + '->copy(', + '->copy_dirs(', + '->readfile(', + '->removeFolder(', + '->deleteFiles(', + '->moveFiles(', + '->afsRename(' + ); + + foreach ($guardMarkers as $marker) { + if (strpos($source, $marker) !== false) { + return false; + } + } + + return true; +} + +function afs_audit_matching_lines($source, $needle) +{ + $matches = array(); + foreach (preg_split('/\r?\n/', $source) as $line) { + if (strpos($line, $needle) !== false) { + $matches[] = $line; + } + } + + return implode("\n", $matches); +} + +function afs_audit_protected($name, $condition, $detail) +{ + global $auditProtected, $auditChecks, $auditFailures; + $auditChecks++; + + if (!$condition) { + $auditFailures[] = $name . ' no longer matches its protected baseline'; + afs_audit_result('FAIL', $name, 'protected baseline changed; review required'); + return; + } + + $auditProtected++; + afs_audit_result('PROTECTED', $name, $detail); +} + +function afs_audit_fail_closed($name, $condition, $detail) +{ + global $auditFailClosed, $auditChecks, $auditFailures; + $auditChecks++; + + if (!$condition) { + $auditFailures[] = $name . ' no longer matches its fail-closed baseline'; + afs_audit_result('FAIL', $name, 'fail-closed baseline changed; review required'); + return; + } + + $auditFailClosed++; + afs_audit_result('FAIL-CLOSED', $name, $detail); +} + +function afs_audit_xfail($name, $condition, $detail) +{ + global $auditExpectedFailures, $auditChecks, $auditFailures; + $auditChecks++; + + if (!$condition) { + $auditFailures[] = $name . ' changed from its expected-gap baseline'; + afs_audit_result('FAIL', $name, 'expected-gap baseline changed; inspect and reclassify'); + return; + } + + $auditExpectedFailures++; + afs_audit_result('XFAIL', $name, $detail); +} + +echo "AFS I/O path audit\n"; + +// Guarded primitives retained in afs.php. These checks do not imply that the +// Tiny File Manager request handlers call them. +$pathSecurity = afs_audit_section($afs, 'function pathSecurity', 'public function makePathAFSlocal', 'Afs::pathSecurity'); +$makeLocal = afs_audit_section($afs, 'public function makePathAFSlocal', '// Checks to see if there is a folder', 'Afs::makePathAFSlocal'); +$removeFolder = afs_audit_section($afs, 'public function removeFolder', 'public function deleteFiles', 'Afs::removeFolder'); +$copyDirs = afs_audit_section($afs, 'public function copy_dirs', 'public function copy(', 'Afs::copy_dirs'); +$copyPrimitive = afs_audit_section($afs, 'public function copy(', '// A AFS safe version of the PHP readfile', 'Afs::copy'); +$readPrimitive = afs_audit_section($afs, 'function readfile()', '// Change the ACL for a given path', 'Afs::readfile'); + +afs_audit_fail_closed( + 'path acceptance on a non-AFS device', + strpos($pathSecurity, '@stat( $path )') !== false + && preg_match('/\$this->afsStat\[[\'\"]dev[\'\"]\]\s*!=\s*\$pathStat\[[\'\"]dev[\'\"]\]/', $pathSecurity) === 1 + && strpos($pathSecurity, 'return false;') !== false, + 'pathSecurity rejects a path whose st_dev differs from /afs' +); +afs_audit_fail_closed( + 'directory-local operation on a non-AFS device', + strpos($makeLocal, '@chdir( $path )') !== false + && preg_match('/\$this->afsStat\[[\'\"]dev[\'\"]\]\s*!=\s*\$stat\[[\'\"]dev[\'\"]\]/', $makeLocal) === 1 + && strpos($makeLocal, 'Path not in AFS') !== false + && strpos($makeLocal, '@chdir( $this->startCWD )') !== false + && strpos($makeLocal, 'return false;') !== false, + 'makePathAFSlocal restores cwd and rejects the device mismatch' +); +afs_audit_protected( + 'AFS file-copy primitive', + strpos($copyPrimitive, 'fstat( $sourceHdl )') !== false + && strpos($copyPrimitive, '$sourceStat[\'dev\'] != $this->afsStat[\'dev\']') !== false + && substr_count($copyPrimitive, 'makePathAFSlocal(') >= 2 + && strpos($copyPrimitive, 'fopen( basename( $dest ), "xb" )') !== false, + 'source handle st_dev and destination directory are checked; overwrite is refused' +); +afs_audit_protected( + 'AFS file-read primitive', + strpos($readPrimitive, 'fstat( $handle )') !== false + && strpos($readPrimitive, '$stat[\'dev\'] == $this->afsStat[\'dev\']') !== false, + 'bytes are emitted only after checking the opened handle device' +); +afs_audit_protected( + 'AFS recursive delete helper', + strpos($removeFolder, 'makePathAFSlocal( $folderPath )') !== false + && strpos($removeFolder, '!is_link( $itemPath )') !== false + && strpos($removeFolder, '$this->removeFolder( $itemPath )') !== false, + 'each directory is rechecked and symlinks are unlinked rather than traversed' +); +afs_audit_protected( + 'AFS recursive copy helper', + substr_count($copyDirs, 'makePathAFSlocal(') >= 2 + && strpos($copyPrimitive, 'if ( is_link( $source ))') !== false + && strpos($copyPrimitive, 'readlink( $name )') !== false, + 'source and destination directories are rechecked and links are reproduced' +); + +// Snapshot the request handlers and generic filesystem helpers. +$save = afs_audit_section($manager, '// save editor file', '// backup files', 'save route'); +$backup = afs_audit_section($manager, '// backup files', '// Save Config', 'backup route'); +$urlUpload = afs_audit_section($manager, '//upload using url', " exit();\n}", 'URL-upload route'); +$deleteRoute = afs_audit_section($manager, '// Delete file / folder', '// Create a new file/folder', 'single-delete route'); +$createRoute = afs_audit_section($manager, '// Create a new file/folder', '// Copy folder / file', 'create route'); +$copyRoute = afs_audit_section($manager, '// Copy folder / file', '// Mass copy files/ folders', 'single-copy route'); +$massCopyRoute = afs_audit_section($manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route'); +$renameRoute = afs_audit_section($manager, '// Rename', '// Download', 'rename route'); +$downloadRoute = afs_audit_section($manager, '// Download', '// Upload', 'download route'); +$uploadRoute = afs_audit_section($manager, '// Upload', '// Mass deleting', 'upload route'); +$massDeleteRoute = afs_audit_section($manager, '// Mass deleting', '// Pack files zip, tar', 'mass-delete route'); +$archiveCreateRoute = afs_audit_section($manager, '// Pack files zip, tar', '// Unpack zip, tar', 'archive-create route'); +$archiveExtractRoute = afs_audit_section($manager, '// Unpack zip, tar', '// Change POSIX permissions', 'archive-extract route'); +$viewer = afs_audit_section($manager, '// file viewer', '// file editor', 'file-view route'); +$listing = afs_audit_section($manager, '// --- TINYFILEMANAGER MAIN ---', '// --- END HTML ---', 'main listing'); +$directLinks = afs_audit_matching_lines($listing, "lng('DirectLink')"); + +$deleteHelper = afs_audit_section($manager, 'function fm_rdelete($path)', 'function fm_rchmod', 'fm_rdelete'); +$renameHelper = afs_audit_section($manager, 'function fm_rename($old, $new)', 'function fm_rcopy', 'fm_rename'); +$recursiveCopy = afs_audit_section($manager, 'function fm_rcopy($path, $dest', 'function fm_mkdir', 'fm_rcopy'); +$mkdirHelper = afs_audit_section($manager, 'function fm_mkdir($dir, $force)', 'function fm_copy', 'fm_mkdir'); +$copyHelper = afs_audit_section($manager, 'function fm_copy($f1, $f2, $upd)', 'function fm_get_mime_type', 'fm_copy'); +$downloadHelper = afs_audit_section($manager, 'function fm_download_file(', 'class FM_Zipper', 'fm_download_file'); +$archiveHelpers = afs_audit_section($manager, 'class FM_Zipper', '//--- Templates Functions ---', 'archive helper classes'); + +afs_audit_xfail( + 'save/edit writes', + afs_audit_unwired($save) + && strpos($save, 'fopen($file_path, "w")') !== false + && strpos($save, '@fwrite($fd, $writedata)') !== false, + 'AJAX save writes the resolved path directly without an AFS handle/device guard' +); +afs_audit_xfail( + 'backup writes', + afs_audit_unwired($backup) + && strpos($backup, 'copy($fullyQualifiedFileName, $fullPath . $newFileName)') !== false, + 'backup uses PHP copy directly and can follow a same-root symlink outside AFS' +); +afs_audit_xfail( + 'file and directory creation', + afs_audit_unwired($createRoute) + && strpos($createRoute, "@fopen(\$path . '/' . \$new, 'w')") !== false + && strpos($createRoute, "fm_mkdir(\$path . '/' . \$new, false)") !== false + && afs_audit_unwired($mkdirHelper) + && strpos($mkdirHelper, 'mkdir($dir, 0777, true)') !== false, + 'create routes use fopen/mkdir without checking the target directory device' +); +afs_audit_xfail( + 'copy', + afs_audit_unwired($copyRoute) + && strpos($copyRoute, 'fm_rcopy($from, $dest)') !== false + && afs_audit_unwired($recursiveCopy) + && strpos($recursiveCopy, 'return fm_copy($path, $dest, $upd)') !== false + && afs_audit_unwired($copyHelper) + && strpos($copyHelper, 'copy($f1, $f2)') !== false, + 'copy dispatches through generic recursive PHP copy, not Afs::copy/copy_dirs' +); +afs_audit_xfail( + 'duplicate', + afs_audit_unwired($copyRoute) + && strpos($copyRoute, 'fm_rcopy($from, $fn_duplicate, False)') !== false, + 'same-directory duplicate uses the same unguarded fm_rcopy path' +); +afs_audit_xfail( + 'move', + afs_audit_unwired($copyRoute . $massCopyRoute) + && strpos($copyRoute, 'fm_rename($from, $dest)') !== false + && strpos($massCopyRoute, 'fm_rename($from, $dest)') !== false + && afs_audit_unwired($renameHelper) + && strpos($renameHelper, 'rename($old, $new)') !== false, + 'single and mass move use generic rename without source/destination device checks' +); +afs_audit_xfail( + 'rename', + afs_audit_unwired($renameRoute) + && strpos($renameRoute, "fm_rename(\$path . '/' . \$old, \$path . '/' . \$new)") !== false + && afs_audit_unwired($renameHelper), + 'rename is not routed through the AFS/filedrawers-safe primitive' +); +afs_audit_xfail( + 'single and bulk delete', + afs_audit_unwired($deleteRoute . $massDeleteRoute) + && strpos($deleteRoute, "fm_rdelete(\$path . '/' . \$del)") !== false + && strpos($massDeleteRoute, 'fm_rdelete($new_path)') !== false + && afs_audit_unwired($deleteHelper) + && strpos($deleteHelper, 'unlink($path)') !== false + && strpos($deleteHelper, 'rmdir($path)') !== false, + 'delete uses generic unlink/rmdir recursion rather than Afs::removeFolder/deleteFiles' +); +afs_audit_xfail( + 'single-part upload', + afs_audit_unwired($uploadRoute) + && strpos($uploadRoute, 'move_uploaded_file($tmp_name, $fullPath)') !== false, + 'the final uploaded-file destination is not device-checked' +); +afs_audit_xfail( + 'chunked upload', + afs_audit_unwired($uploadRoute) + && strpos($uploadRoute, '"{$fullPath}.part"') !== false + && strpos($uploadRoute, 'fopen("{$fullPath}.part"') !== false + && strpos($uploadRoute, 'rename("{$fullPath}.part", $fullPathTarget)') !== false, + 'chunk append and final rename operate directly on the requested path' +); +afs_audit_xfail( + 'URL upload', + afs_audit_unwired($urlUpload) + && strpos($urlUpload, 'copy($url, $temp_file, $ctx)') !== false + && strpos($urlUpload, "rename(\$temp_file, strtok(get_file_path(), '?'))") !== false, + 'SSRF checks are retained, but the final filesystem rename is not AFS-confined' +); +afs_audit_xfail( + 'download/read', + afs_audit_unwired($downloadRoute) + && strpos($downloadRoute, 'fm_download_file(') !== false + && afs_audit_unwired($downloadHelper) + && strpos($downloadHelper, 'realpath($fileLocation)') !== false + && strpos($downloadHelper, 'readfile($fileLocation)') !== false, + 'download follows realpath and calls PHP readfile instead of Afs::readfile' +); +afs_audit_xfail( + 'view/preview reads', + afs_audit_unwired($viewer) + && strpos($viewer, 'file_get_contents($file_path)') !== false + && strpos($viewer, 'is_file($path . \'/\' . $file)') !== false, + 'viewer follows file symlinks and reads without validating the opened handle device' +); +afs_audit_xfail( + 'direct links', + afs_audit_unwired($directLinks) + && substr_count($directLinks, "lng('DirectLink')") === 2 + && substr_count($directLinks, 'FM_ROOT_URL') === 2, + 'links hand paths to the web server, bypassing PHP and every Afs guard' +); +afs_audit_xfail( + 'archive creation', + afs_audit_unwired($archiveCreateRoute . $archiveHelpers) + && strpos($archiveCreateRoute, 'new FM_Zipper()') !== false + && strpos($archiveCreateRoute, 'new FM_Zipper_Tar()') !== false + && strpos($archiveHelpers, 'addFile($filename)') !== false + && strpos($archiveHelpers, 'scandir($path)') !== false, + 'ZIP/TAR creation recursively reads paths without AFS device or symlink boundaries' +); +afs_audit_xfail( + 'archive extraction', + afs_audit_unwired($archiveExtractRoute . $archiveHelpers) + && strpos($archiveExtractRoute, 'extractTo($path, null, true)') !== false + && substr_count($archiveHelpers, 'extractTo($path)') >= 2, + 'ZIP/TAR extraction writes directly to a path without per-entry AFS confinement' +); +afs_audit_xfail( + 'symlink traversal', + afs_audit_unwired($deleteHelper . $recursiveCopy . $viewer . $directLinks) + && strpos($deleteHelper, 'if (is_link($path))') !== false + && strpos($deleteHelper, 'return unlink($path)') !== false + && strpos($recursiveCopy, 'if (is_dir($path))') !== false + && strpos($recursiveCopy, 'is_link(') === false + && strpos($viewer, 'file_get_contents($file_path)') !== false, + 'delete unlinks a link, but copy/view/direct-link paths can follow it outside AFS' +); +afs_audit_xfail( + 'mount-point traversal', + afs_audit_unwired($deleteHelper . $recursiveCopy . $archiveHelpers) + && strpos($deleteHelper, 'scandir($path)') !== false + && strpos($recursiveCopy, 'scandir($path)') !== false + && strpos($archiveHelpers, 'scandir($path)') !== false + && strpos($deleteHelper . $recursiveCopy . $archiveHelpers, 'lstat(') === false + && strpos($deleteHelper . $recursiveCopy . $archiveHelpers, "['dev']") === false, + 'generic recursion treats mount points as directories and never checks st_dev' +); + +echo 'SUMMARY: ' . $auditProtected . ' protected, ' + . $auditFailClosed . ' fail-closed, ' + . $auditExpectedFailures . ' expected failures, ' + . count($auditFailures) . ' unexpected failures across ' + . $auditChecks . " checks\n"; + +if (!empty($auditFailures)) { + exit(1); +} + +exit(0); diff --git a/tests/afs_regression.php b/tests/afs_regression.php new file mode 100644 index 00000000..4cef261f --- /dev/null +++ b/tests/afs_regression.php @@ -0,0 +1,241 @@ +commands[] = $arguments; + $this->lastFsStatus = 0; + return empty($this->responses) ? '' : array_shift($this->responses); + } +} + +final class AfsFilesystemDouble extends Afs +{ + public function __construct() + { + // configureFilesystem() supplies an offline device model. + } + + public function configureFilesystem($device, $startCwd) + { + $this->afsAvailable = true; + $this->afsStat = array('dev' => $device); + $this->startCWD = $startCwd; + } + + public function setTestPath($path) + { + $this->path = $path; + } +} + +$tests = 0; + +function check($condition, $message) +{ + global $tests; + $tests++; + if (!$condition) { + fwrite(STDERR, "not ok $tests - $message\n"); + exit(1); + } + echo "ok $tests - $message\n"; +} + +function remove_test_tree($path) +{ + if (is_link($path) || is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (scandir($path) as $entry) { + if ($entry !== '.' && $entry !== '..') { + remove_test_tree($path . '/' . $entry); + } + } + @rmdir($path); +} + +$aclOutput = "Access list for /afs/example is\n" + . "Normal rights:\n" + . " system:anyuser rl\n" + . " alice rlidwkaABCDEFGH\n" + . " auxiliary AD\n" + . "Negative rights:\n" + . " blocked lkAH\n"; + +$afs = new AfsTestDouble(); +$acl = $afs->parseAclOutput($aclOutput); +check($acl['inherited'] === false, 'marks a normal ACL as explicit'); +check(isset($acl['normal']['alice']), 'parses a normal ACL principal'); +check($acl['normal']['alice']['l'] && $acl['normal']['alice']['r'] + && $acl['normal']['alice']['w'] && $acl['normal']['alice']['i'] + && $acl['normal']['alice']['d'] && $acl['normal']['alice']['k'] + && $acl['normal']['alice']['a'] && $acl['normal']['alice']['A'] + && $acl['normal']['alice']['B'] && $acl['normal']['alice']['C'] + && $acl['normal']['alice']['D'] && $acl['normal']['alice']['E'] + && $acl['normal']['alice']['F'] && $acl['normal']['alice']['G'] + && $acl['normal']['alice']['H'], + 'parses seven standard and eight AuriStor auxiliary rights'); +check($acl['normal']['system:anyuser']['l'] + && $acl['normal']['system:anyuser']['r'] + && !$acl['normal']['system:anyuser']['w'], 'tracks unset normal rights'); +check($acl['negative']['blocked']['l'] && $acl['negative']['blocked']['k'] + && $acl['negative']['blocked']['A'] && $acl['negative']['blocked']['H'] + && !$acl['negative']['blocked']['a'], 'parses negative ACL rights'); +check($acl['normal']['auxiliary']['A'] && $acl['normal']['auxiliary']['D'] + && $acl['normal']['auxiliary']['a'] === false + && $acl['normal']['auxiliary']['d'] === false, + 'auxiliary rights remain case-distinct from admin and delete'); + +$inheritedOutput = str_replace('Access list for', 'Access list (inherited) for', $aclOutput); +$inheritedAcl = $afs->parseAclOutput($inheritedOutput); +check($inheritedAcl['inherited'] === true, + 'detects an inherited AuriStor ACL without materializing it'); +check($afs->parseAclOutput("unexpected output\n") === false, + 'rejects listacl output without the expected headers'); +$unknownRightOutput = str_replace('system:anyuser rl', 'system:anyuser rlZ', $aclOutput); +check($afs->parseAclOutput($unknownRightOutput) === false, + 'rejects an unknown ACL right instead of dropping it'); + +$afs->responses[] = $aclOutput; +$readAcl = $afs->readAcl('/afs/example'); +check($readAcl === $acl, 'readAcl returns the parsed ACL structure'); +check($afs->commands[0] === array('listacl', '/afs/example'), + 'readAcl invokes fs listacl with an argument vector'); + +$afs->responses[] = "fs: permission denied\n"; +check($afs->readAcl('/afs/example') === false, + 'readAcl rejects fs-reported failures'); + +$afs->responses[] = "Callers access to /afs/example is rlidwkaABCDEFGH\n"; +check($afs->getACLAccess('/afs/example') === 'rlidwkaABCDEFGH', + 'getcalleraccess preserves standard and auxiliary right case'); +check($afs->lookupPriv === 1 && $afs->readPriv === 1 + && $afs->writePriv === 1 && $afs->insertPriv === 1 + && $afs->deletePriv === 1 && $afs->lockPriv === 1 + && $afs->adminPriv === 1, 'getcalleraccess maps all privilege flags'); + +$afs->responses[] = "unexpected output\n"; +check($afs->getACLAccess('/afs/example') === '', + 'malformed getcalleraccess output fails closed'); +check($afs->lookupPriv === 0 && $afs->readPriv === 0 + && $afs->writePriv === 0 && $afs->insertPriv === 0 + && $afs->deletePriv === 0 && $afs->lockPriv === 0 + && $afs->adminPriv === 0, 'failed parsing clears stale privilege flags'); + +$afs->responses[] = ''; +check($afs->changeAcl('user;touch /tmp/not-run', 'rl', '/afs/a path') === true, + 'normal ACL changes accept shell-sensitive names as data'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '/afs/a path', 'user;touch /tmp/not-run', 'rl'), + 'normal ACL command remains a structured argument vector'); + +$afs->responses[] = ''; +check($afs->changeAcl('blocked', 'lkAH', '/afs/example', false, true) === true, + 'negative ACL changes are supported'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '-negative', '/afs/example', 'blocked', 'lkAH'), + 'negative ACL command uses the explicit -negative argument'); +check($afs->changeAcl('alice', 'rl;bad', '/afs/example') === false, + 'invalid ACL rights fail before invoking fs'); + +$afs->responses[] = ''; +check($afs->changeAclEntries( + array('alice' => 'rl', 'system:anyuser' => 'l'), '/afs/example') === true, + 'multiple ACL entries are changed in one batch'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '/afs/example', 'alice', 'rl', 'system:anyuser', 'l'), + 'batched ACL entries share one fs setacl invocation'); + +$tempRoot = sys_get_temp_dir() . '/tinyfm-afs-test-' . bin2hex(random_bytes(8)); +check(mkdir($tempRoot, 0700), 'creates an isolated test directory'); +$originalCwd = getcwd(); + +try { + $inside = $tempRoot . '/inside'; + check(mkdir($inside, 0700), 'creates an in-device path'); + $device = stat($inside)['dev']; + $fsAfs = new AfsFilesystemDouble(); + $fsAfs->configureFilesystem($device, $originalCwd); + + check($fsAfs->makePathAFSlocal($inside) === true, + 'same-device directory passes the final chdir/stat guard'); + chdir($originalCwd); + check($fsAfs->setPath($inside) === true && $fsAfs->path === $inside, + 'setPath accepts a same-device path'); + + $fsAfs->configureFilesystem($device + 1, $originalCwd); + check($fsAfs->makePathAFSlocal($inside) === false, + 'different-device directory fails the final guard'); + check(getcwd() === $originalCwd, 'failed device guard restores the cwd'); + check($fsAfs->setPath($inside) === false && $fsAfs->path === '', + 'setPath rejects a different-device path'); + + $source = $tempRoot . '/source.bin'; + $destination = $tempRoot . '/destination.bin'; + $payload = "\x00AFS\n" . random_bytes(2048); + file_put_contents($source, $payload); + $fsAfs->configureFilesystem($device, $originalCwd); + check($fsAfs->copy($source, $destination) === true, + 'handle-checked copy succeeds on the modeled AFS device'); + check(file_get_contents($destination) === $payload, + 'handle-checked copy preserves binary content'); + check($fsAfs->copy($source, $destination) === false, + 'handle-checked copy refuses to overwrite an existing destination'); + + $outsideDestination = $tempRoot . '/wrong-device.bin'; + $fsAfs->configureFilesystem($device + 1, $originalCwd); + check($fsAfs->copy($source, $outsideDestination) === false + && !file_exists($outsideDestination), + 'source handle device mismatch creates no destination'); + + $fsAfs->configureFilesystem($device, $originalCwd); + $fsAfs->setTestPath($source); + ob_start(); + $readResult = $fsAfs->readfile(); + $readPayload = ob_get_clean(); + check($readResult === true && $readPayload === $payload, + 'handle-checked read emits exact binary content'); + + if (function_exists('symlink')) { + $broken = $tempRoot . '/broken-link'; + @symlink('missing-target', $broken); + check($fsAfs->linkSafeFileExists($broken) === true, + 'lstat recognizes a broken symlink without following it'); + } + + check($fsAfs->escape_js("a'b\\c\r\n") === "a\\'b\\\\c\\r\\n", + 'JavaScript escaping covers quote, slash, CR, and LF'); + + $requiredMethods = array('copy', 'copy_dirs', 'deleteFiles', 'removeFolder', + 'readfile', 'changeAcl', 'readAcl', 'getACLAccess', 'makePathAFSlocal'); + $reflection = new ReflectionClass('Afs'); + foreach ($requiredMethods as $method) { + check($reflection->hasMethod($method), "retains Afs::$method"); + } +} finally { + @chdir($originalCwd); + remove_test_tree($tempRoot); +} + +echo "1..$tests\n"; diff --git a/tests/afs_static.php b/tests/afs_static.php new file mode 100644 index 00000000..2eb283d2 --- /dev/null +++ b/tests/afs_static.php @@ -0,0 +1,239 @@ + $start, $label . ' end marker follows its start marker'); + + if ($start === false || $end === false || $end <= $start) { + return ''; + } + + return substr($source, $start, $end - $start); +} + +function afs_test_contains($haystack, $needle, $message) +{ + afs_test_ok(strpos($haystack, $needle) !== false, $message); +} + +echo "AFS static integration contract\n"; + +// AFS must be an explicit config.php opt-in, and the include must be resolved +// before the optional dependency is loaded. +$defaultPos = strpos($manager, '$afsSupport = false;'); +$configPos = strpos($manager, '@include($config_file);'); +$guardPos = strpos($manager, 'if ($afsSupport) {'); +$requirePos = strpos($manager, "require_once __DIR__ . '/afs.php';"); + +afs_test_ok($defaultPos !== false, 'AFS support defaults to disabled'); +afs_test_ok($configPos !== false, 'external config.php is included'); +afs_test_ok($guardPos !== false, 'AFS dependency load is conditional'); +afs_test_ok($requirePos !== false, 'AFS dependency uses an __DIR__-anchored path'); +afs_test_ok( + $defaultPos !== false && $configPos !== false && $guardPos !== false && $requirePos !== false + && $defaultPos < $configPos && $configPos < $guardPos && $guardPos < $requirePos, + 'config.php can override the default before afs.php is required' +); + +// Preserve the upstream request-token checks around every route that already +// had one. The legacy single-item GET copy route is intentionally audited in +// afs_io_path_audit.php instead of being misrepresented as CSRF-protected. +$ajax = afs_test_section($manager, '// Handle all AJAX Request', '// Delete file / folder', 'AJAX dispatcher'); +afs_test_contains($ajax, "isset(\$_POST['ajax'], \$_POST['token'])", 'AJAX dispatcher requires a token field'); +$ajaxVerify = strpos($ajax, "verifyToken(\$_POST['token'])"); +$ajaxSearch = strpos($ajax, '//search'); +afs_test_ok( + $ajaxVerify !== false && $ajaxSearch !== false && $ajaxVerify < $ajaxSearch, + 'AJAX token is verified before request-specific actions' +); + +$csrfSections = array( + 'single delete' => afs_test_section($manager, '// Delete file / folder', '// Create a new file/folder', 'single-delete route'), + 'create' => afs_test_section($manager, '// Create a new file/folder', '// Copy folder / file', 'create route'), + 'mass copy/move' => afs_test_section($manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route'), + 'rename' => afs_test_section($manager, '// Rename', '// Download', 'rename route'), + 'download' => afs_test_section($manager, '// Download', '// Upload', 'download route'), + 'upload' => afs_test_section($manager, '// Upload', '// Mass deleting', 'upload route'), + 'mass delete' => afs_test_section($manager, '// Mass deleting', '// Pack files zip, tar', 'mass-delete route'), + 'archive create' => afs_test_section($manager, '// Pack files zip, tar', '// Unpack zip, tar', 'archive-create route'), + 'archive extract' => afs_test_section($manager, '// Unpack zip, tar', '// Change POSIX permissions', 'archive-extract route'), + 'POSIX chmod' => afs_test_section($manager, '// Change POSIX permissions', '// Change AFS ACLs', 'POSIX-chmod route'), + 'AFS ACL write' => afs_test_section($manager, '// Change AFS ACLs', '/*************************** ACTIONS', 'AFS-ACL-write route') +); + +foreach ($csrfSections as $label => $section) { + afs_test_contains($section, "verifyToken(\$_POST['token'])", $label . ' preserves token verification'); +} + +$verifyFunction = afs_test_section($manager, 'function verifyToken($token)', 'function fm_rdelete($path)', 'verifyToken function'); +afs_test_contains($verifyFunction, 'hash_equals(', 'token comparison remains timing-safe'); + +// Preserve the current URL-upload boundary checks and the fork's proxy path. +$urlUpload = afs_test_section($manager, '//upload using url', " exit();\n}", 'URL-upload route'); +afs_test_contains($urlUpload, 'preg_match("|^http(s)?://.+$|"', 'URL upload accepts only HTTP(S)-shaped URLs'); +afs_test_contains($urlUpload, 'parse_url($url, PHP_URL_HOST)', 'URL upload parses the destination host'); +afs_test_contains($urlUpload, 'parse_url($url, PHP_URL_PORT)', 'URL upload parses the destination port'); +afs_test_contains($urlUpload, '^localhost$', 'URL upload rejects localhost'); +afs_test_contains($urlUpload, '^127', 'URL upload rejects IPv4 loopback'); +afs_test_contains($urlUpload, '0*1$', 'URL upload rejects IPv6 loopback'); +afs_test_contains($urlUpload, '$knownPorts = [22, 23, 25, 3306];', 'URL upload preserves the blocked-port baseline'); +afs_test_contains($urlUpload, 'basename($fileinfo->name)', 'URL-upload destination is reduced to a basename'); +afs_test_contains($urlUpload, "strtok(get_file_path(), '?')", 'URL-upload destination strips a query suffix'); +afs_test_contains($urlUpload, "'proxy' => 'tcp://' . \$proxyServer", 'non-cURL URL upload preserves configured proxy support'); +afs_test_contains($urlUpload, "'request_fulluri' => true", 'proxy requests retain absolute request URIs'); + +// Preserve exclusion behavior added upstream: configured exact names, wildcard +// extensions, and full paths all remain excluded from listing/view/edit. +afs_test_contains( + $manager, + "version_compare(PHP_VERSION, '7.0.0', '<') ? serialize(\$exclude_items) : \$exclude_items", + 'FM_EXCLUDE_ITEMS preserves the PHP 5 serialization compatibility path' +); +$excludeFunction = afs_test_section($manager, 'function fm_is_exclude_items($name, $path)', 'function fm_get_translations($tr)', 'exclusion helper'); +afs_test_contains($excludeFunction, 'in_array($name, $exclude_items)', 'exclusion helper checks exact names'); +afs_test_contains($excludeFunction, 'in_array("*.$ext", $exclude_items)', 'exclusion helper checks wildcard extensions'); +afs_test_contains($excludeFunction, 'in_array($path, $exclude_items)', 'exclusion helper checks full paths'); +afs_test_ok( + substr_count($manager, 'fm_is_exclude_items(') >= 6, + 'listing, view, and edit paths retain exclusion checks' +); +afs_test_contains($manager, "strpbrk(\$text, '/?%*:|\"<>' . chr(0))", 'current null-byte filename rejection is preserved'); + +// ACL write handling must round-trip both lists. Accept either two explicit +// branches or a normal=>false/negative=>true mode map feeding the batched +// negative-mode argument. +$aclSubmit = $csrfSections['AFS ACL write']; +$readBeforeChange = strpos($aclSubmit, '$afs->readAcl($aclPath)'); +$changeCall = strpos($aclSubmit, '$afs->changeAclEntries('); +afs_test_ok( + $readBeforeChange !== false && $changeCall !== false && $readBeforeChange < $changeCall, + 'ACL writes re-read current inheritance state before fs setacl' +); +afs_test_contains($aclSubmit, "!empty(\$currentAcl['inherited'])", + 'inherited AuriStor ACL writes fail closed server-side'); +$mappedAclSets = preg_match('/[\'\"]normal[\'\"]\s*=>\s*false.*[\'\"]negative[\'\"]\s*=>\s*true/s', $aclSubmit) === 1; +$dynamicAclLookup = strpos($aclSubmit, '$_POST[$setName]') !== false; +afs_test_ok( + strpos($aclSubmit, "\$_POST['normal']") !== false || ($mappedAclSets && $dynamicAclLookup), + 'positive ACL submissions are handled' +); +afs_test_ok( + strpos($aclSubmit, "\$_POST['negative']") !== false || ($mappedAclSets && $dynamicAclLookup), + 'negative ACL submissions are handled' +); +$sentinelIgnored = strpos($aclSubmit, "unset(\$perms['acl'])") !== false + || (strpos($aclSubmit, '$allowedRights') !== false + && strpos($aclSubmit, 'isset($perms[$right])') !== false); +afs_test_ok($sentinelIgnored, 'ACL empty-rights sentinel is excluded from assembled rights'); +$emptyBecomesNone = strpos($aclSubmit, "empty(\$perms) ? 'none'") !== false + || preg_match('/\$newAcl\s*=\s*\$newAcl\s*==={0,1}\s*[\'\"]{2}\s*\?\s*[\'\"]none[\'\"]/', $aclSubmit) === 1; +afs_test_ok($emptyBecomesNone, 'clearing every right is translated to fs none'); + +$directNegativeCall = preg_match('/changeAclEntries\s*\([^;]*,\s*true\s*\)/s', $aclSubmit) === 1; +$modeMap = $mappedAclSets; +$variableNegativeCall = preg_match('/changeAclEntries\s*\([^;]*,\s*\$[A-Za-z_][A-Za-z0-9_]*\s*\)/s', $aclSubmit) === 1; +afs_test_ok( + $directNegativeCall || ($modeMap && $variableNegativeCall), + 'negative ACL writes pass true to changeAcl negative mode' +); +afs_test_contains($aclSubmit, '$aclBatches', 'ACL entries are batched by positive/negative set'); + +$aclUi = afs_test_section($manager, '// Edit AFS ACLs', '// --- TINYFILEMANAGER MAIN ---', 'AFS ACL editor'); +afs_test_contains($aclUi, '$afs->readAcl($file_path)', 'ACL editor reads the current AFS ACL'); +afs_test_contains($aclUi, "!empty(\$mode['inherited'])", 'ACL editor detects inherited AuriStor ACLs'); +afs_test_contains($aclUi, "", + 'unreadable and inherited ACL controls are disabled'); + +$rights = array('l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'); +$aclTypes = array('normal', 'negative'); +foreach ($aclTypes as $aclType) { + foreach ($rights as $right) { + $pattern = '/name="' . preg_quote($aclType, '/') . '\[[^"]+\]\[' . preg_quote($right, '/') . '\]"/'; + afs_test_ok( + preg_match($pattern, $aclUi) === 1, + $aclType . ' ACL editor exposes the ' . $right . ' right' + ); + } +} + +afs_test_ok( + preg_match('/name="normal\[[^"]+\]\[acl\]"/', $aclUi) === 1, + 'positive ACL rows post an empty-rights sentinel' +); +afs_test_ok( + preg_match('/name="negative\[[^"]+\]\[acl\]"/', $aclUi) === 1, + 'negative ACL rows post an empty-rights sentinel' +); + +$lockRows = 0; +foreach (preg_split('/\r?\n/', $aclUi) as $line) { + if (strpos($line, '][k]"') === false) { + continue; + } + $lockRows++; + afs_test_ok( + strpos($line, "\$perms['k']") !== false && strpos($line, "\$perms['l']") === false, + 'lock checkbox state is derived from the k right' + ); +} +afs_test_ok($lockRows === 2, 'both positive and negative ACL tables contain one lock row'); + +// Constructing an Afs object used to shell out once, after which each listing +// row called getcalleraccess again. Keep exactly one subprocess per item. +$constructor = afs_test_section($afs, 'public function __construct', 'public function getType', 'Afs constructor'); +afs_test_ok( + substr_count($constructor, 'getACLAccess(') === 0, + 'Afs construction does not perform an implicit getcalleraccess query' +); + +$folderListing = afs_test_section($manager, '$ii = 3399;', '$ik = 8002;', 'folder-listing loop'); +$fileListing = afs_test_section($manager, '$ik = 8002;', 'if (empty($folders) && empty($files))', 'file-listing loop'); +afs_test_ok(substr_count($folderListing, '->getACLAccess(') === 1, 'each folder row has one explicit getcalleraccess call'); +afs_test_ok(substr_count($fileListing, '->getACLAccess(') === 1, 'each file row has one explicit getcalleraccess call'); +afs_test_ok(substr_count($manager, '->getACLAccess(') === 2, 'Tiny File Manager has only the two per-row getcalleraccess call sites'); + +echo "SUMMARY: " . $afsTestPasses . " passed, " . count($afsTestFailures) . " failed\n"; +if (!empty($afsTestFailures)) { + exit(1); +} + +exit(0); From a3241138bea6f400534bb5a56c0c81944be08001 Mon Sep 17 00:00:00 2001 From: "Karl A. Grindley" Date: Mon, 17 Aug 2026 20:53:26 -0400 Subject: [PATCH 05/15] Harden AFS copy dispatch and MaxACL parsing Dispatch symlinks before directory handling, reject direct copy_dirs symlink and special-file inputs, and fail closed on AuriStor Volume access list blocks instead of merging them into editable object ACLs. --- afs.php | 75 +++++++++---- tests/afs_io_path_audit.php | 11 +- tests/afs_regression.php | 103 +++++++++++++++++- .../auristor-listacl-with-volume-acl.txt | 12 ++ 4 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/auristor-listacl-with-volume-acl.txt diff --git a/afs.php b/afs.php index 4af3fd2e..9156cf2b 100644 --- a/afs.php +++ b/afs.php @@ -417,22 +417,39 @@ function copyFiles() continue; } - // Security checks are in Afs::copy() and Afs::copy_dirs + // Link-safe dispatch and security checks are in copyItem(). $sourcePath = $this->originPath . '/'. $file; $destPath = $this->path . '/' . $file; - if ( filetype( $sourcePath ) == 'dir' ) { - if ( !$this->copy_dirs( $sourcePath, $destPath )) { - $this->errorMsg = "Unable to copy $file."; - return false; - } - } else if ( !$this->copy( $sourcePath, $destPath )) { + if ( !$this->copyItem( $sourcePath, $destPath )) { $this->errorMsg = "Unable to copy $file."; return false; } $this->notifyMsg = "Pasted the contents of the clipboard."; } + + return true; + } + + + // Dispatch links before directory checks so a directory symlink is copied + // as a link and is never traversed by copy_dirs(). + protected function copyItem( $source, $target ) + { + if ( is_link( $source )) { + return $this->copy( $source, $target ); + } + + $type = @filetype( $source ); + if ( $type === 'dir' ) { + return $this->copy_dirs( $source, $target ); + } + if ( $type === 'file' ) { + return $this->copy( $source, $target ); + } + + return false; } @@ -441,6 +458,10 @@ function copyFiles() */ public function copy_dirs( $source, $target ) { + if ( is_link( $source ) || !is_dir( $source )) { + return false; + } + $sourceReal = @realpath( $source ); $targetParentReal = @realpath( dirname( $target )); if ( $sourceReal === false || $targetParentReal === false ) { @@ -488,13 +509,8 @@ public function copy_dirs( $source, $target ) $sourcePath = $source . '/' . $entry; $targetPath = $target . '/' . $entry; - // Security checks are in Afs::copy() and Afs::copy_dirs - if ( filetype( $sourcePath ) == 'dir' ) { - if ( !$this->copy_dirs( $sourcePath, $targetPath )) { - @chdir( $this->startCWD ); - return false; - } - } else if ( !$this->copy( $sourcePath, $targetPath )) { + // Link-safe dispatch and security checks are in copyItem(). + if ( !$this->copyItem( $sourcePath, $targetPath )) { @chdir( $this->startCWD ); return false; } @@ -753,7 +769,13 @@ function readAcl( $path='' ) return false; } - return $this->parseAclOutput( $result ); + $acl = $this->parseAclOutput( $result ); + if ( $acl === false ) { + $this->errorMsg = + 'Warning: Unable to parse the access control list.'; + } + + return $acl; } public function parseAclOutput( $result ) @@ -767,12 +789,12 @@ public function parseAclOutput( $result ) $acl = array( 'normal' => array(), 'negative' => array(), - 'inherited' => preg_match( - '/^Access list \(inherited\) for /mi', $result ) === 1 + 'inherited' => false ); $section = ''; $sawHeader = false; $sawNormal = false; + $sawNegative = false; $lines = preg_split( '/\r?\n/', $result ); foreach ( $lines as $line ) { @@ -780,20 +802,35 @@ public function parseAclOutput( $result ) if ( $line === '' ) { continue; } - if ( preg_match( '/^Access list(?: \(inherited\))? for .+ is$/i', $line )) { + if ( preg_match( '/^Access list( \(inherited\))? for .+ is$/i', + $line, $header )) { + if ( $sawHeader ) { + return false; + } $sawHeader = true; + $acl['inherited'] = !empty( $header[1] ); + $section = ''; continue; } + if ( preg_match( '/^Volume access list for .+ is$/i', $line )) { + // A Volume Maximum ACL is a separate, read-only policy block. + // Never merge it into the editable object ACL. + return false; + } if ( preg_match( '/^Normal rights:$/i', $line )) { + if ( !$sawHeader || $sawNormal ) { + return false; + } $section = 'normal'; $sawNormal = true; continue; } if ( preg_match( '/^Negative rights:$/i', $line )) { - if ( !$sawNormal ) { + if ( !$sawNormal || $sawNegative ) { return false; } $section = 'negative'; + $sawNegative = true; continue; } if ( !$section || !preg_match( '/^(\S+)\s+(\S+)$/', $line, $matches )) { diff --git a/tests/afs_io_path_audit.php b/tests/afs_io_path_audit.php index 0b78da60..6608cbb9 100644 --- a/tests/afs_io_path_audit.php +++ b/tests/afs_io_path_audit.php @@ -147,6 +147,8 @@ function afs_audit_xfail($name, $condition, $detail) $pathSecurity = afs_audit_section($afs, 'function pathSecurity', 'public function makePathAFSlocal', 'Afs::pathSecurity'); $makeLocal = afs_audit_section($afs, 'public function makePathAFSlocal', '// Checks to see if there is a folder', 'Afs::makePathAFSlocal'); $removeFolder = afs_audit_section($afs, 'public function removeFolder', 'public function deleteFiles', 'Afs::removeFolder'); +$copyFilesPrimitive = afs_audit_section($afs, 'function copyFiles()', 'protected function copyItem', 'Afs::copyFiles'); +$copyItem = afs_audit_section($afs, 'protected function copyItem', '/* A helper function for copyFiles()', 'Afs::copyItem'); $copyDirs = afs_audit_section($afs, 'public function copy_dirs', 'public function copy(', 'Afs::copy_dirs'); $copyPrimitive = afs_audit_section($afs, 'public function copy(', '// A AFS safe version of the PHP readfile', 'Afs::copy'); $readPrimitive = afs_audit_section($afs, 'function readfile()', '// Change the ACL for a given path', 'Afs::readfile'); @@ -191,9 +193,16 @@ function afs_audit_xfail($name, $condition, $detail) afs_audit_protected( 'AFS recursive copy helper', substr_count($copyDirs, 'makePathAFSlocal(') >= 2 + && strpos($copyFilesPrimitive, 'copyItem(') !== false + && strpos($copyDirs, 'copyItem(') !== false + && strpos($copyDirs, 'is_link( $source )') !== false + && strpos($copyItem, 'is_link( $source )') !== false + && strpos($copyItem, '@filetype( $source )') !== false + && strpos($copyItem, 'is_link( $source )') + < strpos($copyItem, '@filetype( $source )') && strpos($copyPrimitive, 'if ( is_link( $source ))') !== false && strpos($copyPrimitive, 'readlink( $name )') !== false, - 'source and destination directories are rechecked and links are reproduced' + 'copyFiles and copy_dirs dispatch links before directory checks, then reproduce them' ); // Snapshot the request handlers and generic filesystem helpers. diff --git a/tests/afs_regression.php b/tests/afs_regression.php index 4cef261f..5ea65654 100644 --- a/tests/afs_regression.php +++ b/tests/afs_regression.php @@ -43,6 +43,18 @@ public function setTestPath($path) { $this->path = $path; } + + public function copyItemForTest($source, $target) + { + return $this->copyItem($source, $target); + } + + public function configureCopyRequest($originPath, $destinationPath, $selectedItems) + { + $this->originPath = $originPath; + $this->path = $destinationPath; + $this->selectedItems = $selectedItems; + } } $tests = 0; @@ -60,7 +72,8 @@ function check($condition, $message) function remove_test_tree($path) { - if (is_link($path) || is_file($path)) { + if (is_link($path) || is_file($path) + || (file_exists($path) && !is_dir($path))) { @unlink($path); return; } @@ -117,12 +130,45 @@ function remove_test_tree($path) check($afs->parseAclOutput($unknownRightOutput) === false, 'rejects an unknown ACL right instead of dropping it'); +$maxAclFixture = file_get_contents( + __DIR__ . '/fixtures/auristor-listacl-with-volume-acl.txt'); +check($maxAclFixture !== false, 'loads the AuriStor Volume ACL fixture'); +check($afs->parseAclOutput($maxAclFixture) === false, + 'fails closed instead of merging a Volume ACL into the object ACL'); +$inheritedMaxAclFixture = preg_replace( + '/^Access list for /', 'Access list (inherited) for ', $maxAclFixture); +check($afs->parseAclOutput($inheritedMaxAclFixture) === false, + 'fails closed when an inherited object ACL is followed by a Volume ACL'); +check($afs->parseAclOutput($aclOutput . $aclOutput) === false, + 'rejects a second object ACL header in one parser invocation'); +$repeatedNormal = str_replace( + "Negative rights:\n", "Normal rights:\n", $aclOutput); +check($afs->parseAclOutput($repeatedNormal) === false, + 'rejects a repeated Normal rights section'); +$repeatedNegative = $aclOutput . "Negative rights:\n another r\n"; +check($afs->parseAclOutput($repeatedNegative) === false, + 'rejects a repeated Negative rights section'); + +$nestedAclPost = array(); +parse_str( + 'normal[user%40cell.example][l]=1&normal[user%20name][r]=1', + $nestedAclPost); +check(isset($nestedAclPost['normal']['user@cell.example']['l']), + 'PHP preserves a dot in a nested ACL principal key'); +check(isset($nestedAclPost['normal']['user name']['r']), + 'PHP preserves a space in a nested ACL principal key'); + $afs->responses[] = $aclOutput; $readAcl = $afs->readAcl('/afs/example'); check($readAcl === $acl, 'readAcl returns the parsed ACL structure'); check($afs->commands[0] === array('listacl', '/afs/example'), 'readAcl invokes fs listacl with an argument vector'); +$afs->responses[] = $maxAclFixture; +check($afs->readAcl('/afs/example') === false + && strpos($afs->errorMsg, 'Unable to parse') !== false, + 'readAcl reports a fail-closed Volume ACL parse result'); + $afs->responses[] = "fs: permission denied\n"; check($afs->readAcl('/afs/example') === false, 'readAcl rejects fs-reported failures'); @@ -222,6 +268,61 @@ function remove_test_tree($path) @symlink('missing-target', $broken); check($fsAfs->linkSafeFileExists($broken) === true, 'lstat recognizes a broken symlink without following it'); + + $outside = $tempRoot . '/outside'; + $treeSource = $tempRoot . '/tree-source'; + $treeTarget = $tempRoot . '/tree-target'; + check(mkdir($outside, 0700) && mkdir($treeSource, 0700), + 'creates isolated source and outside directories'); + check(stat($outside)['dev'] === $device, + 'outside symlink target is on the modeled AFS device'); + file_put_contents($outside . '/sentinel.txt', 'outside-data'); + check(symlink($outside, $treeSource . '/outside-link'), + 'creates a directory symlink to an outside same-device tree'); + check($fsAfs->copy_dirs($treeSource, $treeTarget) === true, + 'recursive copy completes with a nested directory symlink'); + check(is_link($treeTarget . '/outside-link') + && readlink($treeTarget . '/outside-link') === $outside, + 'recursive copy reproduces the directory symlink without traversing it'); + check(file_get_contents($outside . '/sentinel.txt') === 'outside-data', + 'recursive copy leaves the outside sentinel unchanged'); + + $directTarget = $tempRoot . '/direct-symlink-target'; + check($fsAfs->copy_dirs($treeSource . '/outside-link', $directTarget) === false + && !file_exists($directTarget) && !is_link($directTarget), + 'copy_dirs rejects a directory symlink passed as its top-level source'); + + $copiedTopLink = $tempRoot . '/copied-top-link'; + check($fsAfs->copyItemForTest( + $treeSource . '/outside-link', $copiedTopLink) === true + && is_link($copiedTopLink) + && readlink($copiedTopLink) === $outside, + 'copy dispatcher handles a top-level directory symlink as a link'); + + $copiedBroken = $tempRoot . '/copied-broken-link'; + check($fsAfs->copyItemForTest($broken, $copiedBroken) === true + && is_link($copiedBroken) + && readlink($copiedBroken) === 'missing-target', + 'copy dispatcher preserves a broken symlink'); + + $requestTarget = $tempRoot . '/copy-request-target'; + check(mkdir($requestTarget, 0700), + 'creates a destination for the copyFiles request path'); + $fsAfs->configureCopyRequest( + $treeSource, $requestTarget, 'outside-link'); + check($fsAfs->copyFiles() === true + && is_link($requestTarget . '/outside-link') + && readlink($requestTarget . '/outside-link') === $outside, + 'copyFiles dispatches a directory symlink without traversing it'); + } + + if (function_exists('posix_mkfifo')) { + $fifo = $tempRoot . '/source.fifo'; + $fifoTarget = $tempRoot . '/copied.fifo'; + check(posix_mkfifo($fifo, 0600), 'creates an unsupported special file'); + check($fsAfs->copyItemForTest($fifo, $fifoTarget) === false + && !file_exists($fifoTarget), + 'copy dispatcher fails closed for unsupported special files'); } check($fsAfs->escape_js("a'b\\c\r\n") === "a\\'b\\\\c\\r\\n", diff --git a/tests/fixtures/auristor-listacl-with-volume-acl.txt b/tests/fixtures/auristor-listacl-with-volume-acl.txt new file mode 100644 index 00000000..dc5debd5 --- /dev/null +++ b/tests/fixtures/auristor-listacl-with-volume-acl.txt @@ -0,0 +1,12 @@ +Access list for /afs/example is +Normal rights: + system:anyuser rl + alice rlidwkaABCDEFGH +Negative rights: + blocked lk +Volume access list for example.volume is +Normal rights: + system:anyuser rl + compliance A +Negative rights: + blocked r From 744c8eb07b024e6208f75ec6585da66f0ec8f0a9 Mon Sep 17 00:00:00 2001 From: "Karl A. Grindley" Date: Mon, 17 Aug 2026 20:53:46 -0400 Subject: [PATCH 06/15] Require CSRF for single copy completion Convert copy, move, and duplicate completion from GET links to a token-verified POST form. This closes a pre-existing canonical-upstream route and is not an AFS rebase regression. --- tests/afs_static.php | 42 ++++++++++++++++++++++++++++++++++++++++++ tinyfilemanager.php | 30 ++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/tests/afs_static.php b/tests/afs_static.php index 2eb283d2..eba52d47 100644 --- a/tests/afs_static.php +++ b/tests/afs_static.php @@ -88,6 +88,7 @@ function afs_test_contains($haystack, $needle, $message) $csrfSections = array( 'single delete' => afs_test_section($manager, '// Delete file / folder', '// Create a new file/folder', 'single-delete route'), 'create' => afs_test_section($manager, '// Create a new file/folder', '// Copy folder / file', 'create route'), + 'single copy/move' => afs_test_section($manager, '// Complete a single copy/move', '// Mass copy files/ folders', 'single-copy route'), 'mass copy/move' => afs_test_section($manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route'), 'rename' => afs_test_section($manager, '// Rename', '// Download', 'rename route'), 'download' => afs_test_section($manager, '// Download', '// Upload', 'download route'), @@ -103,6 +104,47 @@ function afs_test_contains($haystack, $needle, $message) afs_test_contains($section, "verifyToken(\$_POST['token'])", $label . ' preserves token verification'); } +// This was a pre-existing upstream GET mutation, not an AFS replay change. +// Completion must remain a token-verified POST while GET is navigation-only. +$singleCopy = $csrfSections['single copy/move']; +afs_test_contains( + $singleCopy, + "isset(\$_POST['copy'], \$_POST['finish'], \$_POST['token'])", + 'single-copy completion requires POST fields and a CSRF token' +); +afs_test_ok( + strpos($singleCopy, "\$_GET['finish']") === false, + 'single-copy completion has no mutating GET finish route' +); +$singleCopyVerify = strpos($singleCopy, "verifyToken(\$_POST['token'])"); +$singleCopyMutation = strpos($singleCopy, 'fm_rename('); +afs_test_ok( + $singleCopyVerify !== false && $singleCopyMutation !== false + && $singleCopyVerify < $singleCopyMutation, + 'single-copy token verification precedes copy or move mutation' +); + +$singleCopyUi = afs_test_section( + $manager, + "// copy form\nif (isset(\$_GET['copy'])", + "if (isset(\$_GET['settings'])", + 'single-copy navigation form' +); +afs_test_contains($singleCopyUi, 'method="post"', + 'single-copy completion UI submits by POST'); +afs_test_contains($singleCopyUi, 'name="token"', + 'single-copy completion UI submits the session token'); +afs_test_contains($singleCopyUi, 'name="copy"', + 'single-copy completion UI submits the source path'); +afs_test_contains($singleCopyUi, 'name="finish" value="1"', + 'single-copy completion UI submits the completion marker'); +afs_test_contains($singleCopyUi, 'name="move" value="1"', + 'single-copy completion UI distinguishes move from copy'); +afs_test_ok( + strpos($singleCopyUi, '&finish=1') === false, + 'single-copy completion UI emits no state-changing GET links' +); + $verifyFunction = afs_test_section($manager, 'function verifyToken($token)', 'function fm_rdelete($path)', 'verifyToken function'); afs_test_contains($verifyFunction, 'hash_equals(', 'token comparison remains timing-safe'); diff --git a/tinyfilemanager.php b/tinyfilemanager.php index 3e804c0c..20d65aa8 100644 --- a/tinyfilemanager.php +++ b/tinyfilemanager.php @@ -774,9 +774,20 @@ function get_file_path() } // Copy folder / file -if (isset($_GET['copy'], $_GET['finish']) && !FM_READONLY) { +// Complete a single copy/move with a token-verified POST. This upstream route +// previously mutated state from a GET request. +if (isset($_POST['copy'], $_POST['finish'], $_POST['token']) && !FM_READONLY) { + if (!is_string($_POST['token']) || !verifyToken($_POST['token'])) { + fm_set_msg(lng('Invalid Token.'), 'error'); + die('Invalid Token.'); + } + if (!is_string($_POST['copy']) || $_POST['finish'] !== '1') { + fm_set_msg(lng('Invalid file or folder name'), 'error'); + die('Invalid copy request.'); + } + // from - $copy = urldecode($_GET['copy']); + $copy = urldecode($_POST['copy']); $copy = fm_clean_path($copy); // empty path if ($copy == '') { @@ -793,8 +804,7 @@ function get_file_path() } $dest .= '/' . basename($from); // move? - $move = isset($_GET['move']); - $move = fm_clean_path(urldecode($move)); + $move = isset($_POST['move']) && $_POST['move'] === '1'; // copy/move/duplicate if ($from != $dest) { $msg_from = trim(FM_PATH . '/' . basename($from), '/'); @@ -1662,11 +1672,15 @@ function getUploadExt() Source path:
Destination folder:

-

- Copy   - Move   +

+ + + + +   +   Cancel -

+

'; - } else if ($online_viewer == 'microsoft') { - echo ''; - } + echo '

' . lng('Preview is disabled') . '

'; } elseif ($is_zip) { // ZIP content if ($filenames !== false) { @@ -2137,13 +2028,8 @@ class="edit-file"> " class="btn btn-sm btn-outline-primary" href="javascript:void(0);" onclick="backup('','')"> - @@ -2597,16 +2482,10 @@ class="edit-file"> >
- - - - - - + - - ' . readlink($path . '/' . $f) . '' : '') ?> + + ' . readlink($path . '/' . $f) . '' : '') ?>
"> @@ -2620,6 +2499,7 @@ class="edit-file"> + - - - - + + + + + +
@@ -2704,6 +2586,15 @@ function print_external($key) echo "$external[$key]"; } +/** Accept a relative or root-relative same-origin browser resource URL. */ +function fm_is_local_resource_url($url) +{ + return is_string($url) && $url !== '' + && strpos($url, '://') === false + && substr($url, 0, 2) !== '//' + && preg_match('/[\x00-\x20\x7f]/', $url) !== 1; +} + /** * Verify CSRF TOKEN and remove after certified * @param string $token @@ -3695,13 +3586,9 @@ function scan($dir = '', $filter = '') } /** - * Parameters: downloadFile(File Location, File Name, - * max speed, is streaming - * If streaming - videos will show as videos, images as images - * instead of download prompt - * https://stackoverflow.com/a/13821992/1164642 + * Stream one guarded file as a non-active attachment. */ -function fm_download_file($fileLocation, $fileName, $chunkSize = 1024, $inline = false) +function fm_download_file($fileLocation, $fileName, $chunkSize = 1024) { if (connection_status() != 0) { return false; @@ -3710,37 +3597,32 @@ function fm_download_file($fileLocation, $fileName, $chunkSize = 1024, $inline = if ($fp === false) { return false; } - $extension = pathinfo($fileName, PATHINFO_EXTENSION); - - $contentType = fm_get_file_mimes($extension); - - if (is_array($contentType)) { - $contentType = implode(' ', $contentType); - } - $stat = fstat($fp); $size = is_array($stat) ? $stat['size'] : false; - if ($size === false || $size == 0) { + if ($size === false) { fclose($fp); - fm_set_msg(lng('Zero byte file! Aborting download'), 'error'); - $FM_PATH = FM_PATH; - fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); - - return (false); + return false; } // headers header('Content-Description: File Transfer'); header('Expires: 0'); - header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); - header('Pragma: public'); - header("Content-Transfer-Encoding: binary"); - header("Content-Type: $contentType"); + header('Cache-Control: no-store, max-age=0'); + header('Pragma: no-cache'); + header('Content-Transfer-Encoding: binary'); + header('Content-Type: application/octet-stream'); + header('X-Content-Type-Options: nosniff'); + header("Content-Security-Policy: default-src 'none'; sandbox"); - $contentDisposition = $inline ? 'inline' : 'attachment'; $fileName = str_replace(array("\r", "\n", '"'), '', basename($fileName)); - header("Content-Disposition: $contentDisposition; filename=\"$fileName\""); + header("Content-Disposition: attachment; filename=\"$fileName\""); + + if ($size === 0) { + header('Content-Length: 0'); + fclose($fp); + return true; + } header("Accept-Ranges: bytes"); $range = 0; @@ -4204,20 +4086,6 @@ function fm_show_nav_path($path)