Skip to content

fix(php): a package that allows PHP exec functions really allows them, and picks disabled functions one by one — GH #1701 - #1990

Merged
shukiv merged 5 commits into
mainfrom
feat-php-disabled-functions
Oct 4, 2026
Merged

shukiv merged 5 commits into
mainfrom
feat-php-disabled-functions

Conversation

@shukiv

@shukiv shukiv commented Oct 4, 2026

Copy link
Copy Markdown
Owner

GH #1701: the reporter's follow-up (comment 5944502718). It covers one bug and two requests.

Bug: the package's exec opt-out did not enable shell_exec

The cause is PHP Defense (Snuffleupagus). Its 00-base.rules bans system, exec, shell_exec, passthru, popen, proc_open and pcntl_exec outright. In enforce mode that ban overrode the package opt-out: the pool's disable_functions line was gone, but the call still aborted. Reproduced on the test box.

The fix: a pool whose package leaves any of those seven enabled now loads its own copy of active.rules, without the bans on the allowed functions.

  • An ini in the pool's own scan dir (/etc/php/<ver>/jabali-ext/<slug>/90-jabali-php-defense.ini) points that one FPM master at the copy. fpm-exec already adds that dir after the FPM conf.d.
  • Every other rule stays, including the eval blacklist, phpinfo, assert and the .php-write block. Only flat .drop() / .drop().simulation() lines are ever lifted.
  • The agent writes the copy on every pool apply. On every rules apply (mode or rule change) it rebuilds the copies, removing copies whose pool is gone. Pool remove and the orphan reaper delete it.
  • CLI PHP (cron, SSH) keeps the server-wide rules through cli.ini.
  • snuffleupagus.apply_rules now reports a failed copy rebuild even when a PHP pool also fails to reload. Before, the reload error hid it, and a stale copy kept the old mode.

