diff --git a/include/class-FTP.php b/include/class-FTP.php index f87939d..bee0e9f 100644 --- a/include/class-FTP.php +++ b/include/class-FTP.php @@ -1,184 +1,190 @@ . // load dependent traits class_exists( 'Domain' ); /** * Methods for an FTP class */ trait FTPTrait { use DomainTrait; /** * Get the FTP login username * * @return string E-mail */ public function getFTPLogin() { return $this->get( 'ftp_login' ); } /** * Check if this FTP account is active * * @return bool */ public function isFTPActive() { return $this->get( 'ftp_active' ); } /** * Get the sanitized raw directory * * This value is sanitized before saving but it can be a malicious string * if the database is overtaken so it's sanitized again. * + * It always start with a filesystem directory separator. + * * @return string */ public function getFTPRawDirectory() { + // return a default '/' if empty $path = $this->get( 'ftp_directory' ); + if( !$path ) { + return '/'; + } // even if it was sanitized during creation, double-sanitize // to protect against hacked databases validate_subdirectory( $path ); return $path; } /** * Get the sanitized absolute FTP directory pathname * * @return string */ public function getFTPAbsoluteDirectory() { // get the sanitized domain base path $absolute_base = $this->getDomainBasePath(); // get the sanitized FTP raw directory $relative_path = $this->getFTPRawDirectory(); - // at this point the absolute realpath of the subdirectory is verified - // to do not contain '/../' or other crap and can be appended - return append_dir( $absolute_base, $relative_path, __ ); + // at this point the absolute realpath does not end with a slash + // at this point the relative path always start with a slash + return $absolute_base . __ . $relative_path; } /** * Get the mailbox permalink * * @return string */ public function getFTPPermalink( $absolute = false ) { return FTP::permalink( $this->getDomainName(), $this->getFTPLogin() ); } protected function normalizeFTP() { $this->normalizeDomain(); $this->booleans( 'ftp_active' ); $this->integers( 'ftp_ulbandwidth', 'ftp_dlbandwidth', 'ftp_quotasize', 'ftp_quotafiles' ); } } /** * An FTP user */ class FTP extends Queried { use FTPTrait; /** * Table name */ const T = 'ftp'; /** * Constructor * * Normalize the object obtained from the database */ public function __construct() { $this->normalizeFTP(); } /** * Get the FTP permalink from domain name and FTP login * * @param string $domain Domain name * @param string $login FTP user login * @return string */ public static function permalink( $domain, $login = null ) { $url = sprintf( '%s/%s', ROOT . '/ftp.php', $domain ); if( $login ) { $url .= "/$login"; } return $url; } /** * Encrypt an FTP user password * * @param $password string Clear text password * @return string One-way encrypted password */ public static function encryptPassword( $password ) { global $HOSTING_CONFIG; // the FTP password encryption mechanism can be customized if( isset( $HOSTING_CONFIG->FTP_ENCRYPT_PWD ) ) { return call_user_func( $HOSTING_CONFIG->FTP_ENCRYPT_PWD, $password ); } // or then just a default behaviour /** * The default behaviour is to adopt the crypt() encryption mechanism * with SHA512 and some random salt. It's strong enough nowadays. * * Read your FTP server documentation, whatever you are using. * We don't know how your infrastructure works, so we don't know * how you want your password encrypted in the database and what kind * of password encryption mechanisms your FTP server supports. * * In short if you are using PureFTPd this default configuration may work * because you may have PureFTPd configured as follow: * ... * MYSQLCrypt crypt * ... * * You can read more here: * https://download.pureftpd.org/pub/pure-ftpd/doc/README.MySQL * * Anyway you can use whatever FTP server that talks with a MySQL database * and so you should adopt the most stronger encryption mechanism available. */ $salt = bin2hex( openssl_random_pseudo_bytes( 3 ) ); return '{SHA512-CRYPT}' . crypt( $password, "$6$$salt" ); } } diff --git a/include/class-Mailbox.php b/include/class-Mailbox.php index ece7e06..0ccec7f 100644 --- a/include/class-Mailbox.php +++ b/include/class-Mailbox.php @@ -1,181 +1,181 @@ . // load dependent traits class_exists( 'Domain' ); trait MailboxTrait { use DomainTrait; /** * Get the mailbox username * * @return string */ public function getMailboxUsername() { return $this->get( 'mailbox_username' ); } /** * Get the mailbox address * * @return string E-mail */ public function getMailboxAddress() { return sprintf( "%s@%s", $this->get( 'mailbox_username' ), $this->get( 'domain_name' ) ); } /** * Get the mailbox permalink * * @return string */ public function getMailboxPermalink( $absolute = false ) { return Mailbox::permalink( $this->get( 'domain_name' ), $this->get( 'mailbox_username' ) ); } /** * Update this mailbox password * * @param string $password * @return string */ public function updateMailboxPassword( $password = null ) { if( ! $password ) { $password = generate_password(); } $enc_password = Mailbox::encryptPassword( $password ); // update ( new MailboxAPI() ) ->whereMailbox( $this ) ->update( [ new DBCol( 'mailbox_password', $enc_password, 's' ), ] ); return $password; } /** * Normalize a Mailbox after being fetched from database */ protected function normalizeMailbox() { $this->normalizeDomain(); $this->booleans( 'mailbox_receive' ); } /** * Get the mailbox filesystem pathname in the MTA host * * TODO: actually all the mailbox are on the same host. * Then, we should support multiple hosts. * * @return string */ public function getMailboxPath() { // require a valid filename or throw $mailbox_user = $this->getMailboxUsername(); - require_safe_filename( $mailbox_user ); + require_safe_dirname( $mailbox_user ); // mailboxes are stored under a $BASE/domain/username filesystem structure return $this->getDomainMailboxesPath() . __ . $mailbox_user; } } /** * A mailbox */ class Mailbox extends Queried { use MailboxTrait; /** * Database table */ const T = 'mailbox'; /** * Constructor */ public function __construct() { $this->normalizeMailbox(); } /** * Get the mailbox permalink * * @param $domain string * @param $mailbox string * @param $absolute boolean * @return string */ public static function permalink( $domain, $mailbox = null, $absolute = false ) { $part = site_page( 'mailbox.php', $absolute ) . _ . $domain; if( $mailbox ) { $part .= _ . $mailbox; } return $part; } /** * Encrypt a password * * @param string $password Clear text password * @return string One-way encrypted password */ public static function encryptPassword( $password ) { global $HOSTING_CONFIG; // the Mailbox password encryption mechanism can be customized if( isset( $HOSTING_CONFIG->MAILBOX_ENCRYPT_PWD ) ) { return call_user_func( $HOSTING_CONFIG->MAILBOX_ENCRYPT_PWD, $password ); } // or then just a default behaviour /** * The default behaviour is to adopt the crypt() encryption mechanism * with SHA512 and some random salt. It's strong enough nowadays. * * Read your MTA/MDA documentation, whatever you are using. * We don't know how your infrastructure works, so we don't know * how you want your password encrypted in the database and what kind * of password encryption mechanisms your MTA/MDA supports. * * In short if you are using Postfix this default configuration may work * because you may have Postfix configured as follow: * * Anyway you can use whatever MTA/MDA that talks with a MySQL database * and so you should adopt the most stronger encryption mechanism available. * * https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ */ $salt = bin2hex( openssl_random_pseudo_bytes( 3 ) ); return '{SHA512-CRYPT}' . crypt( $password, "$6$$salt" ); } } diff --git a/include/functions.php b/include/functions.php index 69c8859..31f2bd8 100644 --- a/include/functions.php +++ b/include/functions.php @@ -1,251 +1,259 @@ . /** * Require a certain page from the template directory * * @param $name string page name (to be sanitized) * @param $args mixed arguments to be passed to the page scope */ function template( $template_name, $template_args = [] ) { extract( $template_args, EXTR_SKIP ); return require TEMPLATE_PATH . __ . "$template_name.php"; } /** * Get the returned text from a template * * @param $name string page name (to be sanitized) * @param $args mixed arguments to be passed to the page scope * @see template() * @return string */ function template_content( $name, $args = [] ) { ob_start(); template( $name, $args ); $text = ob_get_contents(); ob_end_clean(); return $text; } /** * Print an e-mail (safe for bots) * * @param $email string */ function email_blur( $email ) { $dot = strip_tags( __( " dot " ) ); $at = strip_tags( __( " at " ) ); $email = esc_html( $email ); echo str_replace( [ '.', '@' ], [ $dot, $at ], $email ); } /** * Send an e-mail to someone * * @param $subject string E-mail subject * @param $message string E-mail message * @param $to string E-mail recipient (from current logged-in user as default) */ function send_email( $subject, $message, $to = false ) { if( ! $to ) { if( ! is_logged() ) { die( "can't retrieve e-mail address from anon user" ); } $to = get_user( 'user_email' ); } return SMTPMail::instance() ->to( $to ) ->message( $subject, $message ) ->disconnect(); } /** * Require a certain permission * * @param $permission string An internal permission like 'edit-all-user' * @param $redirect boolean Enable or disable the redirect */ function require_permission( $permission, $redirect = true ) { if( ! has_permission( $permission ) ) { require_more_privileges( $redirect ); } } /** * Require more privileges then actual ones */ function require_more_privileges( $redirect = true ) { if( is_logged() ) { Header::spawn( [ 'title' => __( "Permission denied" ), ] ); Footer::spawn(); exit; } else { $login = menu_entry( 'login' ); $url = $login->getAbsoluteURL(); if( $redirect && isset( $_SERVER[ 'REQUEST_URI' ] ) ) { $url = http_build_get_query( $url, [ 'redirect' => $_SERVER[ 'REQUEST_URI' ], ] ); } http_redirect( $url, 307 ); } } /** * Get URL parts from the PATH_INFO * * It spawn a "bad request" page if something goes wrong. * * @param $max int If $min is specified, this is the maximum number of parameters. When unspecified, this is the exact number of parameters. * @param $min int Mininum number of parameters. * @return array * @see https://httpd.apache.org/docs/2.4/mod/core.html#acceptpathinfo */ function url_parts( $max, $min = false ) { if( $min === false ) { $min = $max; } // split the PATH_INFO parts $parts = explode( _, $_SERVER['PATH_INFO'] ?? '' ); array_shift( $parts ); // eventually spawn the "bad request" $n = count( $parts ); if( $n > $max || $n < $min ) { BadRequest::spawn( __( "unexpected URL" ) ); } // eventually fill expected fields for( $i = $n; $i < $max; $i++ ) { $parts[] = null; } return $parts; } /** * Link to an existing page from the menu * * @param $uid string E.g. 'index' * @param $args mixed Arguments */ function the_menu_link( $uid, $args = [] ) { $page = menu_entry( $uid ); the_link( $page->getURL(), $page->name, $args ); } /** * Link to a whatever page * * P.S. link() is a reserved function * * @param $url string * @param $title string * @param $args mixed Arguments */ function the_link( $url, $title, $args = [] ) { template( 'link', [ 'title' => $title, 'url' => $url, 'args' => $args, ] ); } /** * Generate a password * * @param $bytes int */ function generate_password( $bytes = 8 ) { return rtrim( base64_encode( bin2hex( openssl_random_pseudo_bytes( $bytes ) ) ), '=' ); } /** * Validate a mailbox username * * @param $mailbox string * @return bool */ function validate_mailbox_username( $mailbox ) { return 1 === preg_match( '/^[a-z][a-z0-9-_.]+$/', $mailbox ); } /** * A certain value must be an e-mail * * @param $email string * @return string filtered e-mail */ function require_email( $email ) { $email = luser_input( $email, 128 ); if( filter_var( $email, FILTER_VALIDATE_EMAIL ) === false ) { BadRequest::spawn( __( "fail e-mail validation" ) ); } return $email; } /** - * Require a safe filename or throw an exception + * Require a strictly safe file/directory name or throw an exception * * This method is designed to prevend known filesystem exploitations - * that may occurr if a database is compromised and with malicous - * data inside domain names, etc. + * that may also occurr if a database is compromised and with malicous + * data inside domain names, etc. This also removes a wide rang of valid + * characters in Unix but this way we reduce every possible risk in other + * operating systems. * - * Just don't try to register a domain name with a slash in the name. + * Note that an empty directory is NOT a valid directory name so '///' + * is not valid even if it's a safe pathname resolution in Unix-like systems. + * This is because I don't like it. No other reasons. * - * @param string $filename + * @param string $dirname */ -function require_safe_filename( $filename ) { - $bads = [ '/', '\\', '..' ]; - foreach( $bads as $bad ) { - if( strpos( $filename, $bad ) !== false ) { - throw new Exception( sprintf( - "found '%s' in the string '%s': it cannot be considered a safe file/directory name", - $bad, - $filename - ) ); - } +function require_safe_dirname( $dirname ) { + if( !$dirname || $dirname === '.' || $dirname === '..' || !preg_match( '/^[a-zA-Z0-9-.]+$/', $dirname ) ) { + throw new Exception( sprintf( + "the directory name '%s' is weird for me", + $dirname + ) ); } } /** - * Check if a directory is under a subdirectory or throw + * Check if a pathname can be considered a valid sub-directory of something else + * + * The pathname is valid if it starts with a slash. + * The pathname will be normalized after the validation. * - * @param string $sub_directory Specific sub-directory or just '/' + * @param string $pathname Pathname to be validated like '/my-site/www' */ -function validate_subdirectory( $sub_directory ) { - - // validate each part of this pathname - // note that '' and '/' are allowed and also '///' because - // Unix already accepts it and resolve '/base////suffix' - // It does NOT accept stuff like '/../../' - $parts = explode( '/', $sub_directory ); - foreach( $parts as $part ) { - try { - require_safe_filename( $part ); - } catch( Exception $e ) { - // give a meaningful exception - throw new Exception( sprintf( - "unable to sanitize '%s': %s", - $sub_directory, - $e->getMessage() - ) ); +function validate_subdirectory( & $pathname ) { + + // assure that the string starts with just a slash and does not end with slashes + $pathname = trim( $pathname, '/' ); + + // validate each directory (if any) + $directories = []; + if( $pathname ) { + $directories = explode( '/', $pathname ); + foreach( $directories as $directory ) { + try { + require_safe_dirname( $directory ); + } catch( Exception $e ) { + throw new Exception( sprintf( + "apologies but I do not like the pathname '%s': %s", + $pathname, + $e->getMessage() + ) ); + } } } + + // merge all the directories + $pathname = '/' . implode( '/', $directories ); } diff --git a/template/ftp.php b/template/ftp.php index ba4556f..f04d408 100644 --- a/template/ftp.php +++ b/template/ftp.php @@ -1,108 +1,106 @@ . /* * This is the template for a single FTP account * * Called from: * ftp.php * * Available variables: * $domain Domain object * $ftp FTP object */ // unuseful when load directly defined( 'BOZ_PHP' ) or die; ?> $domain, 'ftp' => $ftp, ] ) ?>


getFTPLogin() ); echo " readonly"; } ?> />


- getFTPRawDirectory() ); - } + getFTPRawDirectory() : '/' ); ?> />