diff --git a/cli/destroy-mailbox b/cli/destroy-mailbox new file mode 120000 index 0000000..afa4f79 --- /dev/null +++ b/cli/destroy-mailbox @@ -0,0 +1 @@ +../scripts/mailbox/destroy.php \ No newline at end of file diff --git a/include/class-Mailbox.php b/include/class-Mailbox.php index 228b919..a2be1c6 100644 --- a/include/class-Mailbox.php +++ b/include/class-Mailbox.php @@ -1,164 +1,184 @@ . // 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 + * + * TODO: actually all the mailbox are on the same host. + * Then, we should support multiple hosts. + * + * @return string + */ + public function getMailboxPath() { + + $domain_name = $this->getDomainName(); + $mailbox_user = $this->getMailboxUsername(); + + // require a valid filename or throw + require_safe_filename( $domain_name ); + require_safe_filename( $mailbox_user ); + + // mailboxes are stored under a $BASE/domain/username filesystem structure + return MAILBOX_BASE_PATH . __ . $domain_name . __ . $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/class-MailboxDestroyer.php b/include/class-MailboxDestroyer.php new file mode 100644 index 0000000..daf1f9e --- /dev/null +++ b/include/class-MailboxDestroyer.php @@ -0,0 +1,105 @@ +. + +/** + * Class dedicated to the deletion of a Mailbox + * + * This class is called from command line scripts + * and not from the web interface. + */ +class MailboxDestroyer { + + /** + * Really delete a mailbox with its contents + * + * This method is executed from command line scripts, + * with elevated privileges. + * + * This method is NOT designed to be called from the web interface + * for two reasons: + * + * - IT WILL NOT WORK because the web server user is low privileged + * - IT MAY WORK if you are an idiot and you configured a very dangerous + * webserver and mailserver: in this case your configuration and this method + * would be SO MUCH DANGEROUS. Your costumers should kill you instantly. + * + * This hosting panel is not designed to give high privileges to your users, + * but the SYSADMIN have all the rights to do whatever he wants. Here + * why this method exists and for what kind of people is designed: for SYSADMINS. + * + * @param $mailbox object Mailbox to be deleted + */ + public static function destroy( $mailbox ) { + + // valid and sanitized mailbox pathname + $path = $mailbox->getMailboxPath(); + + // delete the phisical e-mails + if( file_exists( $path ) ) { + + /** + * Now it's the time to completely remove a mailbox + * + * I've invested some time in documenting this method, + * so don't be STUPID and don't RUN across your room + * screaming OH MY GOD OH MY GOD THIS IS SO INSECURE. + * + * If you remember the 2020 Coronavirus, we should arrest you + * if you create such panic. + * + * Again, this method is designed to be called by + * SYSADMINS from a command line interface, after so much + * security layers in the middle, and not by any other user + * from a stupid web interface. + * + * Just two FAQ: + * - NOPE, the webserver will not be able to execute this + * command because it has not enough privileges. + * - NOPE, even a SYSTEM ADMINISTRATOR will NOT be able to run + * rm -Rf / + * because, first of all, look at how Mailbox#getMailboxPath() + * is sanitized. + * - Holy shit stop thinking about rm -RF /. It's impossible. You should + * at least specify also '--no-preserve-root'. It's not possible. + * If you are in panic you are stupid. That's the end of the story. + * + * -- Valerio Bozzolan Sat 07 Mar 2020 05:03:32 PM CET + */ + $command = sprintf( + 'rm --force --recursive -- %s', + escapeshellarg( $path ) + ); + + // try to execute the command + system( $command, $exit_code ); + + // check if the command was not executed successfully + if( $exit_code !== 0 ) { + throw new Exception( sprintf( + "unable to execute this command: %s", + $command + ) ); + } + } + + // last but not the least - delete from database + ( new MailboxAPI() ) + ->whereMailbox( $mailbox ) + ->delete(); + } + +} diff --git a/include/functions.php b/include/functions.php index 278a9ea..126dd82 100644 --- a/include/functions.php +++ b/include/functions.php @@ -1,201 +1,231 @@ . /** * 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 ) { + throw new Exception( sprintf( + "found '%s' in the string '%s': it cannot be considered a safe file/directory name", + $bad, + $filename + ) ); + } + } + + return $filename; +} diff --git a/scripts/mailbox/destroy.php b/scripts/mailbox/destroy.php new file mode 100755 index 0000000..bd82ec8 --- /dev/null +++ b/scripts/mailbox/destroy.php @@ -0,0 +1,115 @@ +#!/usr/bin/php +. + +/** + * destroy-mailbox + * + * This is a command-line interface to destroy a mailbox. + * + * This script is designed to be run by a SYSTEM ADMINISTRATOR + * with enough brain and with enough privileges to delete contents from + * your MDA. + * + * YES, This script PERMANENTLY REMOVE ALL THE F*****G E-MAILS + * of the specified mailbox and PERMANENTLY REMOVE the mailbox from + * your database and YOU SHOULD HAVE A F*****G BACKUP. + */ + +// require the framework +require dirname( __FILE__ ) . '/../../load.php'; + +// no arguments no party +if( !isset( $argv ) ) { + exit( 1 ); +} + +// check the mailbox name +$mailbox_raw = $argv[1] ?? null; + +// no mailbox no party +if( !$mailbox_raw ) { + destroy_mailbox_help( "Please specify a mailbox" ); + exit( 2 ); +} + +// mailbox username and domain name +$mailbox_username = null; +$domain_name = null; + +// no valid mailbox no party +if( substr_count( $mailbox_raw, '@' ) === 1 ) { + + // extract the mailbox username and domain name + list( $mailbox_username, $domain_name ) = explode( '@', $mailbox_raw, 2 ); +} + +// check if the user input has sense +if( !$mailbox_username || !$domain_name ) { + destroy_mailbox_help( sprintf( + "Invalid e-mail address '%s'", + $mailbox_raw + ) ); + exit( 3 ); +} + +// request the mailbox +$mailbox = ( new MailboxAPI() ) + ->joinDomain() + ->whereDomainName( $domain_name ) + ->whereMailboxUsername( $mailbox_username ) + ->queryRow(); + +// no mailbox no party +if( !$mailbox ) { + echo "Cannot destroy an unexisting mailbox.\n"; + exit( 4 ); +} + +// mandatory question +printf( + "Are you F*****G sure to DESTROY the mailbox '%s' FOREVER? [y/n]\n", + $mailbox->getMailboxAddress() +); +$yes = readline(); + +// aborted +if( $yes !== 'y' ) { + echo "Aborted\n"; + exit( 0 ); +} + +// destroy this F*****G mailbox NOW +MailboxDestroyer::destroy( $mailbox ); + +echo "Destroyed.\n"; + +/** + * Show an help message, eventually with a custom message + * + * @param $message string + */ +function destroy_mailbox_help( $message = null ) { + + printf( "Usage:\n %s foo@example.com\n", $GLOBALS['argv'][0] ); + + // eventually spawn an error message + if( $message ) { + echo "\n"; + printf( "Error:\n %s\n", $message ); + } +}