From a9b89fd7b3df1f3d4591c5df020ad4731d97a2c6 Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sat, 7 Jun 2025 13:06:23 +0200 Subject: [PATCH 1/4] Update instructions for Dovecot 2.4 --- plugins/proxy-auth/README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/plugins/proxy-auth/README.md b/plugins/proxy-auth/README.md index 7a3004d70..ec938ecb5 100644 --- a/plugins/proxy-auth/README.md +++ b/plugins/proxy-auth/README.md @@ -29,26 +29,25 @@ This concludes the setup of SnappyMail. In Dovecot, you need to enable Master User. Enable ```!include auth-master.conf.ext``` in /etc/dovecot/conf.d/10-auth.conf. -The file /etc/dovecot/conf.d/auth-master.conf.ext should contain: +In Dovecot 2.3, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain: ``` -# Authentication for master users. Included from auth.conf. - -# By adding master=yes setting inside a passdb you make the passdb a list -# of "master users", who can log in as anyone else. -# - -# Example master user passdb using passwd-file. You can use any passdb though. passdb { driver = passwd-file master = yes args = /etc/dovecot/master-users - - # Unless you're using PAM, you probably still want the destination user to - # be looked up from passdb that it really exists. pass=yes does that. pass = yes } ``` +In Dovecot 2.4, the file /etc/dovecot/conf.d/auth-master.conf.ext should contain: +``` +passdb passwd-file { + master = yes + passwd_file_path = /etc/dovecot/master-users + result_success = continue +} +``` + You then need to create a master user in /etc/dovecot/master-users: ``` admin:PASSWORD::::::allow_nets=local,172.17.0.0/16 From 60ee74c68a7bef2b59e9ef9e5f92507302c349fb Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 17 May 2026 09:53:56 +0200 Subject: [PATCH 2/4] proxy-auth: fix check_proxy enforcement and persistence The proxy IP check was never enforced: ServiceProxyAuth read config key 'proxy_check' but the property is registered as 'check_proxy', so the lookup always returned the default empty string and the else-branch set $sProxyRequest = true unconditionally. The BOOL property also had SetEncrypted(), which caused getDecrypted() to call DecryptFromJSON() on a literal bool and fail with "DecryptFromJSON() invalid $data", so the admin UI never reflected the saved value. BOOLs are stored as plain bools in JSON; switch to Get() and drop SetEncrypted() on the property. Co-Authored-By: Claude Opus 4.7 --- plugins/proxy-auth/index.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/proxy-auth/index.php b/plugins/proxy-auth/index.php index ad2fea0f4..d13a37c40 100644 --- a/plugins/proxy-auth/index.php +++ b/plugins/proxy-auth/index.php @@ -6,8 +6,8 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Proxy Auth', AUTHOR = 'Philipp', URL = 'https://www.mundhenk.org/', - VERSION = '0.5', - RELEASE = '2024-09-20', + VERSION = '0.6', + RELEASE = '2026-05-17', REQUIRED = '2.36.1', CATEGORY = 'Login', LICENSE = 'MIT', @@ -84,7 +84,7 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $sMsg = "ProxyIP: " . $sProxyIP; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sProxyCheck = $this->Config()->getDecrypted('plugin', 'proxy_check', ''); + $sProxyCheck = (bool) $this->Config()->Get('plugin', 'check_proxy', false); $sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); /* make sure that remote user is only set by authorized proxy to avoid security risks */ @@ -192,8 +192,7 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetLabel('Check Proxy') ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) ->SetDescription('Activates check if proxy is connecting') - ->SetDefaultValue(true) - ->SetEncrypted(), + ->SetDefaultValue(true), \RainLoop\Plugins\Property::NewInstance('proxy_ip') ->SetLabel('Proxy IPNet') ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) From 68588287efd77104efed7565860835729585b450 Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 17 May 2026 09:57:22 +0200 Subject: [PATCH 3/4] proxy-auth: document APP_SALT rotation troubleshooting Add README note explaining that AUTHENTICATIONFAILED after a container rebuild or data reset is typically caused by an APP_SALT change invalidating the encrypted master credentials, and that re-entering them in the admin UI restores access. Co-Authored-By: Claude Opus 4.7 --- plugins/proxy-auth/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/proxy-auth/README.md b/plugins/proxy-auth/README.md index ec938ecb5..1f25b7fcc 100644 --- a/plugins/proxy-auth/README.md +++ b/plugins/proxy-auth/README.md @@ -79,3 +79,11 @@ The user is always considered logged in, as authentication is handled through re Auto login can be disabled in the plugin settings. You can also change the logout link in admin panel -> Config -> custom_logout_link to the one of your authentication system, e.g., ```https://auth.yourdomain.com/logout```. In this case, you can log out from your overall system via SnappyMail. + +## Troubleshooting + +### IMAP `AUTHENTICATIONFAILED` after a container rebuild / upgrade + +The master user/password fields are encrypted at rest using SnappyMail's `APP_SALT`. If that salt is regenerated (e.g., the data volume was reset, the container was rebuilt without persisting `_data_`, or the salt file was rotated), the values in `plugin-proxy-auth.json` can no longer be decrypted. `getDecrypted()` then silently returns `null`, an empty password is passed to IMAP, and Dovecot rejects the login with `AUTHENTICATIONFAILED`. + +Fix: open admin panel -> Extensions -> Proxy Auth, re-enter the Master User and Master Password (and any other previously-set encrypted fields), and save. The values will be re-encrypted under the current salt. From 863f719da30c7984f7668a8bb0b91259e875d4af Mon Sep 17 00:00:00 2001 From: Philipp Mundhenk Date: Sun, 17 May 2026 10:44:36 +0200 Subject: [PATCH 4/4] proxy-auth: remove check_proxy / proxy_ip settings The "Check Proxy" option inspected the forwarded client IP returned by Http::GetClientIp(true), which resolves to HTTP_X_FORWARDED_FOR first and only falls back to REMOTE_ADDR. In any typical reverse-proxy deployment that header carries the end-user's IP, not the proxy's, so the option never actually validated that the request came from the trusted proxy. It only constrained the set of end-user IPs allowed to use SSO, which is not a meaningful guarantee on its own. Drop the option, the paired proxy_ip subnet field, and the ip_in_range helper. Document that access must be gated at the network layer (firewall / docker network / bound interface) and that the upstream proxy must strip any client-supplied value of the configured remote-user header. Co-Authored-By: Claude Opus 4.7 --- plugins/proxy-auth/README.md | 6 ++- plugins/proxy-auth/index.php | 95 +++++------------------------------- 2 files changed, 16 insertions(+), 85 deletions(-) diff --git a/plugins/proxy-auth/README.md b/plugins/proxy-auth/README.md index 1f25b7fcc..c9db401e6 100644 --- a/plugins/proxy-auth/README.md +++ b/plugins/proxy-auth/README.md @@ -19,10 +19,12 @@ The following steps are require in SnappyMail: - Master User is dependent on Dovecot config (see below) - Master User Password is dependent on Dovecot config (see below) - Header Name is dependent on authentication solution. This is the header containing the name of currently logged in user. In case of Authelia, this is "Remote-User". - - Check Proxy: Since this plugin partially bypasses authentication, it is important to only allow this access from well-defined hosts. It is highly recommended to activate this option! - - When checking for reverse proxy, it is required to set the IP filter to either an IP address or a subnet. - Automatic Login: Automatically logs in the user of user header is present (see below) +> **Security note** +> +> This plugin trusts the configured request header as proof of identity. Anyone who can reach the SnappyMail container directly and set that header can log in as any user. You **must** ensure that SnappyMail is only reachable through your reverse proxy / SSO chain (e.g. via the docker network, a firewall, or by binding SnappyMail to a non-public interface) and that the upstream proxy strips any client-supplied value of the header before forwarding. The plugin itself does no source-IP validation — earlier versions had a `check_proxy` option, but it inspected the forwarded client IP (the end user's IP) and therefore did not actually verify that the request came from the proxy. It has been removed; gate access at the network layer instead. + This concludes the setup of SnappyMail. ### Dovecot diff --git a/plugins/proxy-auth/index.php b/plugins/proxy-auth/index.php index d13a37c40..36db5d9c2 100644 --- a/plugins/proxy-auth/index.php +++ b/plugins/proxy-auth/index.php @@ -6,7 +6,7 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin NAME = 'Proxy Auth', AUTHOR = 'Philipp', URL = 'https://www.mundhenk.org/', - VERSION = '0.6', + VERSION = '0.7', RELEASE = '2026-05-17', REQUIRED = '2.36.1', CATEGORY = 'Login', @@ -21,26 +21,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $this->addHook('login.credentials', 'MapEmailAddress'); } - /* by https://gist.github.com/tott/7684443 */ - /** - * Check if a given ip is in a network - * @param string $ip IP to check in IPV4 format eg. 127.0.0.1 - * @param string $range IP/CIDR netmask eg. 127.0.0.0/24, also 127.0.0.1 is accepted and /32 assumed - * @return boolean true if the ip is in this range / false if not. - */ - private function ip_in_range( $ip, $range ) { - if ( strpos( $range, '/' ) == false ) { - $range .= '/32'; - } - // $range is in IP/CIDR format eg 127.0.0.1/24 - list( $range, $netmask ) = explode( '/', $range, 2 ); - $range_decimal = ip2long( $range ); - $ip_decimal = ip2long( $ip ); - $wildcard_decimal = pow( 2, ( 32 - $netmask ) ) - 1; - $netmask_decimal = ~ $wildcard_decimal; - return ( ( $ip_decimal & $netmask_decimal ) == ( $range_decimal & $netmask_decimal ) ); - } - public function MapEmailAddress(string &$sEmail, string &$sImapUser, string &$sPassword, string &$sSmtpUser) { $oActions = \RainLoop\Api::Actions(); @@ -80,59 +60,19 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin $sMsg = "Remote User: " . $sRemoteUser; $oLogger->Write($sMsg, $sLevel, $sPrefix); - $sProxyIP = $this->Config()->getDecrypted('plugin', 'proxy_ip', ''); - $sMsg = "ProxyIP: " . $sProxyIP; - $oLogger->Write($sMsg, $sLevel, $sPrefix); + /* create master user login from remote user header and settings */ + $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; + $sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', ''))); - $sProxyCheck = (bool) $this->Config()->Get('plugin', 'check_proxy', false); - $sClientIPs = $this->Manager()->Actions()->Http()->GetClientIP(true); - - /* make sure that remote user is only set by authorized proxy to avoid security risks */ - if ($sProxyCheck) { - $sProxyRequest = false; - $sMsg = "checking client IPs: " . $sClientIPs; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - $sClientIPs = explode(", ", $sClientIPs); - if (is_array($sClientIPs)) { - foreach ($sClientIPs as &$sIP) { - $sMsg = "checking client IP: " . $sIP; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - if ($this->ip_in_range($sIP, $sProxyIP)) { - $sProxyRequest = true; - } - } - } else { - $sMsg = "checking client IP: " . $sClientIPs; - $oLogger->Write($sMsg, $sLevel, $sPrefix); - - if ($this->ip_in_range($sClientIPs, $sProxyIP)) { - $sProxyRequest = true; - } - } - } else { - $sProxyRequest = true; + try + { + static::$login = true; + $oAccount = $oActions->LoginProcess($sEmail, $sPassword); } - - if ($sProxyRequest) { - /* create master user login from remote user header and settings */ - $sEmail = $sRemoteUser . $sMasterSeparator . $sMasterUser; - $sPassword = new \SnappyMail\SensitiveString(\trim($this->Config()->getDecrypted('plugin', 'master_password', ''))); - - try - { - static::$login = true; - $oAccount = $oActions->LoginProcess($sEmail, $sPassword); - } - catch (\Throwable $oException) - { - $oLogger = $oActions->Logger(); - $oLogger && $oLogger->WriteException($oException); - } - - \MailSo\Base\Http::Location('./'); - return true; + catch (\Throwable $oException) + { + $oLogger = $oActions->Logger(); + $oLogger && $oLogger->WriteException($oException); } \MailSo\Base\Http::Location('./'); @@ -188,17 +128,6 @@ class ProxyAuthPlugin extends \RainLoop\Plugins\AbstractPlugin ->SetDescription('Name of header containing username') ->SetDefaultValue('Remote-User') ->SetEncrypted(), - \RainLoop\Plugins\Property::NewInstance('check_proxy') - ->SetLabel('Check Proxy') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL) - ->SetDescription('Activates check if proxy is connecting') - ->SetDefaultValue(true), - \RainLoop\Plugins\Property::NewInstance('proxy_ip') - ->SetLabel('Proxy IPNet') - ->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING_TEXT) - ->SetDescription('IP or Subnet of proxy, auth header will only be accepted from this address') - ->SetDefaultValue('10.1.0.0/24') - ->SetEncrypted(), \RainLoop\Plugins\Property::NewInstance('auto_login') ->SetAllowedInJs(true) ->SetLabel('Activate automatic login')