Skip to content

fix: exec()/shell_exec() crash on hardened PHP; add Telegram channel adapter - #10

Open
rjonesbsink wants to merge 2 commits into
openises:mainfrom
rjonesbsink:fix/exec-shell-crashes-schema-telegram
Open

fix: exec()/shell_exec() crash on hardened PHP; add Telegram channel adapter#10
rjonesbsink wants to merge 2 commits into
openises:mainfrom
rjonesbsink:fix/exec-shell-crashes-schema-telegram

Conversation

@rjonesbsink

@rjonesbsink rjonesbsink commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Fixes #5. disable_functions = shell_exec, exec, system, passthru, popen is a common hardening default on Windows/IIS PHP installs. PHP's @ suppression doesn't catch the resulting "Call to undefined function" fatal error, so every call listed below crashed outright — several mid-request with display_errors off, producing a completely empty HTTP response body (surfaces client-side as Unexpected end of JSON input with zero indication why).

Replaced each exec()/shell_exec() call with a small proc_open()-based helper (argv array, no shell, so no escapeshellarg either), guarded by function_exists('proc_open'). proc_open isn't typically included in hardening presets that disable exec/shell_exec, so this restores full functionality rather than just degrading gracefully.

Changes

  • sql/run_migrations.php — subprocess-per-migration runner
  • tools/install_fresh.php — invokes sql/run_migrations.php
  • tools/check-schema.php — same pattern, twice (--repair run + the fresh-process re-check)
  • api/health.phpcheckOs()'s uptime probe. This one is user-facing: it broke status.php (System Status page) with the JSON-parse error above. Bonus fix while in here: wmic doesn't exist at all on Windows 11 24H2+ — added a PowerShell Get-CimInstance fallback, formatted to the same 14-digit yyyyMMddHHmmss shape wmic used, so the existing parser needed no changes.
  • inc/tts/engine.phptts_bin_on_path() which/where binary detection
  • proxy/ZelloProxyApp.phpbinOnPath(), identical pattern

Also adds inc/channels/telegram.php: the Settings → Telegram panel has a full config UI (token, chat ID, a "Send Test" button) but no backend at all. Every other channel (slack, sms, email, smtp, push, aprs, dmr, meshtastic, meshcore, local_chat) has an adapter in inc/channels/ that self-registers via broker_register(), auto-loaded by inc/broker.php. Telegram had none, so even with the test button wired up, api/chat.php's test_channel action rejected it with Unknown or unregistered channel: telegram — and real incident/PAR/system alerts never reached Telegram either, silently. The new file mirrors inc/channels/slack.php's structure (Bot API sendMessage over cURL).

  • assets/js/config.js — wires up the missing #btnTestTelegram click handler in loadTelegramConfig(). Compare #btnTestSlack's handler immediately below it in the same file, which already existed. Clicking "Send Test" for Telegram previously did nothing at all.

About the base commit

This branch is built on the tree just before 1c56498 (the dashboard_tables.sql fix + install_fresh.php error-surfacing), per your comment on #5 saying you'd rather resolve any overlap yourself on merge rather than have me rebase onto it. Turns out there isn't any real overlap to resolve — your changes landed in the $importSqlFile closure's catch block, mine touches the migration-invocation step further down — so GitHub reports this as cleanly mergeable as-is. sql/dashboard_tables.sql itself isn't touched by this PR at all, since your fix already covers it.

Testing

Verified against a live Windows/IIS install with the hardening preset above:

  • php tools/install_fresh.php and php sql/run_migrations.php run clean end-to-end
  • php tools/check-schema.php --repair completes without crashing
  • System Status page (status.php) loads without the JSON-parse error
  • Settings → Telegram → Send Test delivers an actual message to a real Telegram group

disable_functions = shell_exec, exec, system, passthru, popen is a common
hardening default on Windows/IIS PHP installs. PHP's @ suppression does
not catch the resulting "Call to undefined function" fatal error, so
every call below crashed outright — several of them mid-request with
display_errors off, producing a completely empty HTTP response body
(surfaces client-side as "Unexpected end of JSON input" with no clue why).

