From 06a7882929cb775d98808b09ea49771dab2c764e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Mon, 12 Oct 2026 10:00:55 +0530 Subject: [PATCH] fix(ssl): keep the HTTP-01 challenge files readable under a strict umask Under umask 077 or 027 the challenge dir .well-known/acme-challenge came out 0700 and the token, vhost.d/default and the www check.html 0600, so nginx-proxy and the site's nginx couldn't read them: the local challenge self-check failed with "Can not validate challenge" and the www check got a 403. cleanup() only runs after a successful verify, so the 0700 dir stayed behind and broke every later HTTP-01 attempt, even at umask 022. authorize() now sets 0755 on .well-known and acme-challenge and 0644 on the token and vhost.d/default whatever the umask, and repairs an existing challenge dir and its token files. check() repairs it as well, since ssl-verify retries a pending order against the token already on disk without calling authorize(). Stale tokens are kept for that reason. A symlink or file at .well-known or acme-challenge is removed instead of being chmod-ed through, and links in the token dir are left alone. The www check removes whatever is at check.html (the site can write to its webroot) and creates the file exclusively under umask 022, so a planted symlink is no longer followed. --- src/helper/Site_Letsencrypt.php | 66 ++++++++++++++++++++++++++++++--- src/helper/class-ee-site.php | 17 ++++++++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 9d52b019..4189ca4e 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -375,15 +375,16 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen $token = $authorizationChallenge->toArray()['token']; $payload = $authorizationChallenge->toArray()['payload']; - $fs = new \Symfony\Component\Filesystem\Filesystem(); - $fs->copy( SITE_TEMPLATE_ROOT . '/vhost.d_default_letsencrypt.mustache', EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/default' ); - $challange_dir = EE_ROOT_DIR . '/services/nginx-proxy/html/.well-known/acme-challenge'; - if ( ! $fs->exists( $challange_dir ) ) { - $fs->mkdir( $challange_dir ); - } + $fs = new \Symfony\Component\Filesystem\Filesystem(); + $rule_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/default'; + $fs->copy( SITE_TEMPLATE_ROOT . '/vhost.d_default_letsencrypt.mustache', $rule_file ); + $this->chmod_unless_link( $rule_file, 0644 ); + $challange_dir = $this->prepare_http_challenge_dir( true ); $challange_file = $challange_dir . '/' . $token; \EE::debug( 'Creating challange file ' . $challange_file ); $fs->dumpFile( $challange_file, $payload ); + // dumpFile() keeps the mode of a file it replaces, or follows the umask for a new one. + $this->chmod_unless_link( $challange_file, 0644 ); reload_global_nginx_proxy(); } } @@ -476,6 +477,12 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = $solver = empty ( get_config_value( 'cloudflare-api-key' ) ) ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleDnsCloudflareSolver( null, new ConsoleOutput() ); } else { $solver = new SimpleHttpSolver(); + // A pending order is validated against the token file an earlier authorize() wrote, so fix up what it left behind. + try { + $this->prepare_http_challenge_dir( false ); + } catch ( \Exception $e ) { + \EE::debug( 'Unable to repair the HTTP-01 challenge dir: ' . $e->getMessage() ); + } } $validator = new ChainValidator( [ @@ -1022,6 +1029,53 @@ private function getOrCreateDistinguishedName( $domain, array $alternativeNames, return $distinguishedName; } + /** + * Create the HTTP-01 challenge dir, or repair one a strict umask left 0700 (it outlives a failed verify), so nginx-proxy can read it. + * Stale token files are kept: `ssl-verify` retries a pending order against the token on disk. + * + * @param bool $create Create the dir when it is missing. + * + * @throws \Symfony\Component\Filesystem\Exception\IOException + * @return string The acme-challenge dir. + */ + private function prepare_http_challenge_dir( $create ) { + $fs = new \Symfony\Component\Filesystem\Filesystem(); + $dir = EE_ROOT_DIR . '/services/nginx-proxy/html'; + clearstatcache(); + foreach ( [ '.well-known', 'acme-challenge' ] as $part ) { + $dir .= '/' . $part; + // Only EE creates these, as real dirs; never chmod through a link planted here. + if ( is_link( $dir ) || is_file( $dir ) ) { + $fs->remove( $dir ); + } + if ( ! is_dir( $dir ) ) { + if ( ! $create ) { + return $dir; + } + $fs->mkdir( $dir, 0755 ); + } + $fs->chmod( $dir, 0755 ); + } + + foreach ( scandir( $dir ) as $entry ) { + if ( '.' !== $entry && '..' !== $entry ) { + $this->chmod_unless_link( $dir . '/' . $entry, 0644 ); + } + } + + return $dir; + } + + /** + * Set a regular file's mode, leaving symlinks (and their targets) alone. + */ + private function chmod_unless_link( $file, $mode ) { + clearstatcache( true, $file ); + if ( ! is_link( $file ) && is_file( $file ) ) { + ( new \Symfony\Component\Filesystem\Filesystem() )->chmod( $file, $mode ); + } + } + /** * Cleanup created challenge files and specific rule sets for it. */ diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 0427931d..505a395c 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1911,7 +1911,22 @@ protected function check_www_or_non_www_domain( $site_url, $site_path, $site_con $successful = false; $extra_path = str_replace( '/var/www/htdocs', '', $site_container_path ); $file_path = $site_path . '/app/htdocs' . $extra_path . '/check.html'; - file_put_contents( $file_path, $random_string ); + + // The site can write here: drop whatever is at the path so a planted symlink isn't followed, then create the file exclusively. + if ( is_link( $file_path ) || is_file( $file_path ) ) { + unlink( $file_path ); + } + // The site's nginx has to read the file whatever the umask is. + $old_umask = umask( 0022 ); + $handle = @fopen( $file_path, 'x' ); + umask( $old_umask ); + if ( false === $handle ) { + EE::debug( "Unable to create $file_path" ); + + return false; + } + fwrite( $handle, $random_string ); + fclose( $handle ); if ( 0 === strpos( $site_url, 'www.' ) ) { $site_url = ltrim( $site_url, 'www.' );