From 3c986e13907323e6a5d91f625318c3b25181b423 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:25:52 +0530 Subject: [PATCH 01/30] Add wildcard htpasswd support for multisite subdomains and alias domains - Modified generate_site_auth_files() to create _wildcard.site_url file for subdomain multisites (app_sub_type === 'subdom') - Handle alias_domains: create htpasswd files for each alias domain - Convert *.domain format aliases to _wildcard.domain - For subdomain multisites, also create wildcard versions of alias domains - Updated all callers to pass site_data parameter - Fetch site data from DB when regenerating auth for all sites globally Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 59 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index f297aa6..4865f0b 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -161,7 +161,7 @@ private function create_auth( array $assoc_args, bool $global, string $site_url if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url ); + $this->generate_site_auth_files( $site_url, $this->site_data ); } EE::log( 'Reloading global reverse proxy.' ); @@ -283,7 +283,10 @@ private function generate_global_auth_files() { ); foreach ( $sites as $site ) { - $this->generate_site_auth_files( $site ); + // Fetch site data to get app_sub_type and alias_domains + $site_info = \EE\Model\Site::where( 'site_url', $site ); + $site_data = ! empty( $site_info ) ? $site_info[0] : null; + $this->generate_site_auth_files( $site, $site_data ); } } } @@ -292,25 +295,57 @@ private function generate_global_auth_files() { * Generates auth files for a site * * @param string $site_url URL of site + * @param object $site_data Optional site data object containing app_sub_type and alias_domains * * @throws Exception */ - private function generate_site_auth_files( string $site_url ) { - $site_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $site_url; - $this->fs->remove( $site_auth_file ); + private function generate_site_auth_files( string $site_url, $site_data = null ) { + // Collect all domains to generate htpasswd files for + $domains = [ $site_url ]; + + // For subdomain multisites, add wildcard file + if ( $site_data && ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { + $domains[] = '_wildcard.' . $site_url; + } + + // Add alias domains (excluding main site_url) + if ( $site_data && ! empty( $site_data->alias_domains ) ) { + $alias_list = array_map( 'trim', explode( ',', $site_data->alias_domains ) ); + foreach ( $alias_list as $alias ) { + if ( empty( $alias ) || $alias === $site_url ) { + continue; + } + // Replace *.domain with _wildcard.domain + if ( 0 === strpos( $alias, '*.' ) ) { + $domains[] = '_wildcard.' . substr( $alias, 2 ); + } else { + $domains[] = $alias; + // For subdomain multisites, also add wildcard for non-wildcard alias domains + if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { + $domains[] = '_wildcard.' . $alias; + } + } + } + } $auths = array_merge( Auth::get_global_auths(), Auth::where( 'site_url', $site_url ) ); - foreach ( $auths as $key => $auth ) { - $flags = 'b'; + // Generate htpasswd files for all collected domains + foreach ( $domains as $domain ) { + $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; + $this->fs->remove( $domain_auth_file ); - if ( $key === 0 ) { - $flags = 'bc'; + foreach ( $auths as $key => $auth ) { + $flags = 'b'; + + if ( $key === 0 ) { + $flags = 'bc'; + } + EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $domain, $auth->username, $auth->password ) ); } - EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $site_url, $auth->username, $auth->password ) ); } } @@ -453,7 +488,7 @@ private function update_auth( array $assoc_args, string $site_url ) { if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url ); + $this->generate_site_auth_files( $site_url, $this->site_data ); } EE::log( 'Reloading global reverse proxy.' ); @@ -594,7 +629,7 @@ public function delete( $args, $assoc_args ) { if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url ); + $this->generate_site_auth_files( $site_url, $this->site_data ); } if ( $user ) { From e697bb21021e30e1f75faad6225bea833118853d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:32:54 +0530 Subject: [PATCH 02/30] Fix duplicate domain entries in htpasswd file generation Add array_unique() to deduplicate domains array before processing, preventing duplicate htpasswd file creation when alias domains overlap with wildcard domains (e.g., *.example.com alias on a subdomain multisite) Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 4865f0b..1c0ae94 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -333,6 +333,9 @@ private function generate_site_auth_files( string $site_url, $site_data = null ) Auth::where( 'site_url', $site_url ) ); + // Remove duplicates (e.g., *.example.com alias + subdomain multisite both create _wildcard.example.com) + $domains = array_unique( $domains ); + // Generate htpasswd files for all collected domains foreach ( $domains as $domain ) { $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; From 5589921e3ea4c4f743f59a08fe3be17b232f0f17 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:36:07 +0530 Subject: [PATCH 03/30] Add wildcard and alias domain support to whitelist generation - Modified generate_site_whitelist() to create whitelist files for _wildcard.site_url on subdomain multisites - Handle alias_domains: create whitelist files for each alias domain - Convert *.domain format aliases to _wildcard.domain - For subdomain multisites, also create wildcard versions of alias domains - Updated all callers to pass site_data parameter - Fetch site data from DB when regenerating whitelist for all sites globally Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 53 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 1c0ae94..b2b36a6 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -200,7 +200,7 @@ private function create_whitelist( string $site_url, string $ips ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url ); + $this->generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); @@ -371,7 +371,10 @@ private function generate_global_whitelist() { } foreach ( $sites as $site ) { - $this->generate_site_whitelist( $site ); + // Fetch site data to get app_sub_type and alias_domains + $site_info = \EE\Model\Site::where( 'site_url', $site ); + $site_data = ! empty( $site_info ) ? $site_info[0] : null; + $this->generate_site_whitelist( $site, $site_data ); } } @@ -380,12 +383,41 @@ private function generate_global_whitelist() { * Generates site whitelist files * * @param string $site_url + * @param object $site_data Optional site data object containing app_sub_type and alias_domains * * @throws Exception */ - private function generate_site_whitelist( string $site_url ) { - $site_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $site_url . '_acl'; - $this->fs->remove( $site_whitelist_file ); + private function generate_site_whitelist( string $site_url, $site_data = null ) { + // Collect all domains to generate whitelist files for + $domains = [ $site_url ]; + + // For subdomain multisites, add wildcard file + if ( $site_data && ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { + $domains[] = '_wildcard.' . $site_url; + } + + // Add alias domains (excluding main site_url) + if ( $site_data && ! empty( $site_data->alias_domains ) ) { + $alias_list = array_map( 'trim', explode( ',', $site_data->alias_domains ) ); + foreach ( $alias_list as $alias ) { + if ( empty( $alias ) || $alias === $site_url ) { + continue; + } + // Replace *.domain with _wildcard.domain + if ( 0 === strpos( $alias, '*.' ) ) { + $domains[] = '_wildcard.' . substr( $alias, 2 ); + } else { + $domains[] = $alias; + // For subdomain multisites, also add wildcard for non-wildcard alias domains + if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { + $domains[] = '_wildcard.' . $alias; + } + } + } + } + + // Remove duplicates + $domains = array_unique( $domains ); $whitelists = array_column( 'default' === $site_url ? Whitelist::get_global_ips() : @@ -396,7 +428,12 @@ private function generate_site_whitelist( string $site_url ) { 'ip' ); - $this->put_ips_to_file( $site_whitelist_file, $whitelists ); + // Generate whitelist files for all collected domains + foreach ( $domains as $domain ) { + $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; + $this->fs->remove( $domain_whitelist_file ); + $this->put_ips_to_file( $domain_whitelist_file, $whitelists ); + } } /** @@ -537,7 +574,7 @@ private function update_whitelist( string $site_url, string $ips ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url ); + $this->generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); @@ -684,7 +721,7 @@ public function delete( $args, $assoc_args ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url ); + $this->generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); From 3e7027c8421e110d429f2fbe55e0d214fbe71ccb Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:40:05 +0530 Subject: [PATCH 04/30] Skip *.site_url alias only for subdomain multisites When alias_domains contains *.site_url (e.g., *.example.com for site example.com), skip it ONLY if the site is a subdomain multisite since _wildcard.site_url is already added in that case. For non-multisite sites with *.site_url alias, create the _wildcard.site_url file. Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index b2b36a6..8e5f9a4 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -315,6 +315,10 @@ private function generate_site_auth_files( string $site_url, $site_data = null ) if ( empty( $alias ) || $alias === $site_url ) { continue; } + // Skip *.site_url as it transforms to _wildcard.site_url (already added for subdomain multisite) + if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type && '*.' . $site_url === $alias ) { + continue; + } // Replace *.domain with _wildcard.domain if ( 0 === strpos( $alias, '*.' ) ) { $domains[] = '_wildcard.' . substr( $alias, 2 ); @@ -403,6 +407,10 @@ private function generate_site_whitelist( string $site_url, $site_data = null ) if ( empty( $alias ) || $alias === $site_url ) { continue; } + // Skip *.site_url as it transforms to _wildcard.site_url (already added for subdomain multisite) + if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type && '*.' . $site_url === $alias ) { + continue; + } // Replace *.domain with _wildcard.domain if ( 0 === strpos( $alias, '*.' ) ) { $domains[] = '_wildcard.' . substr( $alias, 2 ); From 63ac4fa8fb067afc3cd4b655a3c5f9afb81f542e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:41:54 +0530 Subject: [PATCH 05/30] Add explicit handling for empty auth/whitelist arrays When no auths or whitelists exist for a site, explicitly remove all related htpasswd/whitelist files (including wildcards and aliases) and return early. This makes the cleanup behavior more explicit rather than silently iterating over an empty array. Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 8e5f9a4..61a77a0 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -340,6 +340,15 @@ private function generate_site_auth_files( string $site_url, $site_data = null ) // Remove duplicates (e.g., *.example.com alias + subdomain multisite both create _wildcard.example.com) $domains = array_unique( $domains ); + // If no auths exist, remove all htpasswd files for this site and its domains + if ( empty( $auths ) ) { + foreach ( $domains as $domain ) { + $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; + $this->fs->remove( $domain_auth_file ); + } + return; + } + // Generate htpasswd files for all collected domains foreach ( $domains as $domain ) { $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; @@ -436,6 +445,15 @@ private function generate_site_whitelist( string $site_url, $site_data = null ) 'ip' ); + // If no whitelists exist, remove all whitelist files for this site and its domains + if ( empty( $whitelists ) ) { + foreach ( $domains as $domain ) { + $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; + $this->fs->remove( $domain_whitelist_file ); + } + return; + } + // Generate whitelist files for all collected domains foreach ( $domains as $domain ) { $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; From 569ed56c2411279104946b056dd2b41c4027e52e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 8 Jan 2026 11:44:54 +0530 Subject: [PATCH 06/30] Clean up wildcard files upfront to handle site type changes Always remove _wildcard.site_url htpasswd and whitelist files at the start of regeneration. This ensures orphan wildcard files are cleaned up when a site changes from subdomain multisite to regular site type. Signed-off-by: Riddhesh Sanghvi --- src/Auth_Command.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 61a77a0..6e6336e 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -300,6 +300,10 @@ private function generate_global_auth_files() { * @throws Exception */ private function generate_site_auth_files( string $site_url, $site_data = null ) { + // Always clean up wildcard file first (handles site type changes from subdom to regular) + $wildcard_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/_wildcard.' . $site_url; + $this->fs->remove( $wildcard_file ); + // Collect all domains to generate htpasswd files for $domains = [ $site_url ]; @@ -401,6 +405,10 @@ private function generate_global_whitelist() { * @throws Exception */ private function generate_site_whitelist( string $site_url, $site_data = null ) { + // Always clean up wildcard file first (handles site type changes from subdom to regular) + $wildcard_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/_wildcard.' . $site_url . '_acl'; + $this->fs->remove( $wildcard_file ); + // Collect all domains to generate whitelist files for $domains = [ $site_url ]; From 6bd97cc70f95240e39a3a4bfcae2f16bebb8eff2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:24:56 +0000 Subject: [PATCH 07/30] refactor(auth): share site domain collection and file generation Auth and whitelist generation duplicated the same domain collection logic. Move it into get_site_auth_domains() and move site-level htpasswd/ACL generation into auth-utils so other callers (hooks) can reuse it. No behaviour change. --- src/Auth_Command.php | 192 +++---------------------------------------- src/auth-utils.php | 129 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 181 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 6e6336e..9587ced 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -17,6 +17,8 @@ use EE\Model\Auth; use EE\Model\Whitelist; use Symfony\Component\Filesystem\Filesystem; +use function EE\Auth\Utils\generate_site_auth_files; +use function EE\Auth\Utils\generate_site_whitelist; use function EE\Auth\Utils\verify_htpasswd_is_present; use function EE\Site\Utils\auto_site_name; use function EE\Site\Utils\get_site_info; @@ -161,7 +163,7 @@ private function create_auth( array $assoc_args, bool $global, string $site_url if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url, $this->site_data ); + generate_site_auth_files( $site_url, $this->site_data ); } EE::log( 'Reloading global reverse proxy.' ); @@ -200,7 +202,7 @@ private function create_whitelist( string $site_url, string $ips ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url, $this->site_data ); + generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); @@ -286,85 +288,7 @@ private function generate_global_auth_files() { // Fetch site data to get app_sub_type and alias_domains $site_info = \EE\Model\Site::where( 'site_url', $site ); $site_data = ! empty( $site_info ) ? $site_info[0] : null; - $this->generate_site_auth_files( $site, $site_data ); - } - } - } - - /** - * Generates auth files for a site - * - * @param string $site_url URL of site - * @param object $site_data Optional site data object containing app_sub_type and alias_domains - * - * @throws Exception - */ - private function generate_site_auth_files( string $site_url, $site_data = null ) { - // Always clean up wildcard file first (handles site type changes from subdom to regular) - $wildcard_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/_wildcard.' . $site_url; - $this->fs->remove( $wildcard_file ); - - // Collect all domains to generate htpasswd files for - $domains = [ $site_url ]; - - // For subdomain multisites, add wildcard file - if ( $site_data && ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { - $domains[] = '_wildcard.' . $site_url; - } - - // Add alias domains (excluding main site_url) - if ( $site_data && ! empty( $site_data->alias_domains ) ) { - $alias_list = array_map( 'trim', explode( ',', $site_data->alias_domains ) ); - foreach ( $alias_list as $alias ) { - if ( empty( $alias ) || $alias === $site_url ) { - continue; - } - // Skip *.site_url as it transforms to _wildcard.site_url (already added for subdomain multisite) - if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type && '*.' . $site_url === $alias ) { - continue; - } - // Replace *.domain with _wildcard.domain - if ( 0 === strpos( $alias, '*.' ) ) { - $domains[] = '_wildcard.' . substr( $alias, 2 ); - } else { - $domains[] = $alias; - // For subdomain multisites, also add wildcard for non-wildcard alias domains - if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { - $domains[] = '_wildcard.' . $alias; - } - } - } - } - - $auths = array_merge( - Auth::get_global_auths(), - Auth::where( 'site_url', $site_url ) - ); - - // Remove duplicates (e.g., *.example.com alias + subdomain multisite both create _wildcard.example.com) - $domains = array_unique( $domains ); - - // If no auths exist, remove all htpasswd files for this site and its domains - if ( empty( $auths ) ) { - foreach ( $domains as $domain ) { - $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; - $this->fs->remove( $domain_auth_file ); - } - return; - } - - // Generate htpasswd files for all collected domains - foreach ( $domains as $domain ) { - $domain_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain; - $this->fs->remove( $domain_auth_file ); - - foreach ( $auths as $key => $auth ) { - $flags = 'b'; - - if ( $key === 0 ) { - $flags = 'bc'; - } - EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $domain, $auth->username, $auth->password ) ); + generate_site_auth_files( $site, $site_data ); } } } @@ -375,7 +299,7 @@ private function generate_site_auth_files( string $site_url, $site_data = null ) * @throws Exception */ private function generate_global_whitelist() { - $this->generate_site_whitelist( 'default' ); + generate_site_whitelist( 'default' ); $sites = array_unique( array_column( @@ -391,103 +315,9 @@ private function generate_global_whitelist() { // Fetch site data to get app_sub_type and alias_domains $site_info = \EE\Model\Site::where( 'site_url', $site ); $site_data = ! empty( $site_info ) ? $site_info[0] : null; - $this->generate_site_whitelist( $site, $site_data ); - } - - } - - /** - * Generates site whitelist files - * - * @param string $site_url - * @param object $site_data Optional site data object containing app_sub_type and alias_domains - * - * @throws Exception - */ - private function generate_site_whitelist( string $site_url, $site_data = null ) { - // Always clean up wildcard file first (handles site type changes from subdom to regular) - $wildcard_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/_wildcard.' . $site_url . '_acl'; - $this->fs->remove( $wildcard_file ); - - // Collect all domains to generate whitelist files for - $domains = [ $site_url ]; - - // For subdomain multisites, add wildcard file - if ( $site_data && ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { - $domains[] = '_wildcard.' . $site_url; - } - - // Add alias domains (excluding main site_url) - if ( $site_data && ! empty( $site_data->alias_domains ) ) { - $alias_list = array_map( 'trim', explode( ',', $site_data->alias_domains ) ); - foreach ( $alias_list as $alias ) { - if ( empty( $alias ) || $alias === $site_url ) { - continue; - } - // Skip *.site_url as it transforms to _wildcard.site_url (already added for subdomain multisite) - if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type && '*.' . $site_url === $alias ) { - continue; - } - // Replace *.domain with _wildcard.domain - if ( 0 === strpos( $alias, '*.' ) ) { - $domains[] = '_wildcard.' . substr( $alias, 2 ); - } else { - $domains[] = $alias; - // For subdomain multisites, also add wildcard for non-wildcard alias domains - if ( ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type ) { - $domains[] = '_wildcard.' . $alias; - } - } - } - } - - // Remove duplicates - $domains = array_unique( $domains ); - - $whitelists = array_column( - 'default' === $site_url ? Whitelist::get_global_ips() : - array_merge( - Whitelist::get_global_ips(), - Whitelist::where( 'site_url', $site_url ) - ), - 'ip' - ); - - // If no whitelists exist, remove all whitelist files for this site and its domains - if ( empty( $whitelists ) ) { - foreach ( $domains as $domain ) { - $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; - $this->fs->remove( $domain_whitelist_file ); - } - return; - } - - // Generate whitelist files for all collected domains - foreach ( $domains as $domain ) { - $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; - $this->fs->remove( $domain_whitelist_file ); - $this->put_ips_to_file( $domain_whitelist_file, $whitelists ); + generate_site_whitelist( $site, $site_data ); } - } - /** - * Function to put list of ip's into a file. - * - * @param string $file Path of file to write ip's in. - * @param array $ips List of ip's. - */ - private function put_ips_to_file( string $file, array $ips ) { - - if ( empty( $ips ) ) { - return; - } - - $file_content = 'satisfy any;' . PHP_EOL; - foreach ( $ips as $ip ) { - $file_content .= "allow $ip;" . PHP_EOL; - } - $file_content .= 'deny all;'; - $this->fs->dumpFile( $file, $file_content ); } /** @@ -562,7 +392,7 @@ private function update_auth( array $assoc_args, string $site_url ) { if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url, $this->site_data ); + generate_site_auth_files( $site_url, $this->site_data ); } EE::log( 'Reloading global reverse proxy.' ); @@ -608,7 +438,7 @@ private function update_whitelist( string $site_url, string $ips ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url, $this->site_data ); + generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); @@ -703,7 +533,7 @@ public function delete( $args, $assoc_args ) { if ( 'default' === $site_url ) { $this->generate_global_auth_files(); } else { - $this->generate_site_auth_files( $site_url, $this->site_data ); + generate_site_auth_files( $site_url, $this->site_data ); } if ( $user ) { @@ -755,7 +585,7 @@ public function delete( $args, $assoc_args ) { if ( 'default' === $site_url ) { $this->generate_global_whitelist(); } else { - $this->generate_site_whitelist( $site_url, $this->site_data ); + generate_site_whitelist( $site_url, $this->site_data ); } reload_global_nginx_proxy(); diff --git a/src/auth-utils.php b/src/auth-utils.php index dde463e..3650186 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -6,6 +6,8 @@ use EE; use EE\Model\Auth; use EE\Model\Option; +use EE\Model\Whitelist; +use Symfony\Component\Filesystem\Filesystem; use function EE\Service\Utils\ensure_global_network_initialized; use function EE\Utils\get_config_value; @@ -62,3 +64,130 @@ function verify_htpasswd_is_present() { } EE::error( sprintf( 'Could not find apache2-utils installed in %s.', EE_PROXY_TYPE ) ); } + +/** + * Maps a domain to its htpasswd/ACL file name: `*.example.com` becomes `_wildcard.example.com`. + * + * @param string $domain Domain name. + * + * @return string + */ +function get_auth_domain( string $domain ): string { + + return 0 === strpos( $domain, '*.' ) ? '_wildcard.' . substr( $domain, 2 ) : $domain; +} + +/** + * Collects the htpasswd/ACL file names of a site: the site itself, `_wildcard.` for subdomain multisites, and its alias domains. + * + * @param string $site_url URL of site. + * @param \EE\Model\Site|null $site_data Site model. + * + * @return array + */ +function get_site_auth_domains( string $site_url, $site_data ): array { + + $is_subdom = ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type; + $domains = [ $site_url ]; + + if ( $is_subdom ) { + $domains[] = '_wildcard.' . $site_url; + } + + if ( ! empty( $site_data->alias_domains ) ) { + foreach ( array_map( 'trim', explode( ',', $site_data->alias_domains ) ) as $alias ) { + if ( '' === $alias ) { + continue; + } + $domains[] = get_auth_domain( $alias ); + if ( $is_subdom && 0 !== strpos( $alias, '*.' ) ) { + $domains[] = '_wildcard.' . $alias; + } + } + } + + return array_values( array_unique( $domains ) ); +} + +/** + * Generates auth files for a site. + * + * @param string $site_url URL of site. + * @param \EE\Model\Site|null $site_data Site model. + * + * @throws \Exception + */ +function generate_site_auth_files( string $site_url, $site_data = null ) { + + $fs = new Filesystem(); + + // Always clean up wildcard file first (handles site type changes from subdom to regular) + $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/_wildcard.' . $site_url ); + + $domains = get_site_auth_domains( $site_url, $site_data ); + $auths = array_merge( + Auth::get_global_auths(), + Auth::where( 'site_url', $site_url ) + ); + + foreach ( $domains as $domain ) { + $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain ); + + foreach ( $auths as $key => $auth ) { + $flags = 0 === $key ? 'bc' : 'b'; + EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $domain, $auth->username, $auth->password ) ); + } + } +} + +/** + * Generates whitelist files for a site. + * + * @param string $site_url URL of site, `default` for global. + * @param \EE\Model\Site|null $site_data Site model. + * + * @throws \Exception + */ +function generate_site_whitelist( string $site_url, $site_data = null ) { + + $fs = new Filesystem(); + + // Always clean up wildcard file first (handles site type changes from subdom to regular) + $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/_wildcard.' . $site_url . '_acl' ); + + $domains = get_site_auth_domains( $site_url, $site_data ); + $whitelists = array_column( + 'default' === $site_url ? Whitelist::get_global_ips() : + array_merge( + Whitelist::get_global_ips(), + Whitelist::where( 'site_url', $site_url ) + ), + 'ip' + ); + + foreach ( $domains as $domain ) { + $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; + $fs->remove( $domain_whitelist_file ); + put_ips_to_file( $domain_whitelist_file, $whitelists ); + } +} + +/** + * Function to put list of ip's into a file. + * + * @param string $file Path of file to write ip's in. + * @param array $ips List of ip's. + */ +function put_ips_to_file( string $file, array $ips ) { + + if ( empty( $ips ) ) { + return; + } + + $file_content = 'satisfy any;' . PHP_EOL; + foreach ( $ips as $ip ) { + $file_content .= "allow $ip;" . PHP_EOL; + } + $file_content .= 'deny all;'; + ( new Filesystem() )->dumpFile( $file, $file_content ); +} From f2b9b11a03a0649b946fa85ba9d1efb2528285d5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:25:04 +0000 Subject: [PATCH 08/30] fix(auth): don't generate _wildcard files for plain alias domains A plain alias of a subdomain multisite is never served as *., so _wildcard. protected nothing and could leak onto unrelated hosts. --- src/auth-utils.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 3650186..ff22f24 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -100,9 +100,6 @@ function get_site_auth_domains( string $site_url, $site_data ): array { continue; } $domains[] = get_auth_domain( $alias ); - if ( $is_subdom && 0 !== strpos( $alias, '*.' ) ) { - $domains[] = '_wildcard.' . $alias; - } } } From 5a3cf0eb6409c379e8368042bdb280931dd393ec Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:25:04 +0000 Subject: [PATCH 09/30] fix(auth): don't remove _wildcard files the site doesn't own The unconditional removal of _wildcard. could delete a file generated for another site's *. alias. A site's type can't change from subdomain multisite to a regular site, so the cleanup isn't needed. --- src/auth-utils.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index ff22f24..2e4ff1e 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -118,9 +118,6 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { $fs = new Filesystem(); - // Always clean up wildcard file first (handles site type changes from subdom to regular) - $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/_wildcard.' . $site_url ); - $domains = get_site_auth_domains( $site_url, $site_data ); $auths = array_merge( Auth::get_global_auths(), @@ -149,9 +146,6 @@ function generate_site_whitelist( string $site_url, $site_data = null ) { $fs = new Filesystem(); - // Always clean up wildcard file first (handles site type changes from subdom to regular) - $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/_wildcard.' . $site_url . '_acl' ); - $domains = get_site_auth_domains( $site_url, $site_data ); $whitelists = array_column( 'default' === $site_url ? Whitelist::get_global_ips() : From 5f12c49b26fbc0566d174c5f99660566ce197ea5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:25:19 +0000 Subject: [PATCH 10/30] fix(auth): remove site files when the site has no own entries Whether to remove a site's htpasswd/ACL files was decided from the merged global and site entries. Since a global whitelist always exists (and global auth may too), site files were never removed and kept overriding the global default files. Decide from the site's own entries instead. --- src/auth-utils.php | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 2e4ff1e..62e9753 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -118,15 +118,21 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { $fs = new Filesystem(); - $domains = get_site_auth_domains( $site_url, $site_data ); - $auths = array_merge( - Auth::get_global_auths(), - Auth::where( 'site_url', $site_url ) - ); + $domains = get_site_auth_domains( $site_url, $site_data ); + $site_auths = Auth::where( 'site_url', $site_url ); foreach ( $domains as $domain ) { $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain ); + } + + // Without site entries the proxy falls back to the global `default` file. + if ( empty( $site_auths ) ) { + return; + } + $auths = array_merge( Auth::get_global_auths(), $site_auths ); + + foreach ( $domains as $domain ) { foreach ( $auths as $key => $auth ) { $flags = 0 === $key ? 'bc' : 'b'; EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $domain, $auth->username, $auth->password ) ); @@ -146,20 +152,25 @@ function generate_site_whitelist( string $site_url, $site_data = null ) { $fs = new Filesystem(); - $domains = get_site_auth_domains( $site_url, $site_data ); + $domains = get_site_auth_domains( $site_url, $site_data ); + $site_ips = Whitelist::where( 'site_url', $site_url ); + + foreach ( $domains as $domain ) { + $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl' ); + } + + // Without site entries the proxy falls back to `default_acl`. + if ( empty( $site_ips ) ) { + return; + } + $whitelists = array_column( - 'default' === $site_url ? Whitelist::get_global_ips() : - array_merge( - Whitelist::get_global_ips(), - Whitelist::where( 'site_url', $site_url ) - ), + 'default' === $site_url ? $site_ips : array_merge( Whitelist::get_global_ips(), $site_ips ), 'ip' ); foreach ( $domains as $domain ) { - $domain_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl'; - $fs->remove( $domain_whitelist_file ); - put_ips_to_file( $domain_whitelist_file, $whitelists ); + put_ips_to_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl', $whitelists ); } } From dfa56f0aa25f7ee01649aa431db7abe8a80c1ace Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:25:44 +0000 Subject: [PATCH 11/30] fix(auth): escape arguments of the htpasswd command The htpasswd command was built by interpolating the file name, username and password into a shell command. Build it in one helper that shell-escapes every argument and use it for all htpasswd writes. --- src/Auth_Command.php | 13 +++---------- src/auth-utils.php | 44 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 9587ced..f445515 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -20,6 +20,7 @@ use function EE\Auth\Utils\generate_site_auth_files; use function EE\Auth\Utils\generate_site_whitelist; use function EE\Auth\Utils\verify_htpasswd_is_present; +use function EE\Auth\Utils\write_htpasswd_file; use function EE\Site\Utils\auto_site_name; use function EE\Site\Utils\get_site_info; use function EE\Site\Utils\reload_global_nginx_proxy; @@ -257,7 +258,7 @@ private function generate_global_auth_files() { $global_admin_tools_auth = Auth::get_global_admin_tools_auth(); if ( ! empty( $global_admin_tools_auth ) ) { - EE::exec( sprintf( 'docker exec %s htpasswd -bc /etc/nginx/htpasswd/default_admin_tools %s %s', EE_PROXY_TYPE, $global_admin_tools_auth->username, $global_admin_tools_auth->password ) ); + write_htpasswd_file( 'default_admin_tools', $global_admin_tools_auth ); } else { $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default_admin_tools' ); $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default' ); @@ -266,15 +267,7 @@ private function generate_global_auth_files() { if ( empty( $auths ) ) { $this->regen_admin_tools_auth(); } else { - foreach ( $auths as $key => $auth ) { - $flags = 'b'; - - if ( 0 === $key ) { - $flags = 'bc'; - } - - EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/default %s %s', EE_PROXY_TYPE, $flags, $auth->username, $auth->password ) ); - } + write_htpasswd_file( 'default', $auths ); } $sites = array_unique( diff --git a/src/auth-utils.php b/src/auth-utils.php index 62e9753..8f6b8ed 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -40,7 +40,7 @@ function init_global_admin_tools_auth( $display_log = true ) { Auth::create( $auth_data ); - EE::exec( sprintf( 'docker exec %s htpasswd -bc /etc/nginx/htpasswd/default_admin_tools %s %s', EE_PROXY_TYPE, $auth_data['username'], $auth_data['password'] ) ); + EE::exec( htpasswd_command( 'bc', 'default_admin_tools', $auth_data['username'], $auth_data['password'] ) ); if ( $display_log ) { EE::success( sprintf( 'Global admin-tools auth added. Use `ee auth list global` to view credentials.' ) ); @@ -133,13 +133,47 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { $auths = array_merge( Auth::get_global_auths(), $site_auths ); foreach ( $domains as $domain ) { - foreach ( $auths as $key => $auth ) { - $flags = 0 === $key ? 'bc' : 'b'; - EE::exec( sprintf( 'docker exec %s htpasswd -%s /etc/nginx/htpasswd/%s %s %s', EE_PROXY_TYPE, $flags, $domain, $auth->username, $auth->password ) ); - } + write_htpasswd_file( $domain, $auths ); + } +} + +/** + * (Re)creates an htpasswd file in the proxy container with the given auth entries. + * + * @param string $name File name inside the htpasswd directory. + * @param array $auths Auth models. + */ +function write_htpasswd_file( string $name, array $auths ) { + + $flags = 'bc'; + foreach ( $auths as $auth ) { + EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ) ); + $flags = 'b'; } } +/** + * Builds the `htpasswd` command run in the proxy container, with shell-escaped arguments. + * + * @param string $flags htpasswd flags without the leading dash. + * @param string $name File name inside the htpasswd directory. + * @param string $username Username. + * @param string $password Password. + * + * @return string + */ +function htpasswd_command( string $flags, string $name, string $username, string $password ): string { + + return sprintf( + 'docker exec %s htpasswd -%s %s %s %s', + EE_PROXY_TYPE, + $flags, + escapeshellarg( '/etc/nginx/htpasswd/' . $name ), + escapeshellarg( $username ), + escapeshellarg( $password ) + ); +} + /** * Generates whitelist files for a site. * From 852c10b3ab247a44872f5416ec47ccdacf8050ce Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:26:29 +0000 Subject: [PATCH 12/30] fix(auth): remove all of a site's auth and ACL files on delete The site_cleanup hook in src/helper/hooks.php was never loaded because the file wasn't in the autoload files, so site deletion only removed htpasswd/ (via site-command) and left _wildcard., alias htpasswd files and every _acl file behind. These orphans then protected later, unrelated sites with the same names. Load the hooks file and remove the htpasswd and ACL files of every domain the site uses, whether or not it still has auth entries. --- composer.json | 3 ++- src/auth-utils.php | 18 ++++++++++++++++++ src/helper/hooks.php | 36 +++++++++++++----------------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index 273b70a..f50ecf0 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ }, "files": [ "auth-command.php", - "src/auth-utils.php" + "src/auth-utils.php", + "src/helper/hooks.php" ] }, "extra": { diff --git a/src/auth-utils.php b/src/auth-utils.php index 8f6b8ed..f61e1f6 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -106,6 +106,24 @@ function get_site_auth_domains( string $site_url, $site_data ): array { return array_values( array_unique( $domains ) ); } +/** + * Removes the htpasswd and ACL files of the given domains. + * + * @param array $domains File names as returned by get_site_auth_domains(). + */ +function remove_auth_files( array $domains ) { + + $fs = new Filesystem(); + foreach ( $domains as $domain ) { + $fs->remove( + [ + EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain, + EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl', + ] + ); + } +} + /** * Generates auth files for a site. * diff --git a/src/helper/hooks.php b/src/helper/hooks.php index fe494f5..c4792bb 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -7,43 +7,33 @@ use EE\Model\Auth; use EE\Model\Site; use EE\Model\Whitelist; -use Symfony\Component\Filesystem\Filesystem; +use function EE\Auth\Utils\get_site_auth_domains; +use function EE\Auth\Utils\remove_auth_files; /** - * Hook to cleanup auth entries and whitelisted ips if any. + * Hook to cleanup auth entries, whitelisted ips and their files if any. * * @param string $site_url The site to be cleaned up. */ function cleanup_auth_and_whitelist( $site_url ) { - if ( ! Site::find( $site_url ) ) { + $site = Site::find( $site_url ); + + if ( ! $site ) { return; } - $fs = new Filesystem(); - - $auths = Auth::where( [ 'site_url' => $site_url ] ); - - if ( ! empty( $auths ) ) { - foreach ( $auths as $auth ) { - $auth->delete(); - } - - $site_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $site_url; - $fs->remove( $site_auth_file ); + foreach ( Auth::where( [ 'site_url' => $site_url ] ) as $auth ) { + $auth->delete(); } - $whitelists = Whitelist::where( [ 'site_url' => $site_url ] ); - - if ( ! empty( $whitelists ) ) { - foreach ( $whitelists as $whitelist ) { - $whitelist->delete(); - } - - $site_whitelist_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $site_url . '_acl'; - $fs->remove( $site_whitelist_file ); + foreach ( Whitelist::where( [ 'site_url' => $site_url ] ) as $whitelist ) { + $whitelist->delete(); } + // Files may exist without site entries (e.g. left by older versions), so always remove them. + remove_auth_files( get_site_auth_domains( $site_url, $site ) ); + \EE\Site\Utils\reload_global_nginx_proxy(); } From e0778d3ae60657f157d81cac64b7446229c6d77d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 05:27:05 +0000 Subject: [PATCH 13/30] feat(auth): sync auth files when alias domains change Adding an alias with `ee site update --add-alias-domains` didn't generate its htpasswd/ACL files, so the new alias was served without the site's auth until an `ee auth` command was re-run. Removing an alias left its files behind. Listen to site-command's site_alias_domains_updated hook: remove the files of removed aliases, regenerate the site's htpasswd and whitelist files when the site has its own entries, and reload the proxy. Requires a site-command release that fires the hook. --- src/helper/hooks.php | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/helper/hooks.php b/src/helper/hooks.php index c4792bb..5cade57 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -7,6 +7,9 @@ use EE\Model\Auth; use EE\Model\Site; use EE\Model\Whitelist; +use function EE\Auth\Utils\generate_site_auth_files; +use function EE\Auth\Utils\generate_site_whitelist; +use function EE\Auth\Utils\get_auth_domain; use function EE\Auth\Utils\get_site_auth_domains; use function EE\Auth\Utils\remove_auth_files; @@ -37,4 +40,50 @@ function cleanup_auth_and_whitelist( $site_url ) { \EE\Site\Utils\reload_global_nginx_proxy(); } +/** + * Hook to sync auth and whitelist files with the alias domains of a site after they change. + * + * @param string $site_url The site whose alias domains changed. + * @param array $added_domains Alias domains that were added. + * @param array $removed_domains Alias domains that were removed. + */ +function update_auth_on_alias_domains_change( $site_url, $added_domains = [], $removed_domains = [] ) { + + $site = Site::find( $site_url ); + + if ( ! $site ) { + return; + } + + $reload = false; + $removed = []; + + foreach ( (array) $removed_domains as $domain ) { + $removed[] = get_auth_domain( trim( $domain ) ); + } + + // Keep files the site still uses, e.g. _wildcard. of a subdomain multisite. + $removed = array_diff( $removed, get_site_auth_domains( $site_url, $site ) ); + + if ( ! empty( $removed ) ) { + remove_auth_files( $removed ); + $reload = true; + } + + if ( ! empty( Auth::where( 'site_url', $site_url ) ) ) { + generate_site_auth_files( $site_url, $site ); + $reload = true; + } + + if ( Whitelist::has_ips( $site_url ) ) { + generate_site_whitelist( $site_url, $site ); + $reload = true; + } + + if ( $reload ) { + \EE\Site\Utils\reload_global_nginx_proxy(); + } +} + EE::add_hook( 'site_cleanup', 'cleanup_auth_and_whitelist' ); +EE::add_hook( 'site_alias_domains_updated', 'update_auth_on_alias_domains_change' ); From fa6c6b1d35439ec64674717f26155c7a1217714b Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:06:04 +0000 Subject: [PATCH 14/30] fix(auth): skip unsafe alias names and guard auth file removal Alias names now have to be a plain hostname or `*.hostname` before they are turned into htpasswd/ACL file names; empty names, `default`, `default_admin_tools` and anything path-like are skipped. An empty alias used to make remove_auth_files() pass the htpasswd directory itself to Filesystem::remove(), wiping every auth file, and `default` deleted the global auth and ACL files. Removal now only deletes a regular file directly inside the htpasswd or vhost.d directory, never touches the global files, and reports whether anything was removed. --- src/auth-utils.php | 107 +++++++++++++++++++++++++++++++++++-------- src/helper/hooks.php | 14 ++---- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index f61e1f6..6536d8c 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -77,6 +77,43 @@ function get_auth_domain( string $domain ): string { return 0 === strpos( $domain, '*.' ) ? '_wildcard.' . substr( $domain, 2 ) : $domain; } +/** + * Checks that an alias domain is a plain hostname or `*.hostname`, so it is safe to use as an htpasswd/ACL file name. + * + * @param string $domain Alias domain. + * + * @return bool + */ +function is_valid_alias_domain( string $domain ): bool { + + // These would map onto the global auth and ACL files. + if ( in_array( $domain, [ 'default', 'default_admin_tools' ], true ) ) { + return false; + } + + return 1 === preg_match( '/^(\*\.)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/D', $domain ); +} + +/** + * Maps alias domains to their htpasswd/ACL file names, skipping unsafe ones. + * + * @param array $aliases Alias domains. + * + * @return array + */ +function get_alias_auth_domains( array $aliases ): array { + + $domains = []; + foreach ( $aliases as $alias ) { + $alias = trim( (string) $alias ); + if ( is_valid_alias_domain( $alias ) ) { + $domains[] = get_auth_domain( $alias ); + } + } + + return $domains; +} + /** * Collects the htpasswd/ACL file names of a site: the site itself, `_wildcard.` for subdomain multisites, and its alias domains. * @@ -95,33 +132,67 @@ function get_site_auth_domains( string $site_url, $site_data ): array { } if ( ! empty( $site_data->alias_domains ) ) { - foreach ( array_map( 'trim', explode( ',', $site_data->alias_domains ) ) as $alias ) { - if ( '' === $alias ) { - continue; - } - $domains[] = get_auth_domain( $alias ); - } + $domains = array_merge( $domains, get_alias_auth_domains( explode( ',', $site_data->alias_domains ) ) ); } return array_values( array_unique( $domains ) ); } +/** + * Checks that a name refers to an entry directly inside a directory, not the directory itself or anything outside it. + * + * @param string $name File name. + * + * @return bool + */ +function is_proxy_file_name( string $name ): bool { + + return '' !== $name && '.' !== $name && '..' !== $name && false === strpbrk( $name, "/\\\0" ); +} + +/** + * Removes a regular file directly inside a proxy directory. + * + * @param string $dir Directory path. + * @param string $name File name. + * + * @return bool Whether the file was removed. + */ +function remove_proxy_file( string $dir, string $name ): bool { + + $file = $dir . '/' . $name; + + // An empty name would make Filesystem::remove() wipe the whole directory. + if ( ! is_proxy_file_name( $name ) || ! is_file( $file ) ) { + return false; + } + + ( new Filesystem() )->remove( $file ); + + return true; +} + /** * Removes the htpasswd and ACL files of the given domains. * * @param array $domains File names as returned by get_site_auth_domains(). + * + * @return bool Whether any file was removed. */ -function remove_auth_files( array $domains ) { +function remove_auth_files( array $domains ): bool { - $fs = new Filesystem(); + $removed = false; foreach ( $domains as $domain ) { - $fs->remove( - [ - EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain, - EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl', - ] - ); + $domain = (string) $domain; + // The global files never belong to a site. + if ( in_array( $domain, [ '', 'default', 'default_admin_tools' ], true ) ) { + continue; + } + $removed = remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', $domain ) || $removed; + $removed = remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d', $domain . '_acl' ) || $removed; } + + return $removed; } /** @@ -134,13 +205,11 @@ function remove_auth_files( array $domains ) { */ function generate_site_auth_files( string $site_url, $site_data = null ) { - $fs = new Filesystem(); - $domains = get_site_auth_domains( $site_url, $site_data ); $site_auths = Auth::where( 'site_url', $site_url ); foreach ( $domains as $domain ) { - $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $domain ); + remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', $domain ); } // Without site entries the proxy falls back to the global `default` file. @@ -202,13 +271,11 @@ function htpasswd_command( string $flags, string $name, string $username, string */ function generate_site_whitelist( string $site_url, $site_data = null ) { - $fs = new Filesystem(); - $domains = get_site_auth_domains( $site_url, $site_data ); $site_ips = Whitelist::where( 'site_url', $site_url ); foreach ( $domains as $domain ) { - $fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl' ); + remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d', $domain . '_acl' ); } // Without site entries the proxy falls back to `default_acl`. diff --git a/src/helper/hooks.php b/src/helper/hooks.php index 5cade57..2f7e6b4 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -9,7 +9,7 @@ use EE\Model\Whitelist; use function EE\Auth\Utils\generate_site_auth_files; use function EE\Auth\Utils\generate_site_whitelist; -use function EE\Auth\Utils\get_auth_domain; +use function EE\Auth\Utils\get_alias_auth_domains; use function EE\Auth\Utils\get_site_auth_domains; use function EE\Auth\Utils\remove_auth_files; @@ -55,18 +55,12 @@ function update_auth_on_alias_domains_change( $site_url, $added_domains = [], $r return; } - $reload = false; - $removed = []; - - foreach ( (array) $removed_domains as $domain ) { - $removed[] = get_auth_domain( trim( $domain ) ); - } + $reload = false; // Keep files the site still uses, e.g. _wildcard. of a subdomain multisite. - $removed = array_diff( $removed, get_site_auth_domains( $site_url, $site ) ); + $removed = array_diff( get_alias_auth_domains( (array) $removed_domains ), get_site_auth_domains( $site_url, $site ) ); - if ( ! empty( $removed ) ) { - remove_auth_files( $removed ); + if ( remove_auth_files( $removed ) ) { $reload = true; } From a9572ffabf267b82f71ff00f30300a4c15411ed0 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:06:20 +0000 Subject: [PATCH 15/30] perf(auth): write a site's htpasswd file once and copy it to its other domains generate_site_auth_files() ran one `docker exec htpasswd` per user and per domain. It now writes the file of the site itself in the proxy container and copies it on the host to the `_wildcard.` and alias files, keeping the source's mode so the result doesn't depend on the umask. If writing fails the existing files are left in place with a warning instead of being removed upfront, so a failed exec can no longer leave the site unprotected. --- src/auth-utils.php | 51 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 6536d8c..36da1e2 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -172,6 +172,28 @@ function remove_proxy_file( string $dir, string $name ): bool { return true; } +/** + * Copies a file inside a proxy directory to other names in the same directory. + * + * @param string $dir Directory path. + * @param string $source Source file name. + * @param array $targets Target file names. + */ +function copy_proxy_file( string $dir, string $source, array $targets ) { + + $fs = new Filesystem(); + $mode = fileperms( $dir . '/' . $source ) & 0777; + + foreach ( $targets as $target ) { + if ( ! is_proxy_file_name( $target ) || $target === $source ) { + continue; + } + $fs->copy( $dir . '/' . $source, $dir . '/' . $target, true ); + // Don't depend on the umask: nginx workers read these files. + $fs->chmod( $dir . '/' . $target, $mode ); + } +} + /** * Removes the htpasswd and ACL files of the given domains. * @@ -205,23 +227,28 @@ function remove_auth_files( array $domains ): bool { */ function generate_site_auth_files( string $site_url, $site_data = null ) { + $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; $domains = get_site_auth_domains( $site_url, $site_data ); $site_auths = Auth::where( 'site_url', $site_url ); - foreach ( $domains as $domain ) { - remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', $domain ); - } - // Without site entries the proxy falls back to the global `default` file. if ( empty( $site_auths ) ) { + foreach ( $domains as $domain ) { + remove_proxy_file( $dir, $domain ); + } + return; } - $auths = array_merge( Auth::get_global_auths(), $site_auths ); + $source = array_shift( $domains ); - foreach ( $domains as $domain ) { - write_htpasswd_file( $domain, $auths ); + if ( ! write_htpasswd_file( $source, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { + EE::warning( sprintf( 'Could not write the htpasswd file of %s.', $site_url ) ); + + return; } + + copy_proxy_file( $dir, $source, $domains ); } /** @@ -229,14 +256,20 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { * * @param string $name File name inside the htpasswd directory. * @param array $auths Auth models. + * + * @return bool Whether all entries were written. */ -function write_htpasswd_file( string $name, array $auths ) { +function write_htpasswd_file( string $name, array $auths ): bool { $flags = 'bc'; foreach ( $auths as $auth ) { - EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ) ); + if ( ! EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ) ) ) { + return false; + } $flags = 'b'; } + + return true; } /** From 3f59e38425e5db243dcabf91343502dedf9f1bd2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:06:34 +0000 Subject: [PATCH 16/30] fix(auth): keep htpasswd credentials out of ee.log EE::exec() logs every command in debug mode, so each `htpasswd -b` call wrote the plain password and username to ee.log. write_htpasswd_file() now passes them as obfuscated values, and the admin-tools auth is written through it too. --- src/auth-utils.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 36da1e2..829e865 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -40,7 +40,7 @@ function init_global_admin_tools_auth( $display_log = true ) { Auth::create( $auth_data ); - EE::exec( htpasswd_command( 'bc', 'default_admin_tools', $auth_data['username'], $auth_data['password'] ) ); + write_htpasswd_file( 'default_admin_tools', [ (object) $auth_data ] ); if ( $display_log ) { EE::success( sprintf( 'Global admin-tools auth added. Use `ee auth list global` to view credentials.' ) ); @@ -255,7 +255,7 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { * (Re)creates an htpasswd file in the proxy container with the given auth entries. * * @param string $name File name inside the htpasswd directory. - * @param array $auths Auth models. + * @param array $auths Auth models, or objects with `username` and `password`. * * @return bool Whether all entries were written. */ @@ -263,7 +263,10 @@ function write_htpasswd_file( string $name, array $auths ): bool { $flags = 'bc'; foreach ( $auths as $auth ) { - if ( ! EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ) ) ) { + // Keep the credentials out of ee.log. + $obfuscate = [ escapeshellarg( $auth->password ), escapeshellarg( $auth->username ) ]; + + if ( ! EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ), false, false, $obfuscate ) ) { return false; } $flags = 'b'; From 88a1fb9df6b94878e1e0f254c187494951d5d355 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:09:02 +0000 Subject: [PATCH 17/30] fix(auth): protect alias domains before the proxy serves them The htpasswd and ACL files of new alias domains were written from `site_alias_domains_updated`, after docker-compose had already brought up the new VIRTUAL_HOST, so a protected site answered 200 without credentials on the new alias for a few seconds. The new `site_alias_domains_before_update` hook now copies the site's htpasswd file and writes its ACL file for the added aliases before the containers are recreated, but only for the files the site has own entries for. If the update is aborted, `site_alias_domains_update_failed` removes those files again, keeping every file the site still uses. The post-update hook still removes the files of deleted aliases once they are no longer served, and regenerates the files from the saved site only when aliases were added. --- src/auth-utils.php | 86 ++++++++++++++++++++++++++++++++++++-------- src/helper/hooks.php | 54 ++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 829e865..982745a 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -251,6 +251,51 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { copy_proxy_file( $dir, $source, $domains ); } +/** + * Writes a site's current htpasswd and ACL files for extra file names, e.g. of alias domains that are about to be served. + * + * Only writes the files the site has own entries for, so other names keep falling back to the global files. + * + * @param string $site_url URL of site. + * @param array $names File names as returned by get_auth_domain(). + * + * @return bool Whether any file was written. + */ +function add_site_auth_files( string $site_url, array $names ): bool { + + $names = array_diff( array_unique( $names ), [ $site_url, 'default', 'default_admin_tools' ] ); + + if ( empty( $names ) ) { + return false; + } + + $written = false; + $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; + $site_auths = Auth::where( 'site_url', $site_url ); + + if ( ! empty( $site_auths ) ) { + if ( is_file( $dir . '/' . $site_url ) || write_htpasswd_file( $site_url, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { + copy_proxy_file( $dir, $site_url, $names ); + $written = true; + } else { + EE::warning( sprintf( 'Could not write the htpasswd file of %s.', $site_url ) ); + } + } + + $ips = get_site_whitelist_ips( $site_url ); + + if ( ! empty( $ips ) ) { + foreach ( $names as $name ) { + if ( is_proxy_file_name( $name ) ) { + put_ips_to_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $name . '_acl', $ips ); + $written = true; + } + } + } + + return $written; +} + /** * (Re)creates an htpasswd file in the proxy container with the given auth entries. * @@ -298,34 +343,47 @@ function htpasswd_command( string $flags, string $name, string $username, string } /** - * Generates whitelist files for a site. + * Gets the IPs to whitelist on a site: global and site entries, or none when the site has no own entries. * - * @param string $site_url URL of site, `default` for global. - * @param \EE\Model\Site|null $site_data Site model. + * @param string $site_url URL of site, `default` for global. * - * @throws \Exception + * @return array */ -function generate_site_whitelist( string $site_url, $site_data = null ) { +function get_site_whitelist_ips( string $site_url ): array { - $domains = get_site_auth_domains( $site_url, $site_data ); $site_ips = Whitelist::where( 'site_url', $site_url ); - foreach ( $domains as $domain ) { - remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d', $domain . '_acl' ); - } - - // Without site entries the proxy falls back to `default_acl`. if ( empty( $site_ips ) ) { - return; + return []; } - $whitelists = array_column( + return array_column( 'default' === $site_url ? $site_ips : array_merge( Whitelist::get_global_ips(), $site_ips ), 'ip' ); +} + +/** + * Generates whitelist files for a site. + * + * @param string $site_url URL of site, `default` for global. + * @param \EE\Model\Site|null $site_data Site model. + * + * @throws \Exception + */ +function generate_site_whitelist( string $site_url, $site_data = null ) { + + $dir = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d'; + $domains = get_site_auth_domains( $site_url, $site_data ); + $ips = get_site_whitelist_ips( $site_url ); foreach ( $domains as $domain ) { - put_ips_to_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $domain . '_acl', $whitelists ); + // Without site entries the proxy falls back to `default_acl`. + if ( empty( $ips ) ) { + remove_proxy_file( $dir, $domain . '_acl' ); + } else { + put_ips_to_file( $dir . '/' . $domain . '_acl', $ips ); + } } } diff --git a/src/helper/hooks.php b/src/helper/hooks.php index 2f7e6b4..b595524 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -7,6 +7,7 @@ use EE\Model\Auth; use EE\Model\Site; use EE\Model\Whitelist; +use function EE\Auth\Utils\add_site_auth_files; use function EE\Auth\Utils\generate_site_auth_files; use function EE\Auth\Utils\generate_site_whitelist; use function EE\Auth\Utils\get_alias_auth_domains; @@ -64,20 +65,59 @@ function update_auth_on_alias_domains_change( $site_url, $added_domains = [], $r $reload = true; } - if ( ! empty( Auth::where( 'site_url', $site_url ) ) ) { - generate_site_auth_files( $site_url, $site ); - $reload = true; + // The added domains got their files before the update; regenerate them from the saved site to reconcile. + if ( ! empty( $added_domains ) ) { + if ( ! empty( Auth::where( 'site_url', $site_url ) ) ) { + generate_site_auth_files( $site_url, $site ); + $reload = true; + } + + if ( Whitelist::has_ips( $site_url ) ) { + generate_site_whitelist( $site_url, $site ); + $reload = true; + } } - if ( Whitelist::has_ips( $site_url ) ) { - generate_site_whitelist( $site_url, $site ); - $reload = true; + if ( $reload ) { + \EE\Site\Utils\reload_global_nginx_proxy(); } +} - if ( $reload ) { +/** + * Hook to write auth and whitelist files for alias domains before the proxy serves them, so they are never reachable unprotected. + * + * @param string $site_url The site whose alias domains change. + * @param array $domains_to_add Alias domains that are being added. + */ +function add_auth_before_alias_domains_update( $site_url, $domains_to_add = [] ) { + + // No reload needed: docker-gen renders the new hosts with these files once the site's containers are recreated. + add_site_auth_files( $site_url, get_alias_auth_domains( (array) $domains_to_add ) ); +} + +/** + * Hook to remove the files written for alias domains whose update failed. + * + * @param string $site_url The site whose alias domains update failed. + * @param array $domains_to_add Alias domains that were not added after all. + */ +function remove_auth_on_alias_domains_update_failure( $site_url, $domains_to_add = [] ) { + + $site = Site::find( $site_url ); + + if ( ! $site ) { + return; + } + + // The site still has its old alias domains, so this keeps every file it uses. + $names = array_diff( get_alias_auth_domains( (array) $domains_to_add ), get_site_auth_domains( $site_url, $site ) ); + + if ( remove_auth_files( $names ) ) { \EE\Site\Utils\reload_global_nginx_proxy(); } } EE::add_hook( 'site_cleanup', 'cleanup_auth_and_whitelist' ); +EE::add_hook( 'site_alias_domains_before_update', 'add_auth_before_alias_domains_update' ); EE::add_hook( 'site_alias_domains_updated', 'update_auth_on_alias_domains_change' ); +EE::add_hook( 'site_alias_domains_update_failed', 'remove_auth_on_alias_domains_update_failure' ); From bbe88ac22554e3896e8faf697973eb8feb8ef2d5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:09:10 +0000 Subject: [PATCH 18/30] fix(auth): reload the proxy on site cleanup only when something was removed The `site_cleanup` hook reloaded the global proxy on every site delete, also for sites that never had auth or whitelist entries or files. It now reloads only when it deleted a row or a file. --- src/helper/hooks.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/helper/hooks.php b/src/helper/hooks.php index b595524..64f82f2 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -27,18 +27,18 @@ function cleanup_auth_and_whitelist( $site_url ) { return; } - foreach ( Auth::where( [ 'site_url' => $site_url ] ) as $auth ) { - $auth->delete(); - } + $rows = array_merge( Auth::where( [ 'site_url' => $site_url ] ), Whitelist::where( [ 'site_url' => $site_url ] ) ); - foreach ( Whitelist::where( [ 'site_url' => $site_url ] ) as $whitelist ) { - $whitelist->delete(); + foreach ( $rows as $row ) { + $row->delete(); } // Files may exist without site entries (e.g. left by older versions), so always remove them. - remove_auth_files( get_site_auth_domains( $site_url, $site ) ); + $removed = remove_auth_files( get_site_auth_domains( $site_url, $site ) ); - \EE\Site\Utils\reload_global_nginx_proxy(); + if ( $removed || ! empty( $rows ) ) { + \EE\Site\Utils\reload_global_nginx_proxy(); + } } /** From dc373d868cab45525c6c0c1c0f96a148bee6f3bd Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:09:28 +0000 Subject: [PATCH 19/30] refactor(auth): look up sites with Site::find() when regenerating global files Site::find() uses the `site_url` primary key and returns false on a miss, which is mapped to null as before. --- src/Auth_Command.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index f445515..436be33 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -278,10 +278,7 @@ private function generate_global_auth_files() { ); foreach ( $sites as $site ) { - // Fetch site data to get app_sub_type and alias_domains - $site_info = \EE\Model\Site::where( 'site_url', $site ); - $site_data = ! empty( $site_info ) ? $site_info[0] : null; - generate_site_auth_files( $site, $site_data ); + generate_site_auth_files( $site, \EE\Model\Site::find( $site ) ?: null ); } } } @@ -305,10 +302,7 @@ private function generate_global_whitelist() { } foreach ( $sites as $site ) { - // Fetch site data to get app_sub_type and alias_domains - $site_info = \EE\Model\Site::where( 'site_url', $site ); - $site_data = ! empty( $site_info ) ? $site_info[0] : null; - generate_site_whitelist( $site, $site_data ); + generate_site_whitelist( $site, \EE\Model\Site::find( $site ) ?: null ); } } From ced81fa9c0963df0a08ae79159a57adae9841a0d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:09:56 +0000 Subject: [PATCH 20/30] feat(auth): regenerate site auth files on upgrade Installs upgraded from a version that only wrote `htpasswd/` and `_acl` would keep subdomains (`_wildcard.`) and alias domains unprotected until an `ee auth` command touched the site, and kept per-site files of sites without own entries. A container migration now regenerates the htpasswd and ACL files of every site, which also removes the files of sites without own entries, and then reloads the proxy once. A failing site is logged and skipped instead of failing the migration. It is skipped on fresh installs and when there are no sites, and has nothing to revert since the files are also valid for the previous version. --- ...uth-command_regenerate_site_auth_files.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php diff --git a/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php new file mode 100644 index 0000000..b57d929 --- /dev/null +++ b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php @@ -0,0 +1,56 @@ +sites = Site::all(); + if ( $this->is_first_execution || ! $this->sites ) { + $this->skip_this_migration = true; + } + } + + /** + * Regenerate the htpasswd and ACL files of all sites. + * + * Older versions only wrote `htpasswd/` and `_acl`, leaving subdomains and alias domains unprotected, and kept files of sites without own entries. + * + * @throws EE\ExitException + */ + public function up() { + + if ( $this->skip_this_migration ) { + EE::debug( 'Skipping site auth files regeneration migration as it is not needed.' ); + + return; + } + + foreach ( $this->sites as $site ) { + try { + generate_site_auth_files( $site->site_url, $site ); + generate_site_whitelist( $site->site_url, $site ); + } catch ( \Throwable $e ) { + EE::warning( sprintf( 'Could not regenerate the auth files of %s: %s', $site->site_url, $e->getMessage() ) ); + } + } + + \EE\Site\Utils\reload_global_nginx_proxy(); + } + + /** + * Nothing to revert: the regenerated files are also valid for the previous version. + */ + public function down() { + } +} From 6de4d78e63102da1ccdf256eed0bd049d9fac068 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:51:58 +0000 Subject: [PATCH 21/30] refactor(auth): keep the global auth file names in one constant `default` and `default_admin_tools` were listed separately in the alias domain check, the file removal guard and add_site_auth_files(). They now come from RESERVED_AUTH_FILE_NAMES through is_reserved_auth_file_name(), which also matches them in any case, as the alias domain check in site-command does. --- src/auth-utils.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 982745a..0233742 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -11,6 +11,21 @@ use function EE\Service\Utils\ensure_global_network_initialized; use function EE\Utils\get_config_value; +// Global htpasswd/ACL file names that never belong to a site. +const RESERVED_AUTH_FILE_NAMES = [ 'default', 'default_admin_tools' ]; + +/** + * Checks whether a name is one of the global htpasswd/ACL file names, in any case. + * + * @param string $name File name. + * + * @return bool + */ +function is_reserved_auth_file_name( string $name ): bool { + + return in_array( strtolower( $name ), RESERVED_AUTH_FILE_NAMES, true ); +} + /** * Initialize global admin tools auth if it's not present. * @@ -87,7 +102,7 @@ function get_auth_domain( string $domain ): string { function is_valid_alias_domain( string $domain ): bool { // These would map onto the global auth and ACL files. - if ( in_array( $domain, [ 'default', 'default_admin_tools' ], true ) ) { + if ( is_reserved_auth_file_name( $domain ) ) { return false; } @@ -206,8 +221,7 @@ function remove_auth_files( array $domains ): bool { $removed = false; foreach ( $domains as $domain ) { $domain = (string) $domain; - // The global files never belong to a site. - if ( in_array( $domain, [ '', 'default', 'default_admin_tools' ], true ) ) { + if ( '' === $domain || is_reserved_auth_file_name( $domain ) ) { continue; } $removed = remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', $domain ) || $removed; @@ -263,7 +277,7 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { */ function add_site_auth_files( string $site_url, array $names ): bool { - $names = array_diff( array_unique( $names ), [ $site_url, 'default', 'default_admin_tools' ] ); + $names = array_diff( array_unique( $names ), array_merge( [ $site_url ], RESERVED_AUTH_FILE_NAMES ) ); if ( empty( $names ) ) { return false; From 9b3cd1f7afbe26ad32e9be0b65dae516c85c8f6c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:52:11 +0000 Subject: [PATCH 22/30] fix(auth): protect alias domains that contain underscores is_valid_alias_domain() only allowed letters, digits and `-` in labels, so an alias domain like `my_blog.example.com` got no htpasswd or ACL file and was served without the site's auth. Labels may now also contain `_`, matching the alias domain names site-command accepts. --- src/auth-utils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 0233742..7da855f 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -106,7 +106,7 @@ function is_valid_alias_domain( string $domain ): bool { return false; } - return 1 === preg_match( '/^(\*\.)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/D', $domain ); + return 1 === preg_match( '/^(\*\.)?[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/D', $domain ); } /** From 1a34106c5e79d1d8252d116076661283cc0029af Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:52:54 +0000 Subject: [PATCH 23/30] fix(auth): replace htpasswd files atomically htpasswd rewrote the live file entry by entry (`-c` for the first, then one call per user), so the proxy could read a file with only some of the users, and a failure midway left it that way. The entries are now written to `..tmp` in the htpasswd directory, a name no host can match, and the file is renamed over the real one once all entries are in; the site's other domains are then copied from it as before. On failure the temp file is removed, the existing file is left unchanged, and write_htpasswd_file() warns, so the callers no longer do. --- src/auth-utils.php | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 7da855f..b3c2496 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -257,8 +257,6 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { $source = array_shift( $domains ); if ( ! write_htpasswd_file( $source, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { - EE::warning( sprintf( 'Could not write the htpasswd file of %s.', $site_url ) ); - return; } @@ -291,8 +289,6 @@ function add_site_auth_files( string $site_url, array $names ): bool { if ( is_file( $dir . '/' . $site_url ) || write_htpasswd_file( $site_url, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { copy_proxy_file( $dir, $site_url, $names ); $written = true; - } else { - EE::warning( sprintf( 'Could not write the htpasswd file of %s.', $site_url ) ); } } @@ -313,6 +309,8 @@ function add_site_auth_files( string $site_url, array $names ): bool { /** * (Re)creates an htpasswd file in the proxy container with the given auth entries. * + * The file is replaced only once all entries are written; on failure the existing file is left unchanged. + * * @param string $name File name inside the htpasswd directory. * @param array $auths Auth models, or objects with `username` and `password`. * @@ -320,18 +318,41 @@ function add_site_auth_files( string $site_url, array $names ): bool { */ function write_htpasswd_file( string $name, array $auths ): bool { - $flags = 'bc'; + if ( empty( $auths ) ) { + return true; + } + + $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; + // No host name starts with a dot, so the proxy never uses the file while it is being built. + $tmp = '.' . $name . '.tmp'; + $flags = 'bc'; + $written = true; + foreach ( $auths as $auth ) { // Keep the credentials out of ee.log. $obfuscate = [ escapeshellarg( $auth->password ), escapeshellarg( $auth->username ) ]; - if ( ! EE::exec( htpasswd_command( $flags, $name, $auth->username, $auth->password ), false, false, $obfuscate ) ) { - return false; + if ( ! EE::exec( htpasswd_command( $flags, $tmp, $auth->username, $auth->password ), false, false, $obfuscate ) ) { + $written = false; + break; } $flags = 'b'; } - return true; + if ( $written ) { + try { + ( new Filesystem() )->rename( $dir . '/' . $tmp, $dir . '/' . $name, true ); + } catch ( \Exception $e ) { + $written = false; + } + } + + if ( ! $written ) { + remove_proxy_file( $dir, $tmp ); + EE::warning( sprintf( 'Could not write the htpasswd file %s, so it was left unchanged.', $name ) ); + } + + return $written; } /** From 1ea9bef6e7393ecb426930466f93d8f5bdb13577 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:53:18 +0000 Subject: [PATCH 24/30] fix(auth): keep htpasswd usernames out of ee.log htpasswd prints `Adding password for user ` on STDERR, and EE::exec() logs the output of every command, so each write still put the usernames in ee.log even though the command itself was masked. The htpasswd output is now discarded; failures are still detected through the exit code, which write_htpasswd_file() checks. --- src/auth-utils.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index b3c2496..8e6cb87 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -367,8 +367,9 @@ function write_htpasswd_file( string $name, array $auths ): bool { */ function htpasswd_command( string $flags, string $name, string $username, string $password ): string { + // htpasswd reports `Adding password for user ` on STDERR, which EE::exec() would log; failures still show in the exit code. return sprintf( - 'docker exec %s htpasswd -%s %s %s %s', + 'docker exec %s htpasswd -%s %s %s %s 2>/dev/null', EE_PROXY_TYPE, $flags, escapeshellarg( '/etc/nginx/htpasswd/' . $name ), From 3a5d2f6b72e0ad6dee12ad538d3f2d67fc54eb4d Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:54:12 +0000 Subject: [PATCH 25/30] refactor(auth): generate files for alias domains being added with the site's generators add_site_auth_files() reimplemented the htpasswd and ACL generation for the alias domains about to be added. generate_site_auth_files(), generate_site_whitelist() and get_site_auth_domains() now take the alias domains not saved on the site yet, and add_site_auth_files() just calls the two generators with them. It no longer returns whether anything was written, which no caller used. Stale files for the new names are now also removed when the site has no own entries, so they fall back to the global files. The post-update hook no longer regenerates everything (running htpasswd again) whenever alias domains were added: it only rewrites the files if one the site's entries call for is missing, and reloads the proxy only when it changed something. --- src/auth-utils.php | 85 ++++++++++++++++++++++---------------------- src/helper/hooks.php | 26 +++++++------- 2 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 8e6cb87..5ad176f 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -132,12 +132,13 @@ function get_alias_auth_domains( array $aliases ): array { /** * Collects the htpasswd/ACL file names of a site: the site itself, `_wildcard.` for subdomain multisites, and its alias domains. * - * @param string $site_url URL of site. - * @param \EE\Model\Site|null $site_data Site model. + * @param string $site_url URL of site. + * @param \EE\Model\Site|null $site_data Site model. + * @param array $extra_aliases Alias domains not saved on the site yet, e.g. ones about to be added. * * @return array */ -function get_site_auth_domains( string $site_url, $site_data ): array { +function get_site_auth_domains( string $site_url, $site_data, array $extra_aliases = [] ): array { $is_subdom = ! empty( $site_data->app_sub_type ) && 'subdom' === $site_data->app_sub_type; $domains = [ $site_url ]; @@ -146,9 +147,8 @@ function get_site_auth_domains( string $site_url, $site_data ): array { $domains[] = '_wildcard.' . $site_url; } - if ( ! empty( $site_data->alias_domains ) ) { - $domains = array_merge( $domains, get_alias_auth_domains( explode( ',', $site_data->alias_domains ) ) ); - } + $aliases = empty( $site_data->alias_domains ) ? [] : explode( ',', $site_data->alias_domains ); + $domains = array_merge( $domains, get_alias_auth_domains( array_merge( $aliases, $extra_aliases ) ) ); return array_values( array_unique( $domains ) ); } @@ -234,15 +234,16 @@ function remove_auth_files( array $domains ): bool { /** * Generates auth files for a site. * - * @param string $site_url URL of site. - * @param \EE\Model\Site|null $site_data Site model. + * @param string $site_url URL of site. + * @param \EE\Model\Site|null $site_data Site model. + * @param array $extra_aliases Alias domains not saved on the site yet, e.g. ones about to be added. * * @throws \Exception */ -function generate_site_auth_files( string $site_url, $site_data = null ) { +function generate_site_auth_files( string $site_url, $site_data = null, array $extra_aliases = [] ) { $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; - $domains = get_site_auth_domains( $site_url, $site_data ); + $domains = get_site_auth_domains( $site_url, $site_data, $extra_aliases ); $site_auths = Auth::where( 'site_url', $site_url ); // Without site entries the proxy falls back to the global `default` file. @@ -264,46 +265,43 @@ function generate_site_auth_files( string $site_url, $site_data = null ) { } /** - * Writes a site's current htpasswd and ACL files for extra file names, e.g. of alias domains that are about to be served. - * - * Only writes the files the site has own entries for, so other names keep falling back to the global files. + * Writes a site's htpasswd and ACL files, including for alias domains that are about to be served. * - * @param string $site_url URL of site. - * @param array $names File names as returned by get_auth_domain(). + * @param string $site_url URL of site. + * @param \EE\Model\Site|null $site_data Site model. + * @param array $aliases Alias domains not saved on the site yet. * - * @return bool Whether any file was written. + * @throws \Exception */ -function add_site_auth_files( string $site_url, array $names ): bool { +function add_site_auth_files( string $site_url, $site_data, array $aliases ) { - $names = array_diff( array_unique( $names ), array_merge( [ $site_url ], RESERVED_AUTH_FILE_NAMES ) ); - - if ( empty( $names ) ) { - return false; - } + generate_site_auth_files( $site_url, $site_data, $aliases ); + generate_site_whitelist( $site_url, $site_data, $aliases ); +} - $written = false; - $dir = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd'; - $site_auths = Auth::where( 'site_url', $site_url ); +/** + * Checks whether any of the given file names lacks the htpasswd or ACL file the site's own entries call for. + * + * @param string $site_url URL of site. + * @param array $names File names as returned by get_alias_auth_domains(). + * + * @return bool + */ +function site_auth_files_missing( string $site_url, array $names ): bool { - if ( ! empty( $site_auths ) ) { - if ( is_file( $dir . '/' . $site_url ) || write_htpasswd_file( $site_url, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { - copy_proxy_file( $dir, $site_url, $names ); - $written = true; - } - } + $has_auths = ! empty( Auth::where( 'site_url', $site_url ) ); + $has_ips = Whitelist::has_ips( $site_url ); - $ips = get_site_whitelist_ips( $site_url ); + foreach ( $names as $name ) { + $auth_missing = $has_auths && ! is_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $name ); + $acl_missing = $has_ips && ! is_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $name . '_acl' ); - if ( ! empty( $ips ) ) { - foreach ( $names as $name ) { - if ( is_proxy_file_name( $name ) ) { - put_ips_to_file( EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/' . $name . '_acl', $ips ); - $written = true; - } + if ( $auth_missing || $acl_missing ) { + return true; } } - return $written; + return false; } /** @@ -402,15 +400,16 @@ function get_site_whitelist_ips( string $site_url ): array { /** * Generates whitelist files for a site. * - * @param string $site_url URL of site, `default` for global. - * @param \EE\Model\Site|null $site_data Site model. + * @param string $site_url URL of site, `default` for global. + * @param \EE\Model\Site|null $site_data Site model. + * @param array $extra_aliases Alias domains not saved on the site yet, e.g. ones about to be added. * * @throws \Exception */ -function generate_site_whitelist( string $site_url, $site_data = null ) { +function generate_site_whitelist( string $site_url, $site_data = null, array $extra_aliases = [] ) { $dir = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d'; - $domains = get_site_auth_domains( $site_url, $site_data ); + $domains = get_site_auth_domains( $site_url, $site_data, $extra_aliases ); $ips = get_site_whitelist_ips( $site_url ); foreach ( $domains as $domain ) { diff --git a/src/helper/hooks.php b/src/helper/hooks.php index 64f82f2..4bcc2b6 100644 --- a/src/helper/hooks.php +++ b/src/helper/hooks.php @@ -8,11 +8,10 @@ use EE\Model\Site; use EE\Model\Whitelist; use function EE\Auth\Utils\add_site_auth_files; -use function EE\Auth\Utils\generate_site_auth_files; -use function EE\Auth\Utils\generate_site_whitelist; use function EE\Auth\Utils\get_alias_auth_domains; use function EE\Auth\Utils\get_site_auth_domains; use function EE\Auth\Utils\remove_auth_files; +use function EE\Auth\Utils\site_auth_files_missing; /** * Hook to cleanup auth entries, whitelisted ips and their files if any. @@ -65,17 +64,10 @@ function update_auth_on_alias_domains_change( $site_url, $added_domains = [], $r $reload = true; } - // The added domains got their files before the update; regenerate them from the saved site to reconcile. - if ( ! empty( $added_domains ) ) { - if ( ! empty( Auth::where( 'site_url', $site_url ) ) ) { - generate_site_auth_files( $site_url, $site ); - $reload = true; - } - - if ( Whitelist::has_ips( $site_url ) ) { - generate_site_whitelist( $site_url, $site ); - $reload = true; - } + // The added domains got their files before the update, so only rewrite them if some are missing, e.g. when that failed. + if ( site_auth_files_missing( $site_url, get_alias_auth_domains( (array) $added_domains ) ) ) { + add_site_auth_files( $site_url, $site, [] ); + $reload = true; } if ( $reload ) { @@ -91,8 +83,14 @@ function update_auth_on_alias_domains_change( $site_url, $added_domains = [], $r */ function add_auth_before_alias_domains_update( $site_url, $domains_to_add = [] ) { + $site = Site::find( $site_url ); + + if ( ! $site || empty( $domains_to_add ) ) { + return; + } + // No reload needed: docker-gen renders the new hosts with these files once the site's containers are recreated. - add_site_auth_files( $site_url, get_alias_auth_domains( (array) $domains_to_add ) ); + add_site_auth_files( $site_url, $site, (array) $domains_to_add ); } /** From 510f8cc964661c6d7b2c352b9720f215273d7682 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:54:25 +0000 Subject: [PATCH 26/30] fix(auth): copy a site's existing htpasswd file to its other domains when it can't be rewritten generate_site_auth_files() gave up when htpasswd could not run, e.g. when nginx-proxy is stopped while the upgrade migration runs. The migration was then recorded as done, but the subdomain and alias domain files it exists to create were never written, leaving those hosts on the global auth. If the site's own `htpasswd/` exists, it is now copied host-side to the site's other domains, as the alias domain hook did before, so they are at least as protected as the site itself. --- src/auth-utils.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 5ad176f..1ce5a37 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -257,11 +257,10 @@ function generate_site_auth_files( string $site_url, $site_data = null, array $e $source = array_shift( $domains ); - if ( ! write_htpasswd_file( $source, array_merge( Auth::get_global_auths(), $site_auths ) ) ) { - return; + // If it can't be rewritten (e.g. the proxy is stopped during an upgrade), still spread the existing file to the other domains. + if ( write_htpasswd_file( $source, array_merge( Auth::get_global_auths(), $site_auths ) ) || is_file( $dir . '/' . $source ) ) { + copy_proxy_file( $dir, $source, $domains ); } - - copy_proxy_file( $dir, $source, $domains ); } /** From cf073e7ef17a03bf6544aa2575f470771e8cb29e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 07:54:32 +0000 Subject: [PATCH 27/30] docs(auth): say the auth files migration needs the new nginx-proxy image The down() comment claimed the regenerated files are also valid for the previous version. With the previous nginx-proxy template a `_wildcard.X` file also protects sibling hosts that share the last labels (e.g. shop.example.com picks up _wildcard.example.com), so the files written here are only correct with the nginx-proxy image shipped in the same release. --- .../20260924120000_auth-command_regenerate_site_auth_files.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php index b57d929..1b3740f 100644 --- a/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php +++ b/migrations/container/20260924120000_auth-command_regenerate_site_auth_files.php @@ -49,7 +49,7 @@ public function up() { } /** - * Nothing to revert: the regenerated files are also valid for the previous version. + * Not reverted. The files need the nginx-proxy image of the same release: the previous image also applies `_wildcard.*` files to sibling sites. */ public function down() { } From cbf7d11df20d3f6512544d01ae84a10d54c46d10 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:03:18 +0000 Subject: [PATCH 28/30] fix(auth): use site-command's alias domain rule Alias domains are now checked with site-command's `is_valid_alias_domain()` and the global file names with its `is_reserved_proxy_file_name()`, instead of a separate copy of the rule that disagreed with it (it accepted `-a.com` and `a-.com`). This also skips aliases whose labels start with `_`, so an alias like `_wildcard.example.com` can no longer delete or overwrite the `*.example.com` files of site example.com. --- src/auth-utils.php | 38 ++++---------------------------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 1ce5a37..1bf6903 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -9,23 +9,10 @@ use EE\Model\Whitelist; use Symfony\Component\Filesystem\Filesystem; use function EE\Service\Utils\ensure_global_network_initialized; +use function EE\Site\Utils\is_reserved_proxy_file_name; +use function EE\Site\Utils\is_valid_alias_domain; use function EE\Utils\get_config_value; -// Global htpasswd/ACL file names that never belong to a site. -const RESERVED_AUTH_FILE_NAMES = [ 'default', 'default_admin_tools' ]; - -/** - * Checks whether a name is one of the global htpasswd/ACL file names, in any case. - * - * @param string $name File name. - * - * @return bool - */ -function is_reserved_auth_file_name( string $name ): bool { - - return in_array( strtolower( $name ), RESERVED_AUTH_FILE_NAMES, true ); -} - /** * Initialize global admin tools auth if it's not present. * @@ -93,24 +80,7 @@ function get_auth_domain( string $domain ): string { } /** - * Checks that an alias domain is a plain hostname or `*.hostname`, so it is safe to use as an htpasswd/ACL file name. - * - * @param string $domain Alias domain. - * - * @return bool - */ -function is_valid_alias_domain( string $domain ): bool { - - // These would map onto the global auth and ACL files. - if ( is_reserved_auth_file_name( $domain ) ) { - return false; - } - - return 1 === preg_match( '/^(\*\.)?[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/D', $domain ); -} - -/** - * Maps alias domains to their htpasswd/ACL file names, skipping unsafe ones. + * Maps alias domains to their htpasswd/ACL file names, skipping the ones site-command would reject. * * @param array $aliases Alias domains. * @@ -221,7 +191,7 @@ function remove_auth_files( array $domains ): bool { $removed = false; foreach ( $domains as $domain ) { $domain = (string) $domain; - if ( '' === $domain || is_reserved_auth_file_name( $domain ) ) { + if ( '' === $domain || is_reserved_proxy_file_name( $domain ) ) { continue; } $removed = remove_proxy_file( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd', $domain ) || $removed; From d313f5e1914ca500d6635212126a4cffe4e25efa Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:03:18 +0000 Subject: [PATCH 29/30] fix(auth): keep the global htpasswd file until its replacement is written Regenerating global auth removed `htpasswd/default` and `default_admin_tools` before writing the new file, so a failed write left every host that falls back to global auth unprotected. `default` is now only removed when there are no global auths and otherwise replaced atomically, and `default_admin_tools` is removed only once `default` is written. The global rows are also no longer regenerated a second time as if they were sites. --- src/Auth_Command.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Auth_Command.php b/src/Auth_Command.php index 436be33..55588ad 100644 --- a/src/Auth_Command.php +++ b/src/Auth_Command.php @@ -23,6 +23,7 @@ use function EE\Auth\Utils\write_htpasswd_file; use function EE\Site\Utils\auto_site_name; use function EE\Site\Utils\get_site_info; +use function EE\Site\Utils\is_reserved_proxy_file_name; use function EE\Site\Utils\reload_global_nginx_proxy; class Auth_Command extends EE_Command { @@ -260,14 +261,15 @@ private function generate_global_auth_files() { if ( ! empty( $global_admin_tools_auth ) ) { write_htpasswd_file( 'default_admin_tools', $global_admin_tools_auth ); } else { - $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default_admin_tools' ); - $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default' ); $auths = Auth::get_global_auths(); if ( empty( $auths ) ) { + $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default_admin_tools' ); + $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default' ); $this->regen_admin_tools_auth(); - } else { - write_htpasswd_file( 'default', $auths ); + } elseif ( write_htpasswd_file( 'default', $auths ) ) { + // Admin tools prefer default_admin_tools, so drop it only once `default` is written; on failure both files keep protecting. + $this->fs->remove( EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/default_admin_tools' ); } $sites = array_unique( @@ -278,7 +280,10 @@ private function generate_global_auth_files() { ); foreach ( $sites as $site ) { - generate_site_auth_files( $site, \EE\Model\Site::find( $site ) ?: null ); + // The global files were handled above. + if ( ! is_reserved_proxy_file_name( $site ) ) { + generate_site_auth_files( $site, \EE\Model\Site::find( $site ) ?: null ); + } } } } From 329faee0a036bdd9089a290b2f1c4cddbbf694ef Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:03:18 +0000 Subject: [PATCH 30/30] fix(auth): copy auth files to a site's other domains atomically Each copy is written under a name no host matches and then renamed, so the proxy never reads a truncated file. A failed copy is cleaned up and leaves the existing file unchanged. --- src/auth-utils.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/auth-utils.php b/src/auth-utils.php index 1bf6903..420abc0 100644 --- a/src/auth-utils.php +++ b/src/auth-utils.php @@ -173,9 +173,17 @@ function copy_proxy_file( string $dir, string $source, array $targets ) { if ( ! is_proxy_file_name( $target ) || $target === $source ) { continue; } - $fs->copy( $dir . '/' . $source, $dir . '/' . $target, true ); - // Don't depend on the umask: nginx workers read these files. - $fs->chmod( $dir . '/' . $target, $mode ); + // Built under a name no host matches, then renamed, so the proxy never reads a partial copy. + $tmp = '.' . $target . '.tmp'; + try { + $fs->copy( $dir . '/' . $source, $dir . '/' . $tmp, true ); + // Don't depend on the umask: nginx workers read these files. + $fs->chmod( $dir . '/' . $tmp, $mode ); + $fs->rename( $dir . '/' . $tmp, $dir . '/' . $target, true ); + } catch ( \Exception $e ) { + remove_proxy_file( $dir, $tmp ); + EE::warning( sprintf( 'Could not copy %s to %s, so it was left unchanged.', $source, $target ) ); + } } }