Skip to content

fix(restore): keep session upload progress off after restoring an older backup - #520

Open
mrrobot47 wants to merge 14 commits into
EasyEngine:developfrom
mrrobot47:fix/restore-keeps-upload-progress-off
Open

mrrobot47 wants to merge 14 commits into
EasyEngine:developfrom
mrrobot47:fix/restore-keeps-upload-progress-off

Conversation

@mrrobot47

@mrrobot47 mrrobot47 commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

What

After ee site restore restores a site's PHP config, add session.upload_progress.enabled = Off back to the top of its custom.ini unless the restored file already sets the directive.

Also add a one-off container migration that checks every WordPress and PHP site's custom.ini again with the same parser and adds the line where the v4.13.3 migrations missed it.

Why

v4.13.3 turned session upload progress off by adding session.upload_progress.enabled = Off to every site's custom.ini (EasyEngine/site-type-wp#246, EasyEngine/site-type-php#120). EasyEngine/dockerfiles#374 makes Off the default in the images' php.ini too, which covers new sites but not a restore, since a restore also brings back the backup's php.ini. PHP's informational advisory of 2026-10-23 for CVE-2026-115560 asks to keep it off until the next regular bugfix release.

restore_php_conf() copies custom.ini and php.ini from the backup over the site's files. A backup taken before v4.13.3 has neither the line in custom.ini nor a php.ini that sets it, so the restored site runs with upload progress On again, PHP's default. Shown on a runner with develop: a WordPress site with a pre-v4.13.3 custom.ini was backed up, upgraded to the v4.13.3 custom.ini (FPM reports Off), then restored: the line was gone and FPM reported On.

How

  • New helpers in site-utils.php: add_session_upload_progress_off() returns the content with the setting added, or null when the content already sets it outside a [PATH=]/[HOST=] section; ini_sets_globally() and ini_global_part() decide that. The rule is the v4.13.3 migrations' rule: an effective global line, On or Off, is the user's choice and is kept; a commented-out line, another spelling, or a setting only inside a [PATH=]/[HOST=] section doesn't count. The lines go at the very top, so neither such a section nor a multi-line quoted value can capture them; CRLF, a UTF-8 BOM and the rest of the file are kept.
  • ini_global_part() lets PHP's ini parser decide where each section header starts, ends and what it is called: a [ counts only where the parser would take a new entry, and the header is named by parsing it alone. The migrations' version only recognised a header that was unquoted and alone on an LF-terminated line, so in a CRLF or CR-only file, with a quoted name like ["PATH=/x"], or with [PATH=/x][Session] on one line, a setting inside the section counted as global and the line wasn't added. The part before such a section is read without sections, so an array entry keyed by the directive doesn't count either.
  • restore_php_conf() calls the new keep_session_upload_progress_off() after copying the files. It writes custom.ini in place (owner, mode and inode kept) through a checked handle (see below), puts the restored file back if a write fails part way, warns with the manual fix when the file is missing or can't be read or written, and logs when it adds the line. The site reload at the end of the restore applies it.

Re-check migration