Existing opt-out packages need no operator action. jabali update already runs jabali php pool reapply-all (update.go, GH #401 step), which re-applies every pool and writes the copies.

Disabled PHP functions, per function

The admin picks the package's list one function at a time.

  • New hosting_packages.php_disabled_functions (TEXT NULL, migration 000314, no backfill).
    • NULL means the default command-exec lockdown.
    • A row saved before this change keeps reading through php_exec_enabled.
  • php_exec_enabled is now derived from the list and written with it, so older readers stay correct. When both are sent, the list wins.
  • Names are lowercased and must match ^[a-z_][a-z0-9_]{0,63}$, at most 200. The agent re-validates them before they reach the pool conf.
  • API: php_disabled_functions on package create and update. On update, null resets to the default and an omitted field leaves the list alone. The fan-out to the package's pools runs only when the effective list changes.
  • CLI: jabali package create|edit --php-disabled-functions default|none|<list>. --php-exec maps onto the list.
  • Package editor: the all-or-nothing switch is replaced by:
    • checkboxes for the ten lockdown functions,
    • an "Also disable" tag field,
    • a reset link.
      The package list column shows a summary.

What a domain's PHP pool really runs with (read-only)

A new collapsed "Disabled functions and paths" section on the domain's PHP Settings page. The admin and the domain owner both see it.

  • New agent RPC php.pool.effective reads the files the pool's master loads: the pool conf, plus the FPM php.ini, conf.d and the pool's scan dir.
  • Endpoint: GET /domains/:id/php-settings/effective. It is owner-or-admin, cached for 15 s, and fetched only when the section opens.
  • For each function it shows the real status:
    • disabled by the package,
    • disabled server-wide (php.ini),
    • blocked by PHP Defense,
    • allowed but logged (simulation),
    • allowed.
  • include_path and session.save_path show with their source: the pool setting or the server php.ini.

Verification

  • Go: panel-api and panel-agent full suites pass.

    • New unit tests cover normalization, effective-list precedence, legacy rows, API create and update, the CLI flag, the agent filter, apply/move/remove/regenerate, the effective RPC and the endpoint.
    • Cross-boundary sync tests keep the lockdown list identical in Go (panel and agent) and TS.
  • UI: vitest passes. New Playwright spec php-disabled-functions.spec.ts runs in a real browser:

    • The editor PATCH sends the list and no php_exec_enabled.
    • The effective section fetches only when opened, and renders statuses and paths.
    • Falsified: both tests fail against a broken encoder and an eager query.
  • Test box (.60), PHP 8.4, PHP Defense in enforce, probes over FastCGI. I installed the binaries by hand and ran reapply-all as jabali update would.

    Case Result
    Legacy row (php_exec_enabled=1, list NULL) copy lifts all 7; shell_exec and exec run
    Kept ban in the copy (phpinfo) still aborts
    List with only shell_exec allowed, plus MAIL stored lowercase; php_exec_enabled derived 0; copy lifts only shell_exec; shell_exec runs, exec undefined, mail undefined
    php.pool.effective RPC 10 PD bans listed, shell_exec absent; paths from php.ini; ../etc slug refused
    Effective route 401 unauthenticated (route registered)
    Mode switch to simulation copy rebuilt in .simulation() form, lift kept; RPC reports logged
    --php-disabled-functions default copy and pointer removed; lockdown back in the pool conf; shell_exec undefined after the reload

    .60 was restored afterwards: original binaries, schema 308, PHP Defense back to simulation, drill files moved to /root/.trash.

https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj

shukiv added 5 commits October 4, 2026 07:11
…, and can pick functions one by one — GH #1701

A hosting package's "Allow PHP exec functions" (GH #402) removed the
pool's disable_functions line, but PHP Defense (Snuffleupagus) in
enforce mode bans system, exec, shell_exec, passthru, popen, proc_open
and pcntl_exec in every PHP-FPM master on its own. So on a box in
enforce mode the opt-out did nothing: shell_exec still aborted.
Reproduced on the test box: with the opt-out on and PHP Defense in
enforce, function_exists('shell_exec') is true, disable_functions is
empty, and the call is still dropped ("[disabled_function][drop]").

Disabled functions per package:
- hosting_packages.php_disabled_functions (migration 000314, TEXT
  NULL) holds the disable_functions list the package's pools run with.
  NULL = the GH #401 lockdown. An admin can allow any lockdown function
  back or disable more. Names are lowercased, de-duplicated and checked
  against ^[a-z_][a-z0-9_]{0,63}$ (at most 200).
- php_exec_enabled is now derived from the list (true when no lockdown
  function is disabled) and written with it, so an older binary reading
  the row keeps the right opt-out. A NULL list reads through
  php_exec_enabled, so existing opt-outs keep working with no backfill.
- REST: php_disabled_functions on create and update (string sets,
  null resets, omitted leaves it). It wins over php_exec_enabled; the
  old toggle still works and keeps other entries. CLI:
  --php-disabled-functions <list>|default|none on package create and
  edit, same rules. A change to the effective list re-renders the
  package's pools (REST) or marks them pending (CLI), as the toggle did.
- The two pool-apply callers send the list through one helper. The
  cPanel importer's direct apply is left to the next reconcile, as its
  comment already says.

PHP Defense per pool (agent):
- A pool whose disable_functions leaves any of those seven functions
  enabled gets /etc/jabali/snuffleupagus/pools/<slug>.rules: a copy of
  active.rules with only those flat bans commented out (enforce or
  simulation form; filtered rules and the eval blacklist stay), and an
  ini in its own scan dir (/etc/php/<ver>/jabali-ext/<slug>/
  90-jabali-php-defense.ini) pointing sp.configuration_file at it.
  fpm-exec already scans that dir after conf.d, so it applies to that
  master only. Checked on the test box in enforce: shell_exec runs
  through the copy, exec (still banned in it) aborts, and without the
  ini both abort; a USR2 reload is enough.
- The agent derives the lift from the disable_functions it renders, so
  the two cannot disagree, and only ever lifts those seven.
- snuffleupagus.apply_rules rebuilds every copy from the new rules
  before it reloads the pools, and removes copies whose pool is gone.
  Pool remove and the orphan reaper delete them too. The agent now
  refuses a disable_functions value that is not comma-separated
  lowercase function names.
- CLI PHP (cron, SSH) keeps active.rules through cli.ini.

Tests: model normalisation, effective list, legacy rows, derived flag
and pool params; REST create/update precedence, null reset, fan-out
only on a real change; repo allowlist; CLI create/edit; agent rule
filter (enforce + simulation lines, nothing outside the seven), apply/
version move/removal, regeneration with an orphan and a foreign file,
name validation, and the order of the wiring. The agent and panel
lists are pinned to each other.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
…e what a domain's PHP pool really runs with — GH #1701

Package editor: "Disabled PHP functions" replaces the "Allow PHP exec
functions" switch. The ten command-execution functions are checkboxes
(checked = disabled, all checked by default), with an "Also disable"
list for any other function and a reset to the default. It says which
ones also lift the PHP Defense ban. The editor sends the list (null at
the default) and no longer sends php_exec_enabled; a package saved with
the old switch on loads with every box unchecked. The package list's
"PHP exec" column shows "locked", "exec allowed" or "N exec allowed",
plus extra disabled functions, so a package that re-allows one function
no longer reads as fully locked.

PHP Settings (admin and tenant): a read-only, collapsed "Disabled
functions and paths" section, fetched only when opened, from the new
GET /domains/:id/php-settings/effective (owner or admin, cached 15 s
per pool). The agent's new php.pool.effective reads the files the
pool's master loads:
- disabled functions from the pool conf and from the FPM php.ini +
  conf.d + the pool's scan dir (FPM adds the pool's list to php.ini's),
  each marked as the hosting package's or server-wide;
- what PHP Defense bans there, from the rules file the pool is pointed
  at (its own copy or active.rules): blocked in enforce, logged in
  simulation, nothing when off or not loaded for that PHP version;
- include_path and session.save_path, from the pool conf (an admin's
  pool ini override) or php.ini.
The table lists the command-execution functions plus anything disabled
or banned, with the status a call really meets.

Tests: agent read (sources, admin_value over value, pool copy, a
pointer outside the copy dir ignored, no module, failed php.ini read),
argument checks; endpoint (owner, other tenant refused without an agent
call, admin, cache, versioned slug); vitest for the codec, summary,
table precedence, the section and the editor save.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
…and the read-only PHP environment — GH #1701

- Hosting packages: the php_disabled_functions field, its default, the
  derived php_exec_enabled and the CLI flag.
- PHP: how the package list reaches the pool and lifts PHP Defense's
  command-execution bans, and what the read-only section shows.
- PHP Defense: packages that allow command execution get their own
  copy of the rules; CLI PHP keeps the server-wide rules.
- User PHP Settings: the read-only "Disabled functions and paths".
- ADR-0146: the opt-out is now a list and holds in enforce mode.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
…the domain view shows the pool's real setup — GH #1701

Playwright, mocked API, real browser:
- admin unchecks shell_exec and adds mail on a package: the PATCH body
  carries php_disabled_functions in canonical order and no php_exec_enabled.
- tenant opens "Disabled functions and paths": the effective endpoint is
  read only when the section opens; per-function statuses (allowed, package,
  php.ini) and the include_path / session.save_path sources render.

Falsified: both tests fail against a build whose encoder keeps shell_exec
and whose query fires while collapsed.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
…PHP pool fails to reload — GH #1701

snuffleupagus.apply_rules returned only the reload error when both the
FPM reload and the per-pool rules rebuild failed. A pool unit that fails
to reload is a standing condition on some boxes (a leftover unit of a
deleted user), so every rebuild failure was hidden behind it, and a
pool's stale copy kept the old PHP Defense mode with no signal. Both
errors are now reported.

Claude-Session: https://claude.ai/code/session_0173PcNd4h6NceYPc4FuhvXj
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.

1 participant