diff --git a/include/class-Domain.php b/include/class-Domain.php index d4ba3fd..a5bdcc2 100644 --- a/include/class-Domain.php +++ b/include/class-Domain.php @@ -1,253 +1,284 @@ . // load Plan trait class_exists( 'Plan' ); /** * Methods for a Domain class */ trait DomainTrait { use PlanTrait; /** * Count of the Domain's Mailboxes * * This is a kind of cache * * @var int */ private $domainMailboxCount = null; /** * Count of the Domain's FTP accounts * * This is a kind of cache * * @var int */ private $domainFTPAccountCount = null; /** * Get domain ID * * @return int */ public function getDomainID() { return $this->get( 'domain_ID' ); } /* * Get domain name * * @return string */ public function getDomainName() { return $this->get( 'domain_name' ); } + /** + * Get the sanitized relative directory name of this domain name + * + * Actually this should be valid for both the MTA and for the webserver. + * + * @return string + */ + public function getDomainDirname() { + + $dir = $this->getDomainName(); + + // it was validated during creation time, but validate also now + // to prevent malicious actions over hacked databases + sanitize_subdirectory( $dir ); + + return $dir; + } + /** * Get the domain edit URl * * @param boolean $absolute True for an absolute URL * @return string */ public function getDomainPermalink( $absolute = false ) { return Domain::permalink( $this->get( 'domain_name' ), $absolute ); } /** * Check if you can create a new Mailbox for this Domain * * The Domain must have Plan informations. * * @return boolean */ public function canCreateMailboxInDomain() { return $this->getPlanMailboxes() > $this->getDomainMailboxCount() || has_permission( 'edit-email-all' ); } /** * Check if you can create a new FTP account for this Domain * * The Domain must have Plan informations. * * The Domain must have Plan informations. */ public function canCreateFTPAccountForDomain() { return $this->getPlanFTPUsers() > $this->getDomainFTPAccountCount() || has_permission( 'edit-ftp-all' ); } /** * Factory mailbox from this domain * * @return MailboxFullAPI */ public function factoryMailbox() { return ( new MailboxFullAPI() )->whereDomain( $this ); } /** * Factory e-mail forward from this domain * * @return MailforwardFullAPI */ public function factoryMailforwardfrom() { return ( new MailforwardfromQuery() )->whereDomain( $this ); } /** * Set a count of Domain's Mailboxes * * This method should not be used directly. * * @param $count int * @return self */ public function setDomainMailboxCount( $count ) { $this->domainMailboxCount = $count; } /** * Get the number of Mailboxes of this Domain * * This method has a layer of cache. * * @return int */ public function getDomainMailboxCount() { // check if we already know the count if( !isset( $this->domainMailboxCount ) ) { // count the number of mailboxes associated to this Domain $count = $this->factoryMailbox() ->select( 'COUNT(*) count' ) ->queryValue( 'count' ); // save in cache $this->domainMailboxCount = (int) $count; } return $this->domainMailboxCount; } /** * Get the number of FTP accounts of this Domain * * This method has a layer of cache. * * @return int */ public function getDomainFTPAccountCount() { // check if we already know the count if( !isset( $this->domainFTPAccountCount ) ) { // count the number of mailboxes associated to this Domain $count = $this->factoryFTP() ->select( 'COUNT(*) count' ) ->queryValue( 'count' ); // save in cache $this->domainFTPAccountCount = (int) $count; } return $this->domainFTPAccountCount; } /** - * Get the MTA directory containing Domain's mailboxes + * Get the expected MTA directory containing Domain's mailboxes + * + * This pathname should be considered true for the MTA host. * * TODO: actually all the mailbox are on the same host. * Then, we should support multiple hosts. * * @return string */ public function getDomainMailboxesPath() { + // mailboxes are stored under a $BASE/domain/username filesystem structure + return MAILBOX_BASE_PATH . __ . $this->getDomainDirname(); + } - // require a valid filename or throw - $domain_name = $this->getDomainName(); - require_safe_filename( $domain_name ); - + /** + * Get the expected and sanitized base domain directory containing its directories + * + * This pathname should be considered true both for the webserver serving + * that domain and for the related FTP server. + * + * TODO: actually all the domains are on the same host. + * Then, we should support multiple hosts. + * + * @return string + */ + public function getDomainBasePath() { // mailboxes are stored under a $BASE/domain/username filesystem structure - return MAILBOX_BASE_PATH . __ . $domain_name; + return VIRTUALHOSTS_DIR . __ . $this->getDomainDirname(); } /** * Factory FTP users from this domain * * @return FTPAPI */ public function factoryFTP() { return ( new FTPAPI() )->whereDomain( $this ); } /** * Normalize a Domain object after being retrieved from database */ protected function normalizeDomain() { $this->integers( 'domain_ID' ); $this->booleans( 'domain_active' ); $this->dates( 'domain_born', 'domain_expiration' ); $this->normalizePlan(); } } /** * Describe the 'domain' table */ class Domain extends Queried { use DomainTrait; /** * Table name */ const T = 'domain'; const UID = 'domain_name'; /** * Constructor */ public function __construct() { $this->normalizeDomain(); } /** * Get the domain permalink * * @param string $domain_name Domain name * @param boolean $absolute True for an absolute URL */ public static function permalink( $domain_name = null, $absolute = false ) { $url = 'domain.php'; if( $domain_name ) { $url .= _ . $domain_name; } return site_page( $url, $absolute ); } } diff --git a/include/class-FTP.php b/include/class-FTP.php index 7fb31f5..972e913 100644 --- a/include/class-FTP.php +++ b/include/class-FTP.php @@ -1,148 +1,184 @@ . // 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. + * + * @return string + */ + public function getFTPRawDirectory() { + + $path = $this->get( 'ftp_directory' ); + + // 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 + 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/functions.php b/include/functions.php index 126dd82..69c8859 100644 --- a/include/functions.php +++ b/include/functions.php @@ -1,231 +1,251 @@ . /** * 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 * - * It returns that filename untouched. - * * 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. * * Just don't try to register a domain name with a slash in the name. * * @param string $filename - * @return string */ function require_safe_filename( $filename ) { - $bads = [ '/', '\\', '..' ]; foreach( $bads as $bad ) { - if( strpos( $filename, '/' ) !== false ) { + 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 ) ); } } +} - return $filename; +/** + * Check if a directory is under a subdirectory or throw + * + * @param string $sub_directory Specific sub-directory or just '/' + */ +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() + ) ); + } + } } diff --git a/load-post.php b/load-post.php index 289e558..c7d61ef 100644 --- a/load-post.php +++ b/load-post.php @@ -1,103 +1,109 @@ . // include path define_default( 'INCLUDE_PATH', ABSPATH . __ . 'include' ); // template path define_default( 'TEMPLATE_PATH', ABSPATH . __ . 'template' ); // autoload classes from the /include directory spl_autoload_register( function( $name ) { // TODO: autoload classes and create DomainTrait and use in Mailbox $path = INCLUDE_PATH . __ . "class-$name.php"; if( is_file( $path ) ) { require $path; } } ); // override default user class define( 'SESSIONUSER_CLASS', 'User' ); // load common functions require INCLUDE_PATH . __ . 'functions.php'; // jquery URL // provided by the libjs-jquery package as default define_default( 'JQUERY_URL', '/javascript/jquery/jquery.min.js' ); // Bootstrap CSS/JavaScript files without trailing slash // provided by the libjs-bootstrap package as default define_default( 'BOOTSTRAP_DIR_URL', '/javascript/bootstrap' ); // path to the Net SMTP class // provided by the php-net-smtp package as default define_default( 'NET_SMTP', '/usr/share/php/Net/SMTP.php' ); +// base directory for your virtualhosts +// e.g. you may have /var/www/example.com/index.html +// do NOT end with a slash +// TODO: support multiple hosts +define_default( 'VIRTUALHOSTS_DIR', '/var/www' ); + // register JavaScript/CSS files register_js( 'jquery', JQUERY_URL ); register_js( 'bootstrap', BOOTSTRAP_DIR_URL . '/js/bootstrap.min.js' ); register_css( 'bootstrap', BOOTSTRAP_DIR_URL . '/css/bootstrap.min.css' ); register_css( 'custom-css', ROOT . '/content/style.css' ); // GNU Gettext i18n define( 'GETTEXT_DOMAIN', 'reyboz-hosting-panel' ); define( 'GETTEXT_DIRECTORY', 'l10n' ); define( 'GETTEXT_DEFAULT_ENCODE', CHARSET ); // UTF-8 // common strings define_default( 'SITE_NAME', "Boz Libre Hosting Panel" ); define_default( 'CONTACT_EMAIL', 'support@' . DOMAIN ); define_default( 'REPO_URL', 'https://gitpull.it/project/profile/15/' ); // limit session duration to 5 minutes (60s * 100m) define_default( 'SESSION_DURATION', 6000 ); /** * Mailbox base path * * Used by CLI scripts to calculate the current quotas. * * The mailboxes should have paths like: * MAILBOX_BASE_PATH/domain_name/user_name/ */ define_default( 'MAILBOX_BASE_PATH', '/home/vmail' ); // register web pages add_menu_entries( [ new MenuEntry( 'index', '/', __( "Dashboard" ), null, 'backend' ), new MenuEntry( 'login', 'login.php', __( "Login" ) ), new MenuEntry( 'profile', 'profile.php', __( "Profile" ) ), new MenuEntry( 'logout', 'logout.php', __( "Logout" ), null, 'read' ), new MenuEntry( 'user-list', 'user-list.php', __( "Users" ), null, 'edit-user-all' ), new MenuEntry( 'password-reset', 'password-reset.php', __( "Password reset" ) ), ] ); // permissions of a normal user register_permissions( 'user', [ 'read', 'backend', ] ); // permissions of an admin inherit_permissions( 'admin', 'user', [ 'edit-user-all', 'edit-email-all', 'edit-domain-all', 'edit-plan-all', 'edit-ftp-all', ] ); diff --git a/template/ftp.php b/template/ftp.php index 117e112..ba4556f 100644 --- a/template/ftp.php +++ b/template/ftp.php @@ -1,77 +1,108 @@ . /* * 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() ) ?> -

-
- +

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

+ +

+
+ getFTPRawDirectory() ); + } + ?> /> +

+

+ + + +

+

+ +

+ + + + + +