The v4.13.3 migrations (EasyEngine/site-type-wp#246, EasyEngine/site-type-php#120) used the older header check, so they skipped every custom.ini whose only session.upload_progress.enabled line is inside a [PATH=]/[HOST=] section with a quoted header, two headers on one line, a header named like an earlier key, or CRLF/CR line endings, and every file with an array entry keyed by the directive. Those sites still run with upload progress On. Hosts that upgrade from v4.13.2 run those migrations first, so the new one is ordered after them.

migrations/container/20261023233000_site-command_recheck_session_upload_progress.php:

  • Goes through every wp and php site, enabled or disabled, and adds the same two lines at the top of custom.ini where the directive isn't set outside a [PATH=]/[HOST=] section. A missing custom.ini gets a warning with the manual fix instead of being created; the v4.13.3 migrations already created it wherever conf.d existed. A global On or Off is kept; a global On is logged, naming the site. A file 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-<time>/<site>/custom.ini and rewritten in place, keeping owner, mode and inode. down() restores the files byte for byte, so core reverts it when a later upgrade step fails.
  • Running php containers of enabled sites get the graceful FPM reload the v4.13.3 migrations use, after the image migration and only when the container wasn't recreated. Disabled and stopped sites read the file when they start.
  • A failure on one site is a warning with the manual fix, and that site's file is restored; the other sites go on.
  • custom.ini and the directories above it, up to config/php, are in the site's config volume, which its php container can change. New helpers in site-utils.php, used by both the restore and the migration, open the file once and check that the handle is the regular, singly linked file in that site's conf.d before anything is read or written through it: on Linux by the kernel's path of the open file (/proc/self/fd), which a directory swapped for a symlink and back around the open can't fake; elsewhere by resolving conf.d before and after the open. Reads stop at 64 KB. So a symlink, hard link or swapped directory doesn't send a write anywhere else on the host. Nothing is created in that directory, because PHP's fopen() and link() expand the path to an absolute one, which a swapped directory could redirect before any check. A file is opened for writing only when the line has to be added, so a read-only file that already sets the directive is fine. Files over 64 KB are skipped with a warning.

The helpers also gained ini_global_value(), so the migration can tell an explicit On from an Off. A bare [PATH] or [HOST] header (nothing after the word) doesn't scope anything in PHP, and the helpers now treat entries under it as global too.

Testing

  • Case matrix for the helpers on PHP 7.4 and 8.5 (CI) and 8.5 locally: empty or missing file, the v4.13.2 and v4.13.3 templates (LF, CRLF, BOM, the 5.6 template), explicit On/Off/1/"0", commented-out (; and #), wrong case, other session.upload_progress.* keys, a setting only in [PATH=], ["PATH=..."], ['PATH=...'], [path=...], [PATH = ...], [HOST=...] and ["HOST=..."] (LF, CRLF and CR-only), [PATH=...][Session] on one line, quoted PATH names containing a newline or ], a CR before a header, an array entry keyed by the directive (custom_flags[session.upload_progress.enabled]), a PATH header named like an earlier global key or array, PATH-like text in comments, values and array keys, an ordinary section after a PATH one, [ PATH=...] (global for PHP), a PATH-like line inside a multi-line quoted value or a comment, a [ inside a quoted value before a real header, and settings before and after a syntax error. Each added result is a no-op on a second run, sets the directive globally, keeps line endings, BOM and the original lines, and parses as Off with real PHP. The migrations' version of the check gets 15 of these cases wrong.
  • The global/scoped verdict of every case was checked against real PHP-FPM (easyengine/php8.4), requesting a script inside and outside the section: all match.
  • The real restore_php_conf() with stubs: pre-v4.13.3 backup, v4.13.3 backup, explicit On, CRLF [PATH=] On, ["PATH=..."] On, backup without custom.ini; develop leaves three of them On, this branch none.
  • Re-check migration, in isolation on PHP 7.4 and 8.5 against the real v4.13.3 migration: the case matrix above (56 entries), starting both from the state the v4.13.3 migration leaves and from the untouched files, on enabled and disabled wp and php sites, plus a missing custom.ini, a missing conf.d, a symlinked or hard-linked custom.ini, a hard-linked file that already sets Off, a symlinked conf.d and php/ pointing out of the site, a file over 64 KB, and a site whose container lookup throws. It changes exactly the files that lack a global setting (15 of them after v4.13.3, 34 untouched), keeps everything else, the backups equal the old files, a second run and its down() change nothing, down() restores every file byte for byte, and only running enabled sites whose container wasn't recreated get reloaded. Every end state was served by real PHP-FPM (easyengine/php8.4): the global value is Off for all of them except the seven that set a global On or "0" themselves, which keep it; before the migration the same requests show On for those 15 and 34 files.
  • The file helpers under a race: conf.d flipped between the real directory and a symlink to an outside directory about 44,000 times while open-and-write ran in a loop; the outside file was never written (with only the before/after path checks, it was).
  • Re-check migration end to end on a fresh runner: sites with the 15 layouts (two of them WordPress, one disabled), an explicit global On and an Off control, created with core develop, which then re-ran the v4.13.3 migrations: they left all 15 files unchanged and FPM still reported On. Then this branch: an upgrade that fails in the image migration reverts the migration, and every file, its mode, owner and inode, the containers and FPM's values are as before; the next upgrade turns all 15 Off in FPM without restarting a container, keeps the values inside the [PATH=] sections, keeps the explicit On (logged), leaves the control alone, and the disabled site reports Off once enabled; a second run changes nothing and reloads nothing.
  • On a fresh runner with core develop: a WordPress site with a pre-v4.13.3 custom.ini restored with this branch gets the line back at the top and FPM reports Off; a PHP site whose backup sets session.upload_progress.enabled = On (changed to Off after the backup) is restored with On kept and FPM reports On. The site-command Behat suite passes, also with three extra scenarios for the migration (a CRLF [PATH=]-only file gets Off, a second run changes nothing, an explicit On is kept).

…er 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Verified INI-classification errors can skip the mitigation and leave session upload progress globally enabled.

2 open issues
What changed in this PR

This PR restores the session upload-progress mitigation when older backups overwrite a site's PHP configuration, while preserving explicit global settings.

Changes:

  • Add INI helpers to distinguish global directives from PATH/HOST-scoped settings.
  • Check restored custom.ini, prepend Off when needed, and warn when the file cannot be updated.
File Description
src/​helper/​site-utils.php Adds INI inspection and upload-progress mitigation helpers.
src/​helper/​Site_Backup_Restore.php Applies the mitigation after restoring PHP configuration.

Review effort: Balanced

Comment thread src/helper/site-utils.php Outdated
Comment thread src/helper/site-utils.php Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive INI parser has an unresolved scope-detection edge case and needs final human validation across supported PHP versions.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Handle scoped headers colliding with existing global keys

src/​helper/​site-utils.php:1704

A scoped header is missed when its name already exists as a global key. For example:

PATHprivate = ignored
[Session]
[PATHprivate]
session.upload_progress.enabled = Off

Parsing [PATHprivate] replaces the earlier PATHprivate entry without moving it after Session, so end($ini) still sees Session. The helper then treats the scoped directive as global and returns null. If the restored php.ini leaves upload progress enabled, restore skips the mitigation and it stays enabled globally. Identify the header just parsed independently of the result array's insertion order, and add this collision case to the regression checks.

2 resolved since last review

Review effort: Balanced

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.
@mrrobot47

Copy link
Copy Markdown
Member Author

The header-name collision case (PATHprivate = ignored, [Session], [PATHprivate]) is fixed in b1792c3: each header is now named by parsing it alone and only counts when it adds that section, so the scoped directive is no longer taken as global. The case is in the regression matrix and matches php-fpm.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A scoped-header detection bug can skip the safeguard and leave session upload progress globally enabled.

1 open issue

Review effort: Balanced

Comment thread src/helper/site-utils.php Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive INI parsing and restore behavior need final human validation across supported PHP runtimes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

…heck 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-<time>/ and written in place, keeping owner, mode and inode. down() restores the files byte for byte. Running php containers of enabled sites get the same graceful FPM reload as in the v4.13.3 migrations, after the image migration and only when the container wasn't recreated. A failure on one site is a warning and its file is restored; the other sites go on.

ini_global_value() returns the value next to ini_sets_globally(), so the migration can tell an explicit On from an Off.
…lobal

PHP scopes entries only to a section whose name has something after PATH or HOST. Under a header named just [PATH] or [HOST], in any case, php-fpm applies them globally, but ini_global_part() cut the global part there, so a global On under such a header was taken as unset and the Off line was added in front of it for nothing.
…ration

custom.ini lives in a directory the site's php container can write to, so a symlink or hard link can appear there between the checks and the write, and the migration runs as root on the host. Read, back up, rewrite and restore the file through one handle, checked after opening to be the regular, singly linked file at that path, the same way the session ID migration handles php.ini. A missing custom.ini is created under an unguessable name and hard-linked into place, because fopen() follows a dangling symlink even in 'x' mode; its owner is set with lchown().

A failed restore inside the per-site error path now only warns instead of aborting the upgrade, the log names the backup directory only when a file was backed up, and the explicit-On check follows PHP's atoi() fallback for values like "1e-5".
…k migration

Only config/php is EasyEngine's own link to the site's config volume; php/ and conf.d below it belong to the container, which can swap either for a symlink to a host directory. Resolve conf.d before and after each open and refuse anything outside the site's config volume, so the migration never creates, rewrites, restores or removes a custom.ini elsewhere on the host. A created file is removed on rollback only while it is still the same file in that directory.

Read custom.ini read-only first and reopen it for writing only when the line has to be added, so a read-only or hard-linked file that already sets the directive is left alone without a warning; a file with other hard links is still never written. Skip files over 64 KB with a warning, since the check re-parses the text before every '['. A write that fails and can't be undone is left for down() but no longer counted or reloaded as done, and a failed backup cleanup no longer reads as a failed restore.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

File-access races can permit host-file overwrites, and write-failure paths can leave incomplete configurations without reliable recovery.

4 open issues

Review effort: Balanced

Comment thread src/helper/Site_Backup_Restore.php Outdated
Comment thread src/helper/Site_Backup_Restore.php Outdated
The restored custom.ini sits in the site's config volume, which its php container can change: between the checks and the write it could swap the file for a symlink, or conf.d for a symlink to a host directory, and file_put_contents() as root would follow it. A short write also left only part of the file behind.

Add helpers that open such a file once and verify the handle is the regular, singly linked file in the site's conf.d before anything is read or written through it: on Linux by the kernel's path of the open file from /proc/self/fd, which a directory swapped around the open can't fake, elsewhere by checking the path before and after. A missing file is created under an unguessable name and hard-linked into place, since fopen() follows a dangling symlink even in 'x' mode. The restore now reads read-only first, writes through the handle, and on a failed write puts the restored file back or removes the new one.
Its own handle checks compared paths before and after opening, which a container that swaps conf.d for a host symlink and back around the open can beat; the shared helpers also check the kernel's path of the open file. Clear PHP's realpath cache along with the stat cache before each check, and remove a custom.ini the migration created when writing it fails.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Ownership and deletion operations remain vulnerable to directory-swap races, and configuration reads need enforced size limits.

2 open issues
2 previously missed issues

In code that hasn't changed since last review

Medium severity Unbounded reads bypass MAX_SIZE and can exhaust memory

migrations/​container/​20261023233000_site-command_recheck_session_upload_progress.php:207

The fstat() check does not enforce MAX_SIZE on this read. The site's PHP container can grow the same file after the check, allowing stream_get_contents() to allocate oversized content and pass it to the repeatedly reparsing helpers. Read at most MAX_SIZE + 1 bytes and reject an oversized result before parsing. Also bound the pre-write comparison at line 237 to MAX_SIZE + 1 bytes so growth during parsing cannot exhaust memory there instead.

Medium severity Unbounded restore parsing can exhaust memory or cause quadratic work

src/​helper/​Site_Backup_Restore.php:1741

Unlike the migration, restore reads the entire custom.ini before invoking ini_global_part(), which reparses the preceding content for every [. A large restored file with many array entries can exhaust CLI memory or stall restore with quadratic parsing work. Bound the initial read to 65537 bytes and warn/skip when it exceeds 64 KB. Bound the pre-write comparison read too, so a file that grows during parsing cannot cause an unbounded allocation there.

4 resolved since last review

Review effort: Balanced

Comment thread src/helper/site-utils.php Outdated
… directory

remove_container_file() and the owner change of a newly created custom.ini went by path after their checks, so a container that swapped conf.d or php/ for a host symlink in between could have a custom.ini outside the site removed or chowned. Both now enter the directory, check with getcwd() that it is the site's conf.d, and work on the bare file name, which keeps reaching that directory whatever its path points to later.

Reads of the file are capped at 64 KB (read_container_file()), so a file the container keeps growing can't exhaust memory or stall the ini check; the restore warns and leaves a larger custom.ini as it is.
…h the helpers

The size check used fstat() before reading, but the container can grow the file after it; read at most 64 KB plus one byte instead, also when comparing before the write and when restoring. Set the owner of a created custom.ini with chown_container_file().

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The shared file helpers still allow outside-directory file creation during a directory swap and unbounded verification reads.

2 open issues
2 resolved since last review

Review effort: Balanced

Comment thread src/helper/site-utils.php Outdated
Comment thread src/helper/site-utils.php Outdated
Creating the file can't be confined to the site's conf.d from PHP: fopen() and link() expand the path to an absolute one, so a conf.d swapped for a host symlink at the wrong moment gets the new file outside, before any check of the open handle can catch it. A site without custom.ini now gets the warning with the manual fix instead; the restore only copies custom.ini from a backup that has one, so the site keeps its own. With nothing created, the helpers that removed and chowned created files are gone.

The read-back after a write stops one byte past the new content, so data the container appends in the meantime can't make it unbounded.
A missing custom.ini is now skipped with a warning and the manual fix, like a symlinked one, instead of being created, for the same reason as in the restore: PHP can't create it safely in a directory the site's container can swap. The v4.13.3 migrations already created it wherever conf.d existed, so this only leaves the sites they warned about. Every changed file now has a backup.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive INI parsing, privileged filesystem handling, and rollback behavior require final human validation.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread src/helper/site-utils.php
throw new \Exception( "a directory above $name is a symlink" );
}

$handle = @fopen( $file, $mode );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants