Skip to content

Commit c56d164

Browse files
authored
feat(devtools): drop privileges to apache for OpenEMR CLI invocations (#743)
## Summary openemr/openemr#12267 introduces a `RootCliGuard` that refuses to let OpenEMR CLI scripts run as root: anything that loads `interface/globals.php`, runs through `bin/console`, or calls into the `Installer` class aborts with a `RuntimeException`. The failure it catches is the one that produced the original `/tmp/openemr-smarty` issue — PHP run as root creates files (cache dirs, generated keys, Smarty compile output) the web server later cannot read, surfacing as opaque fatal errors deep in template rendering or OAuth. This PR is openemr-devops's companion: it makes every OpenEMR CLI invocation across **flex (dev container)**, **8.1.1 (production)**, and **binary (rolling production)** drop privileges to the apache user before the script runs — including the container entrypoint's first-boot install path and the fsupgrade chain that runs when a container starts on an image with a newer docker-version than the persistent volume. Without this PR landed and the images rebuilt against an openemr master containing #12267, every fresh container would brick on first-boot (`auto_configure.php` hits the new Installer gate at startup), every devtools install / upgrade / multisite / API-docs command would throw, and every customer container crossing into a guard-bearing release would fail mid-upgrade. ## What changes Four classes of changes across each of the three images: ### 1. New `su-exec` dependency (all three Dockerfiles) `apk add --no-cache su-exec` (alpine main, 14 KB static binary). su-exec exec's a target program directly under a different uid/gid + supplementary groups with **no intervening shell** — argv reaches the target program verbatim, env is preserved automatically. It's what official postgres/mariadb/etc. Docker images use for the same job. `shadow` is already present in all three Dockerfiles, but alpine deliberately excludes shadow's `/usr/bin/su` to avoid clashing with busybox `/bin/su`. Busybox `su` itself can't be used in argv-passthrough mode (`su -c 'exec "$@"' arg0 …`) because its option parser rescans the entire arg list, absorbing inner `-c`, `-f`, `-s` flags as su's own — that's why we need a dedicated tool. ### 2. `utilities/devtoolsLibrary.source` — manual-invocation paths New helper: ```bash run_php_as_apache() { su-exec apache "$@" } ``` Affected `devtools` commands: | devtools command | Underlying script | Function | |---|---|---| | `dev-install` / `dev-reset-install` / `dev-reset-install-demodata` / `dev-sqldrive` / `dev-reset-install-sqldrive` | `php auto_configure.php` | `installOpenemr()` | | `upgrade <version>` | `php sql_upgrade.php` | `upgradeOpenEMR()` | | `dev-sqldrive` / `dev-reset-install-sqldrive` | `php import_ccda.php` | `demoDataDrive()` | | `generate-multisite-bank` | `php InstallerAuto.php` | `multiSiteBank()` | | `set-swagger-to-multisite` | `php bin/command-runner` | (flex devtools) | | `build-api-docs` | `php bin/console openemr:create-api-documentation` | (flex devtools) | ### 3. `openemr.sh` — first-boot install path (all three images) `openemr.sh:run_auto_configure()` invokes `auto_configure.php` directly at container startup. `auto_configure.php` instantiates the `Installer` class and calls `quick_install()` — which is now gated. **Without this fix, every fresh container would fail to install and the web UI would never come up.** Three coordinated adjustments per entrypoint: - Wrap the `php auto_configure.php …` invocation in `su-exec apache …`. Argv-passthrough means `${CONFIGURATION}` / `${config_args[@]}` reach php verbatim — values containing quotes, `$`, `;`, backticks, or control characters in (e.g.) `MYSQL_ROOT_PASS` pass through unmolested. - `chmod 0644 auto_configure.php` immediately before invocation. The Dockerfile sets the file to mode 000 as a safety measure (root could read it regardless); apache cannot, so a brief loosen-then-run sequence is needed. The file is `rm -f`'d at the end of the entrypoint either way. - `chown apache:apache` + `chmod 0700` the opcache file-cache dir (`/tmp/php-file-cache`) after `mkdir -p`. apache is the only writer (under su-exec). Cache dir is `rm -rf`'d at the end of the entrypoint either way. ### 4. `upgrade/fsupgrade-*.sh` — automatic upgrade path on container restart (8.1.1 + binary only) 18 scripts (10 in 8.1.1, 8 in binary). Each builds a `TEMPsql_upgrade.php` from the on-disk `sql_upgrade.php` and runs it via `php -f` to migrate the database. `openemr.sh:run_upgrade()` invokes these on container startup whenever the persisted `docker-version` is older than the image's — without the wrap, an existing customer container starting on a guard-bearing release would abort partway through the upgrade chain. Each script's `php -f TEMPsql_upgrade.php` is wrapped inline in `su-exec apache php -f …`. ## Scoping Only flex, 8.1.1, and binary are patched. 7.0.4, 8.0.0, and 8.1.0 are intentionally out of scope per discussion — those aren't expected to be rebuilt against a guard-containing openemr release. ## Iteration history (what local rebuild caught) This PR went through several iterations driven by local-rebuild validation against an openemr master containing #12267. Each iteration's bug was invisible from PR diff review alone but immediately apparent on container boot: 1. `openemr.sh:run_auto_configure` ran auto_configure.php as root → tripped the new Installer gate. Fixed: wrap in privilege drop. 2. Dockerfile's `chmod 000 auto_configure.php` safety measure left the script apache-unreadable. Fixed: `chmod 0644` immediately before the invocation. 3. `mkdir -p /tmp/php-file-cache` default mode 0755 → apache couldn't write opcache files. Fixed: chown apache + chmod 0700 (originally `chmod 0777`; tightened to least-privilege after Copilot review). 4. Copilot review flagged shell-injection / quoting risk in the `su -c "…"` interpolation pattern (real concern: passwords with `"` or `$` would mis-parse). Multiple attempts at the `su -c 'exec "$@"' …` argv-passthrough form failed against busybox `su`'s option rescanning. Resolved by adopting `su-exec` (14 KB alpine package) for direct argv-passthrough with no shell parsing at all. ## Test plan End-to-end **local rebuild validation** completed against both image classes after each substantive iteration: ### flex (dev) — gate-firing scenario Built `openemr/openemr:flex` locally from this branch; ran against an openemr master containing openemr/openemr#12267 via the dev-easy stack (volume-mounted source provides the new gate). Recycled the stack with volumes removed. - Container boot: install completed cleanly through `su-exec`. Zero `RootCliGuard` / `RuntimeException` in container logs. Login page returns HTTP 200. - Devtools end-to-end: - `build-api-docs` → swagger yaml generated (exercises `bin/command-runner` + `bin/console openemr:create-api-documentation`). - `upgrade 7.0.0` → all schema migrations 7.0.0 → current complete (exercises `sql_upgrade.php`). - `generate-multisite-bank 1` → multisite `run1` created (exercises `InstallerAuto.php`). - Negative test: bare `php bin/console list` and bare `php sql_upgrade.php …` as root both throw the expected `RootCliGuard` `RuntimeException` — confirms the gate still bites for unwrapped invocations. - Post-test web UI + swagger UI both still HTTP 200; container log scan over the test window: zero `permission denied` / `fatal` errors. ### 8.1.1 (production) — backward-compatibility scenario Built `openemr/openemr:8.1.1` locally from this branch; ran via `docker/production/` compose against the image's baked-in 8.1.1 openemr source (which predates the gate). Tore down with `--volumes` after. - Container boot: install completed, status `running (health: healthy)`, login page returns HTTP 200. - Log scan: zero `RootCliGuard` / `RuntimeException` / `opcache.file_cache` / install errors. - Confirms the openemr.sh + devtools changes don't regress current production-image behavior. When 8.1.1 is eventually rebuilt against an openemr master containing #12267, the changes become load-bearing. ### binary (rolling production) — not directly tested Structurally identical to 8.1.1 (just `php` → `/usr/local/bin/php`). Changes mirror 8.1.1's; relies on that symmetry rather than a separate build cycle. ## Related - **openemr/openemr#12267** — establishes the `RootCliGuard`. Required for this PR's wraps to be load-bearing rather than hygiene. - **openemr/demo_farm_openemr#109** (merged) — same privilege-drop pattern in `demo_build.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 55e402c commit c56d164

28 files changed

Lines changed: 256 additions & 49 deletions

docker/openemr/8.1.1/Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ RUN apk --no-cache upgrade
4646
# dcron (scheduled tasks), git (version control), imagemagick (image processing),
4747
# mariadb-client (database client), mariadb-connector-c (DB connector), ncurses (terminal),
4848
# nodejs/npm (JavaScript runtime), openssl/openssl-dev (cryptography), perl (interpreter),
49-
# rsync (file sync), shadow (user management), tar (archives)
49+
# rsync (file sync), shadow (user management), su-exec (lightweight privilege-drop
50+
# tool used by run_php_as_apache to invoke OpenEMR CLI scripts as the apache user
51+
# without an intermediate shell — busybox `su` rescans options across the whole
52+
# arg list, which breaks argv-passthrough), tar (archives)
5053
RUN apk add --no-cache \
5154
apache2 \
5255
apache2-proxy \
@@ -68,6 +71,7 @@ RUN apk add --no-cache \
6871
perl \
6972
rsync \
7073
shadow \
74+
su-exec \
7175
tar
7276

7377
# Install PHP and all required extensions for OpenEMR

docker/openemr/8.1.1/openemr.sh

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,11 @@ run_auto_configure() {
578578
# Create temporary file cache directory for opcache
579579
TMP_FILE_CACHE_LOCATION="/tmp/php-file-cache"
580580
mkdir -p "${TMP_FILE_CACHE_LOCATION}"
581+
# Apache is the only writer (we drop to apache below via su -p);
582+
# chown and restrict the dir to that user. The dir is rm -rf'd at
583+
# the end of this entrypoint either way.
584+
chown apache:apache "${TMP_FILE_CACHE_LOCATION}"
585+
chmod 0700 "${TMP_FILE_CACHE_LOCATION}"
581586

582587
# Create optimized PHP configuration for installation
583588
{
@@ -596,12 +601,21 @@ run_auto_configure() {
596601
# Update heartbeat right before the long-running PHP command
597602
[[ "${AUTHORITY}" = "yes" ]] && update_leader_heartbeat
598603

599-
# Run auto_configure with optimized PHP settings
600-
# Note: CONFIGURATION is a space-separated string like "server=mysql rootpass=root loginhost=%"
601-
# We need to split it and pass as separate arguments, not use -f flag (which doesn't exist)
602-
# Split CONFIGURATION into an array and pass each element as a separate argument
604+
# Run auto_configure with optimized PHP settings.
605+
# Drop privileges to apache: auto_configure.php goes through the
606+
# Installer class, which openemr#12267's RootCliGuard refuses to
607+
# run as root. `su-exec` exec's the program directly with no
608+
# intervening shell, preserving env and passing each arg
609+
# verbatim — see run_php_as_apache in devtoolsLibrary.source for
610+
# the rationale on using su-exec instead of busybox su.
611+
# The Dockerfile sets auto_configure.php to mode 000 as a safety
612+
# measure (root can read regardless); briefly chmod to 0644 so
613+
# apache can read it. The file is rm'd at the end of this
614+
# entrypoint either way.
615+
chmod 0644 auto_configure.php
603616
read -r -a config_args <<< "${CONFIGURATION}"
604-
php -c auto_configure.ini auto_configure.php "${config_args[@]}" || return 1
617+
su-exec apache \
618+
php -c auto_configure.ini auto_configure.php "${config_args[@]}" || return 1
605619

606620
# Update heartbeat after PHP execution completes
607621
[[ "${AUTHORITY}" = "yes" ]] && update_leader_heartbeat

docker/openemr/8.1.1/upgrade/fsupgrade-1.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
5454
-e "s/!empty(\$_POST\['form_submit'\])/empty(\$_POST\['form_submit'\])/" \
5555
-e "s/^[ ]*\$form_old_version[ =].*$/\$form_old_version = \"${priorOpenemrVersion}\";/" \
5656
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
57-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
57+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
58+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
5859
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
5960
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
6061
done

docker/openemr/8.1.1/upgrade/fsupgrade-10.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
3333
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
3434
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
3535
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
36-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
36+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
37+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
3738
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
3839
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
3940
done

docker/openemr/8.1.1/upgrade/fsupgrade-2.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

docker/openemr/8.1.1/upgrade/fsupgrade-3.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

docker/openemr/8.1.1/upgrade/fsupgrade-4.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

docker/openemr/8.1.1/upgrade/fsupgrade-5.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

docker/openemr/8.1.1/upgrade/fsupgrade-6.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

docker/openemr/8.1.1/upgrade/fsupgrade-7.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ for dirdata in /var/www/localhost/htdocs/openemr/sites/*/; do
4040
} | sed -e "s@!empty(\$_POST\['form_submit'\])@true@" \
4141
-e "s@\$form_old_version = \$_POST\['form_old_version'\];@\$form_old_version = '${priorOpenemrVersion}';@" \
4242
> /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43-
php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
43+
# Drop privileges to apache: RootCliGuard (openemr#12267) refuses root for OpenEMR CLI scripts.
44+
su-exec apache php -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4445
rm -f /var/www/localhost/htdocs/openemr/TEMPsql_upgrade.php
4546
echo "Completed: Upgrade database for ${sitename} from ${priorOpenemrVersion}"
4647
done

0 commit comments

Comments
 (0)