Replaced each exec()/shell_exec() call with a small proc_open()-based
helper (argv array, no shell, so no escapeshellarg either), guarded by
function_exists('proc_open'). proc_open isn't typically included in
hardening presets that disable exec/shell_exec, so this restores full
functionality instead of just degrading gracefully.

- sql/run_migrations.php: subprocess-per-migration runner
- tools/install_fresh.php: invokes sql/run_migrations.php
- tools/check-schema.php: same pattern, twice (--repair run + the
  fresh-process re-check)
- api/health.php: checkOs() uptime probe — user-facing, this is what
  broke status.php with the JSON-parse error above. Bonus fix while in
  here: wmic doesn't exist at all on Windows 11 24H2+, added a
  PowerShell Get-CimInstance fallback formatted to the same
  yyyyMMddHHmmss shape wmic used, so the existing parser needed no
  changes.
- inc/tts/engine.php: tts_bin_on_path() which/where binary detection
- proxy/ZelloProxyApp.php: binOnPath(), identical pattern

Also adds inc/channels/telegram.php: the Settings -> Telegram panel has
a full config UI (token, chat ID, a "Send Test" button) but no backend.
Every other channel (slack, sms, email, smtp, push, aprs, dmr,
meshtastic, meshcore, local_chat) has an adapter in inc/channels/ that
self-registers via broker_register(), auto-loaded by inc/broker.php.
Telegram had none, so even with the test button wired up (config.js
change below), api/chat.php's test_channel action would reject it with
"Unknown or unregistered channel: telegram" — and real incident/PAR/
system alerts never reached Telegram either, silently. New file mirrors
inc/channels/slack.php's structure (Bot API sendMessage over cURL).

- assets/js/config.js: wires up the missing #btnTestTelegram click
  handler in loadTelegramConfig() — compare the #btnTestSlack handler
  immediately below it in the same file, which already existed.
  Clicking "Send Test" for Telegram previously did nothing at all.

Verified against a live Windows/IIS install with the hardening preset
above: migrations/install/schema-check all run clean, System Status
loads without error, Telegram Send Test delivers a real message.

Based on the tree prior to 1c56498 (the dashboard_tables.sql /
install_fresh.php error-surfacing fix) per your note on openises#5 that you'd
rather resolve any overlap there yourself on merge.

Fixes openises#5
@rjonesbsink
rjonesbsink force-pushed the fix/exec-shell-crashes-schema-telegram branch from c47977a to ef10248 Compare July 28, 2026 04:16
@rjonesbsink
rjonesbsink marked this pull request as ready for review July 28, 2026 04:17
Per @ejosterberg's audit on openises#7: telegram_bot_token matches the _token$
suffix in is_secret_setting_key(), so the server has always masked it
(GET settings returns telegram_bot_token_set instead of the value).
loadTelegramConfig() didn't know that — it read settings.telegram_bot_token
directly (always undefined) and rebuilt the save payload by hand with no
"blank means keep" guard, so saving the Telegram panel for any reason
(e.g. just updating the chat ID) silently wiped the bot token. This is
the same bug as feed_api_key, just via bespoke JS instead of the generic
data-key path.

Fix: give both Telegram fields data-key (telegram_bot_token gets
data-secret="1" too, matching every other credential field), and switch
loadTelegramConfig() to the generic applySettingsToForm()/
collectSettingsFromForm() helpers plus the same manual placeholder
handling feed_api_key needed (applySettingsToForm doesn't render a
"stored" placeholder from the _set sentinel on its own).

Verified against tests/test_settings_secret_fields.php from main:
telegram_bot_token no longer appears in the failure list. (The other
three failures the test still reports on this branch are the
pre-existing owm_api_key/geocoding_api_key/etc. gaps and the two
boolean-masking issues, already fixed independently on main by
07b9d1d — nothing to do here, they'll merge in on their own.)
@rjonesbsink

Copy link
Copy Markdown
Author

Added a second commit here per @ejosterberg's note on #7: telegram_bot_token had the identical silent-blanking bug (matches the _token$ suffix in is_secret_setting_key(), so the server always masked it — loadTelegramConfig() just never accounted for that).

Rather than bolt on a one-off fix, brought Telegram's two fields onto the same data-key/data-secret + applySettingsToForm()/collectSettingsFromForm() pattern the rest of Settings uses, plus the manual "stored — leave blank to keep" placeholder handling feed_api_key needed (since applySettingsToForm() doesn't render that from the _set sentinel on its own — the gap you mentioned filing separately).

