Skip to content

refactor: escape --host in spark serve to prevent CLI command injection#10156

Open
sgInnora wants to merge 2 commits intocodeigniter4:developfrom
sgInnora:security/escape-spark-serve-host
Open

refactor: escape --host in spark serve to prevent CLI command injection#10156
sgInnora wants to merge 2 commits intocodeigniter4:developfrom
sgInnora:security/escape-spark-serve-host

Conversation

@sgInnora
Copy link
Copy Markdown

@sgInnora sgInnora commented May 4, 2026

Description

The php spark serve command in system/Commands/Server/Serve.php passes the --host CLI option directly into passthru() without escaping, while sibling arguments ($php, $docroot, $rewrite) on the same line are correctly escaped via escapeshellarg(). This PR closes the inconsistency.

Motivation

Same root pattern as the recently-fixed CVE-2025-54418 (ImageMagickHandler _resize/_text command injection): user-controlled string concatenated into a shell command. Although the impact is limited (requires local CLI control over argv to influence --host), defense-in-depth and consistency with the surrounding code warrant the fix.

Vulnerable code (current develop)

$php  = escapeshellarg(CLI::getOption('php') ?? PHP_BINARY);    // ✅ escaped
$host = CLI::getOption('host') ?? 'localhost';                   // ❌ not escaped
$port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset;  // ✅ int cast
$docroot = escapeshellarg(FCPATH);                              // ✅ escaped
$rewrite = escapeshellarg(SYSTEMPATH . 'rewrite.php');          // ✅ escaped

passthru($php . ' -S ' . $host . ':' . $port . ' -t ' . $docroot . ' ' . $rewrite, $status);

PoC (illustrative, requires local CLI access)

$ php spark serve --host='\$(id > /tmp/pwn)'
$ cat /tmp/pwn
uid=1000(...) gid=1000(...) groups=1000(...)

The `$(...)` substitution executes when passthru() invokes `/bin/sh -c`.

Fix

Wrap `$host` with `escapeshellarg()`, identical to how `$php` / `$docroot` / `$rewrite` are handled on the same `passthru()` line:

```diff

  •    \$host = CLI::getOption('host') ?? 'localhost';
    
  •    \$host = escapeshellarg(CLI::getOption('host') ?? 'localhost');
    

```

Backwards compatibility

`escapeshellarg('localhost')` returns `'localhost'` (single-quoted), which remains a valid `php -S host:port` argument. Existing functionality is unaffected.

Display message

Line ~110 prints `'http://...:port'` to stdout via `CLI::write`. After the fix, `$host` is now `'localhost'` (with embedded single quotes). I opted to keep the diff minimal (1 line). Happy to extend with a separate `$rawHost` for cleaner display if maintainers prefer.

Impact assessment

  • CVE-worthy? Marginal — local CLI argument injection, not unauth web RCE. Maintainers may decide it's hardening-only.
  • Backwards compat: None. (`escapeshellarg` of a normal hostname is a no-op semantically.)
  • Test impact: No existing tests cover the spark-serve passthru wiring directly. No new tests added; this is a 2-line defense-in-depth.

Related

The `spark serve` command in system/Commands/Server/Serve.php passes
$host (--host CLI option) directly into passthru() without escaping,
while sibling arguments ($php, $docroot, $rewrite) on the same line
are correctly escaped via escapeshellarg().

Same root pattern as recently-fixed CVE-2025-54418 (ImageMagickHandler
_resize/_text command injection): user-controlled string concatenation
into a shell command. Although impact is limited here (requires local
CLI control over argv to influence --host), the inconsistency makes
defense-in-depth and consistency with the surrounding code beneficial.

PoC (illustrative, requires local CLI access):
  php spark serve --host='$(id > /tmp/pwn)'

The $(...) substitution executes when passthru() invokes /bin/sh -c.

Fix wraps $host with escapeshellarg(), identical to how $php /
$docroot / $rewrite are handled on the same passthru() line.

Backwards compatibility: escapeshellarg('localhost') returns 'localhost'
(single-quoted), which remains a valid 'php -S host:port' argument.
Existing functionality unaffected.
@mergeable
Copy link
Copy Markdown

mergeable Bot commented May 4, 2026

Hi there, sgInnora! 👋

Thank you for sending this PR!

We expect the following in all Pull Requests (PRs).

Important

We expect all code changes or bug-fixes to be accompanied by one or more tests added to our test suite to prove the code works.

If pull requests do not comply with the above, they will likely be closed. Since we are a team of volunteers, we don't have any more time to work
on the framework than you do. Please make it as painless for your contributions to be included as possible.

See https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing/pull_request.md

Sincerely, the mergeable bot 🤖

@michalsn michalsn changed the title security: escape --host in spark serve to prevent CLI command injection refactor: escape --host in spark serve to prevent CLI command injection May 4, 2026
Copy link
Copy Markdown
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

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

Thanks, good find.

$host is also used for display, so users would see:

http://'localhost':8080

Maybe we can do something like this:

$host = CLI::getOption('host') ?? 'localhost';
$address = escapeshellarg($host . ':' . $port);

And then use $address instead of $host and $port in the passthru() call?

…rveTest

Address review feedback from @michalsn on PR codeigniter4#10156:
codeigniter4#10156 (review)

Previously $host was escaped on its own, which made the development
server banner display:

    http://'localhost':8080

Build a single shell-escaped $address = escapeshellarg($host . ':' . $port)
for the passthru() call instead, and keep $host in its raw form for the
display line. The shell command is still safe against the original
`spark serve --host='$(id)'` style injection, while the user-facing
output is no longer mangled by stray quotes.

Also add tests/system/Commands/Server/ServeTest.php with three cases:

  - testServeRunsWithDefaultHostAndPort
      sanity check that the default host/port path is wrapped in single
      quotes inside the constructed command.

  - testServeEscapesShellMetacharactersInHost
      passes --host='$(id)' and asserts the literal substitution token
      stays inside escapeshellarg's single-quoted wrapper.

  - testServeEscapesEmbeddedSingleQuoteInHost
      passes --host="evil'host" and asserts the embedded single quote
      is escaped via the standard '\\'' sequence rather than breaking
      out of the argument.

The tests intercept passthru() through a function declaration in the
CodeIgniter\\Commands\\Server namespace so the real PHP development
server is never spawned during test runs.
{
$_SERVER['argv'] = ['spark', 'serve', '--host', '$(id)'];

command('serve --host="$(id)"');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

develop cannot parse yet options with =. it's a feature in 4.8.


public function testServeEscapesShellMetacharactersInHost(): void
{
$_SERVER['argv'] = ['spark', 'serve', '--host', '$(id)'];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do you need this separate argv manipulation?

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.

3 participants