From afa4eb79f3c00e1f82220c8a198b05f9471d3ab6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 19:32:46 +0530 Subject: [PATCH 01/14] fix(restore): keep session upload progress off after restoring an older backup A restore copies custom.ini and php.ini from the backup over the site's files. A backup taken before v4.13.3 has neither the session.upload_progress.enabled = Off line that v4.13.3 added to custom.ini nor a php.ini that sets it, so the restored site ran with upload progress back On, PHP's default. After restoring the PHP config, add the line again at the top of custom.ini unless the restored file already sets the directive outside a [PATH=] or [HOST=] section, so an explicit On or Off is kept, as in the v4.13.3 migrations. The site reload at the end of the restore applies it. The check that finds those sections now lets PHP's ini parser decide what each header line is. Before, a header was only recognised when it was unquoted and LF-terminated, so in a CRLF file, or with a quoted name like ["PATH=/x"], a setting inside the section was taken as a global one and the line was not added. --- src/helper/Site_Backup_Restore.php | 48 ++++++++++++++++ src/helper/site-utils.php | 89 ++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/helper/Site_Backup_Restore.php b/src/helper/Site_Backup_Restore.php index d2620cc4..f4de8bc4 100644 --- a/src/helper/Site_Backup_Restore.php +++ b/src/helper/Site_Backup_Restore.php @@ -1693,6 +1693,54 @@ private function restore_php_conf( $backup_dir ) { $restore_command = sprintf( 'rsync -a %s/php/php/conf.d/custom.ini %s/config/php/php/conf.d/custom.ini', $backup_dir, EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'] ); EE::exec( $restore_command ); } + + $this->keep_session_upload_progress_off(); + } + + /** + * A backup taken before v4.13.3 brings back a custom.ini without `session.upload_progress.enabled = Off`, so add it + * again unless the restored file sets the directive itself. The site reload at the end of the restore applies it. + */ + private function keep_session_upload_progress_off() { + $site_url = $this->site_data['site_url']; + $conf_dir = EE_ROOT_DIR . '/sites/' . $site_url . '/config/php/php/conf.d'; + $file = $conf_dir . '/custom.ini'; + $manual = "Add 'session.upload_progress.enabled = Off' to $file and run: ee site reload $site_url"; + + $created = ! file_exists( $file ) && ! is_link( $file ); + if ( is_link( $file ) || ( ! $created && ! is_file( $file ) ) || ( $created && ! is_dir( $conf_dir ) ) ) { + EE::warning( "Could not check session.upload_progress in the restored custom.ini: it is not a regular file or its directory is missing. $manual" ); + + return; + } + + $content = $created ? '' : @file_get_contents( $file ); + if ( false === $content ) { + EE::warning( "Could not read the restored custom.ini to check session.upload_progress. $manual" ); + + return; + } + + $new_content = \EE\Site\Utils\add_session_upload_progress_off( $content ); + if ( null === $new_content ) { + return; + } + + // Written in place: keeps owner, mode and inode (macOS bind-mounts this single file). + $written = @file_put_contents( $file, $new_content ); + clearstatcache( true, $file ); + if ( strlen( $new_content ) !== $written ) { + EE::warning( "Could not turn off session.upload_progress in the restored custom.ini. $manual" ); + + return; + } + if ( $created ) { + @chmod( $file, 0644 ); + @chown( $file, fileowner( $conf_dir ) ); + @chgrp( $file, filegroup( $conf_dir ) ); + } + + EE::log( 'The restored custom.ini did not set session.upload_progress.enabled, so it was set to Off again. Set it to On there only if your app needs it.' ); } /** diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a4766b19..09d0fb40 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1623,3 +1623,92 @@ function get_wp_core_download_command( string $path, array $args = [] ) { // docker_compose_exec() wraps the command in bash -c "...". return addcslashes( $script, '"$`\\' ); } + +/** + * Add `session.upload_progress.enabled = Off` to a site's custom.ini content, unless the content already sets it globally. + * + * Same rule as the v4.13.3 upload-progress migrations: an effective global line, On or Off, is the user's choice and is kept. + * + * @param string $content custom.ini content. + * + * @return string|null The new content, or null when it already sets the directive. + */ +function add_session_upload_progress_off( string $content ) { + + $bom = ''; + if ( 0 === strpos( $content, "\xEF\xBB\xBF" ) ) { + $bom = "\xEF\xBB\xBF"; + $content = substr( $content, 3 ); + } + + if ( ini_sets_globally( $content, 'session.upload_progress.enabled' ) ) { + return null; + } + + $eol = false !== strpos( $content, "\r\n" ) ? "\r\n" : "\n"; + $lines = '; Session upload progress is off for security; set it to On only if your app needs it.' . $eol . 'session.upload_progress.enabled = Off' . $eol; + + if ( '' === trim( $content ) ) { + return $bom . $lines; + } + + // At the very top, so neither a [PATH=]/[HOST=] section nor a multi-line quoted value can capture it. + return $bom . $lines . $eol . $content; +} + +/** + * Whether ini content sets a directive outside any [PATH=]/[HOST=] section, read the way PHP reads php.ini. + * + * @param string $content ini content without BOM. + * @param string $key Directive name (case-sensitive, like PHP). + * + * @return bool + */ +function ini_sets_globally( string $content, string $key ) { + + $global = ini_global_part( $content ); + $ini = @parse_ini_string( $global, true, INI_SCANNER_NORMAL ); + // PHP applies the lines above a syntax error, so only that part counts. + for ( $lines = preg_split( '/(?<=\n)/', $global ), $n = count( $lines ) - 1; false === $ini && $n > 0; $n-- ) { + $ini = @parse_ini_string( implode( '', array_slice( $lines, 0, $n ) ), true, INI_SCANNER_NORMAL ); + } + if ( false === $ini ) { + return false; + } + + foreach ( $ini as $name => $value ) { + if ( is_array( $value ) ? array_key_exists( $key, $value ) : $key === $name ) { + return true; + } + } + + return false; +} + +/** + * The part of ini content before the first section whose name starts with PATH or HOST. + * + * PHP scopes every entry after such a header to it, even under later ordinary headers. The parser decides what a header's + * name is, so CRLF line endings and quoted names like ["PATH=/x"] are handled the way PHP handles them. + * + * @param string $content ini content without BOM. + * + * @return string + */ +function ini_global_part( string $content ) { + + if ( ! preg_match_all( '/^[ \t]*\[/m', $content, $matches, PREG_OFFSET_CAPTURE ) ) { + return $content; + } + + foreach ( $matches[0] as $match ) { + $eol = strpos( $content, "\n", $match[1] ); + // Text inside a multi-line quoted value doesn't parse as the last section. + $ini = @parse_ini_string( false === $eol ? $content : substr( $content, 0, $eol ), true, INI_SCANNER_NORMAL ); + if ( false !== $ini && is_array( end( $ini ) ) && preg_match( '/^(PATH|HOST)/i', (string) key( $ini ) ) ) { + return substr( $content, 0, $match[1] ); + } + } + + return $content; +} From 671cf81fe6a1ea29e26c8764871f6509bb1583a4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 20:15:35 +0530 Subject: [PATCH 02/14] fix(restore): find PATH and HOST sections with any header layout Find the start of a [PATH=]/[HOST=] section by letting PHP's ini parser read up to every '[' and ']' instead of only at the start of an LF-separated line. A header after a CR-only line ending, a second header on the same line as in [PATH=/x][Session], and a quoted name that contains a newline or ']' are now found, so a setting inside such a section no longer counts as global. Read the part before such a section without sections, so an array entry like custom_flags[session.upload_progress.enabled] = Off isn't taken for the directive. A CR-only file gets CR line endings for the added lines. --- src/helper/site-utils.php | 49 ++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 09d0fb40..a23f13a9 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1645,7 +1645,7 @@ function add_session_upload_progress_off( string $content ) { return null; } - $eol = false !== strpos( $content, "\r\n" ) ? "\r\n" : "\n"; + $eol = false !== strpos( $content, "\r\n" ) ? "\r\n" : ( false === strpos( $content, "\n" ) && false !== strpos( $content, "\r" ) ? "\r" : "\n" ); $lines = '; Session upload progress is off for security; set it to On only if your app needs it.' . $eol . 'session.upload_progress.enabled = Off' . $eol; if ( '' === trim( $content ) ) { @@ -1667,29 +1667,21 @@ function add_session_upload_progress_off( string $content ) { function ini_sets_globally( string $content, string $key ) { $global = ini_global_part( $content ); - $ini = @parse_ini_string( $global, true, INI_SCANNER_NORMAL ); + // Without sections, entries of ordinary sections are global and `foo[key] = x` stays an array, so it can't match. + $ini = @parse_ini_string( $global, false, INI_SCANNER_NORMAL ); // PHP applies the lines above a syntax error, so only that part counts. - for ( $lines = preg_split( '/(?<=\n)/', $global ), $n = count( $lines ) - 1; false === $ini && $n > 0; $n-- ) { - $ini = @parse_ini_string( implode( '', array_slice( $lines, 0, $n ) ), true, INI_SCANNER_NORMAL ); - } - if ( false === $ini ) { - return false; + for ( $lines = preg_split( '/(?<=\n)|(?<=\r)(?!\n)/', $global ), $n = count( $lines ) - 1; false === $ini && $n > 0; $n-- ) { + $ini = @parse_ini_string( implode( '', array_slice( $lines, 0, $n ) ), false, INI_SCANNER_NORMAL ); } - foreach ( $ini as $name => $value ) { - if ( is_array( $value ) ? array_key_exists( $key, $value ) : $key === $name ) { - return true; - } - } - - return false; + return false !== $ini && array_key_exists( $key, $ini ) && ! is_array( $ini[ $key ] ); } /** * The part of ini content before the first section whose name starts with PATH or HOST. * - * PHP scopes every entry after such a header to it, even under later ordinary headers. The parser decides what a header's - * name is, so CRLF line endings and quoted names like ["PATH=/x"] are handled the way PHP handles them. + * PHP scopes every entry after such a header to it, even under later ordinary headers. The parser decides where each + * header ends and what it is called, so any line ending, quoted names and several headers on one line are handled. * * @param string $content ini content without BOM. * @@ -1697,16 +1689,21 @@ function ini_sets_globally( string $content, string $key ) { */ function ini_global_part( string $content ) { - if ( ! preg_match_all( '/^[ \t]*\[/m', $content, $matches, PREG_OFFSET_CAPTURE ) ) { - return $content; - } - - foreach ( $matches[0] as $match ) { - $eol = strpos( $content, "\n", $match[1] ); - // Text inside a multi-line quoted value doesn't parse as the last section. - $ini = @parse_ini_string( false === $eol ? $content : substr( $content, 0, $eol ), true, INI_SCANNER_NORMAL ); - if ( false !== $ini && is_array( end( $ini ) ) && preg_match( '/^(PATH|HOST)/i', (string) key( $ini ) ) ) { - return substr( $content, 0, $match[1] ); + for ( $open = strpos( $content, '[' ); false !== $open; $open = strpos( $content, '[', $open + 1 ) ) { + // A '[' inside a quoted value (or after a syntax error, where PHP stops anyway) starts no header. + if ( false === @parse_ini_string( substr( $content, 0, $open ), true, INI_SCANNER_NORMAL ) ) { + continue; + } + // A quoted name may contain ']', so try each one until the text up to it parses. + for ( $close = strpos( $content, ']', $open ); false !== $close; $close = strpos( $content, ']', $close + 1 ) ) { + $ini = @parse_ini_string( substr( $content, 0, $close + 1 ), true, INI_SCANNER_NORMAL ); + if ( false === $ini ) { + continue; + } + if ( is_array( end( $ini ) ) && preg_match( '/^(PATH|HOST)/i', (string) key( $ini ) ) ) { + return substr( $content, 0, $open ); + } + break; } } From b1792c36661ae30e5c8c155961f6cf44e8dd3317 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 20:31:57 +0530 Subject: [PATCH 03/14] fix(restore): name each section header from the header itself A section that reuses the name of an earlier global key replaces that key in place, so the last entry of the parsed array isn't the header just read. Name the header by parsing it alone, and count it only when it adds that section, which still leaves header-like text in a comment alone. --- src/helper/site-utils.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a23f13a9..e90fe01e 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1691,7 +1691,8 @@ function ini_global_part( string $content ) { for ( $open = strpos( $content, '[' ); false !== $open; $open = strpos( $content, '[', $open + 1 ) ) { // A '[' inside a quoted value (or after a syntax error, where PHP stops anyway) starts no header. - if ( false === @parse_ini_string( substr( $content, 0, $open ), true, INI_SCANNER_NORMAL ) ) { + $before = @parse_ini_string( substr( $content, 0, $open ), true, INI_SCANNER_NORMAL ); + if ( false === $before ) { continue; } // A quoted name may contain ']', so try each one until the text up to it parses. @@ -1700,7 +1701,11 @@ function ini_global_part( string $content ) { if ( false === $ini ) { continue; } - if ( is_array( end( $ini ) ) && preg_match( '/^(PATH|HOST)/i', (string) key( $ini ) ) ) { + // Named from the header alone: a section replacing an earlier key of the same name keeps that key's position. + $header = @parse_ini_string( substr( $content, $open, $close - $open + 1 ), true, INI_SCANNER_NORMAL ); + $name = is_array( $header ) && 1 === count( $header ) ? (string) key( $header ) : ''; + // In a comment the text parses as a header alone but adds no section. + if ( preg_match( '/^(PATH|HOST)/i', $name ) && is_array( $ini[ $name ] ?? null ) && ! is_array( $before[ $name ] ?? null ) ) { return substr( $content, 0, $open ); } break; From 5e0b969821ffceca0581dd8ba461ad7586cd8a35 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 20:44:46 +0530 Subject: [PATCH 04/14] fix(restore): detect section headers where the parser expects an entry Decide whether a '[' starts a section header by checking that the ini parser takes an entry at that point, instead of comparing the parsed array before and after the header. An earlier global array or key with the same name as a [PATH=] section no longer hides it, and header-like text in a comment, a value or an array key still doesn't count. --- src/helper/site-utils.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index e90fe01e..c16a7383 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1689,23 +1689,22 @@ function ini_sets_globally( string $content, string $key ) { */ function ini_global_part( string $content ) { + $probe = '__ee_ini_probe_' . md5( $content ); for ( $open = strpos( $content, '[' ); false !== $open; $open = strpos( $content, '[', $open + 1 ) ) { - // A '[' inside a quoted value (or after a syntax error, where PHP stops anyway) starts no header. - $before = @parse_ini_string( substr( $content, 0, $open ), true, INI_SCANNER_NORMAL ); - if ( false === $before ) { + // Only where the parser expects a new entry can '[' start a header: not in a quoted value, a comment or a value. + $probed = @parse_ini_string( substr( $content, 0, $open ) . $probe . ' = 1', true, INI_SCANNER_NORMAL ); + if ( false === $probed || ! array_key_exists( $probe, $probed ) && ! array_filter( $probed, function ( $entry ) use ( $probe ) { + return is_array( $entry ) && array_key_exists( $probe, $entry ); + } ) ) { continue; } // A quoted name may contain ']', so try each one until the text up to it parses. for ( $close = strpos( $content, ']', $open ); false !== $close; $close = strpos( $content, ']', $close + 1 ) ) { - $ini = @parse_ini_string( substr( $content, 0, $close + 1 ), true, INI_SCANNER_NORMAL ); - if ( false === $ini ) { + if ( false === @parse_ini_string( substr( $content, 0, $close + 1 ), true, INI_SCANNER_NORMAL ) ) { continue; } - // Named from the header alone: a section replacing an earlier key of the same name keeps that key's position. $header = @parse_ini_string( substr( $content, $open, $close - $open + 1 ), true, INI_SCANNER_NORMAL ); - $name = is_array( $header ) && 1 === count( $header ) ? (string) key( $header ) : ''; - // In a comment the text parses as a header alone but adds no section. - if ( preg_match( '/^(PATH|HOST)/i', $name ) && is_array( $ini[ $name ] ?? null ) && ! is_array( $before[ $name ] ?? null ) ) { + if ( is_array( $header ) && 1 === count( $header ) && preg_match( '/^(PATH|HOST)/i', (string) key( $header ) ) ) { return substr( $content, 0, $open ); } break; From 987a58f058fe8a4919f92a5432ab99b53ba193d4 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Fri, 23 Oct 2026 23:19:28 +0530 Subject: [PATCH 05/14] fix(migrations): turn off session upload progress where the v4.13.3 check missed it The v4.13.3 migrations of site-type-wp and site-type-php added `session.upload_progress.enabled = Off` to every custom.ini that didn't already set it globally, but their check took a setting inside a [PATH=] or [HOST=] section for a global one when the section header was quoted, shared its line with another header, was named like an earlier key, or ended with CRLF or CR, and it also counted an array entry keyed by the directive. Those sites still run with PHP's default On. Add a container migration, ordered after those, that checks the custom.ini of every WordPress and PHP site again with the parser from the restore fix and adds the same lines at the top where the directive still isn't set globally, creating the file when it is missing. A global On or Off is kept; a global On is logged. A site that already has the line is left alone, so a second run changes nothing. Each changed file is backed up under .backup/session-upload-progress-recheck-