Verified against tests/test_settings_secret_fields.php pulled from main: telegram_bot_token no longer appears in the failure list. The three failures the test still reports on this branch (the other eight fields + two boolean toggles) are already fixed independently by 07b9d1d and aren't part of this PR's scope — they'll merge in on their own.

@ejosterberg

Copy link
Copy Markdown
Member

Thank you for this — and for the four issue reports that came with it. Root-causing @exec() being fatal under disable_functions (rather than just "status page is broken") is the kind of report that saves a maintainer days, and you did it four times in about forty-eight hours. It's appreciated.

I reviewed this with a security focus and test-merged it against main. Summary: the security posture is clearly better with this change, and I want to take it. There's one correctness issue I need resolved first, because it lands on exactly the environment this PR targets.

What I checked, and what's good

  • Every proc_open argv is a static literal — no user-controlled value reaches any command. Moving from a shell string to an argv array eliminates the metacharacter surface, so the absence of escapeshellarg is correct rather than an omission.
  • function_exists('proc_open') is a valid guard — disable_functions does remove entries from the function table.
  • api/health.php keeps its auth.php + is_admin() gate; chat.php's test_channel keeps admin + CSRF and builds the broker message from fixed keys, so a caller can't inject telegram_chat_id.
  • No secrets added, no new dependency, no json_error_safe() regression.
  • Merges with zero conflicts; test_settings_secret_fields.php passes 14/14 on the merged tree; the API-contract audit shows no new findings; all eight files lint clean.
  • The wmic → PowerShell fallback genuinely works — the existing parser loops each line against /^(\d{14})/, so the single-line PowerShell output matches.
  • You were right to drop that stale "array form isn't universally available" comment; composer.json requires PHP >= 8.0.

Also worth noting because it isn't in the PR body: the settings.php submit-handler change fixes a latent bug where an always-empty telegram_bot_token was posted and wiped the stored value on every save — the same class as #7. Good catch, whether or not it was deliberate.

The one blocker: pipe deadlock

In sql/run_migrations.php (~314-321), tools/install_fresh.php (~410-417) and tools/check-schema.php (~346-354), the code drains stdout to EOF and then reads stderr. If the child fills the stderr pipe buffer first, both sides block and neither can proceed — and there's no timeout.

Measured on Windows: 4 KB of stderr passes, 8 KB hangs indefinitely.

This is likely precisely where the PR is aimed. A hardened host running php.ini-production has log_errors on, so PHP 8.4 deprecation notices from migration scripts go to stderr — and a migration run emits plenty. The old exec(… 2>&1) merged the streams and could not deadlock, so this is a regression rather than a pre-existing risk.

The smallest fix, verified clean up to 1 MB, also matches what the surrounding code already does with $stdout . $stderr:

2 => ['redirect', 1]   // instead of ['pipe', 'w'] — then drop the stderr read

Worth applying the same treatment in api/health.php, inc/tts/engine.php and proxy/ZelloProxyApp.php, which currently fclose() stderr unread. Low risk there given the tiny outputs, but it's the same shape.

Two minor things, not blockers

  • inc/channels/telegram.php:186 — on an API failure with no description, the raw response body is returned to the admin UI. Worth trimming to a fixed message.
  • inc/channels/telegram.php:199-204 — the silent catch { }. It's a faithful clone of slack.php, so I won't hold the PR for it, but our conventions ask for at least an error_log().

Happy for you to push the pipe fix to this branch, or say the word and I'll apply it on merge and credit you. Either way this is going in.

One question: was the Telegram adapter something you needed, or added for completeness? Asking because it's a new user-facing channel and I'd want a line in the setup docs before it ships.

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.

Windows/IIS install: exec()/shell_exec() crashes on hardened PHP, MySQL 8.0 schema bug, and Telegram integration has no backend

2 participants