Skip to content

Fix CVE-2026-8450: send_file() honoured 2-arg open() shell-magic#89

Merged
oalders merged 7 commits into
masterfrom
advisory-fix-1
May 19, 2026
Merged

Fix CVE-2026-8450: send_file() honoured 2-arg open() shell-magic#89
oalders merged 7 commits into
masterfrom
advisory-fix-1

Conversation

@oalders
Copy link
Copy Markdown
Member

@oalders oalders commented May 16, 2026

Summary

HTTP::Daemon::ClientConn::send_file() used 2-argument open(FILE, $file), which interprets shell-magic prefixes in the path argument: | cmd / cmd | (command execution), > path / >> path (arbitrary file write), +< path, <& fd, and leading-whitespace variants. Any HTTP::Daemon-based application that passed attacker-influenced bytes to send_file($string) — e.g. a download endpoint deriving a filename from a query parameter — granted command execution and/or arbitrary file write at the daemon's UID.

Tracked as CVE-2026-8450 (affects 6.15 and earlier). Reported and patched by Stig Palmquist (stigtsp).

The fix

send_file() now uses 3-argument open(my $fh, '<', $file). The explicit < read mode makes the path argument a literal filename, so every magic shape above is opened as an ordinary file by that exact name (and fails, returning undef).

Two collateral hardening changes ride along:

  • binmode() failure now closes the handle and returns undef, rather than streaming the file with a wrong PerlIO layer.
  • send_file() returns '0E0' (true zero) on a successful zero-byte transfer, so callers using send_file or die can distinguish an open failure (undef) from an empty-but-successful copy.

The POD documents the new return-value contract and spells out that this fix only neutralises 2-arg open() shell-magic — callers remain responsible for validating attacker-influenced paths against symlinks, character/block devices, named pipes, and document-root escapes.

Testing

New t/send-file-magic-open.t is a regression test covering the pipe, redirect, and bare-< shapes plus positive controls (ordinary file, empty file). It uses socketpair rather than TCP so it runs anywhere.

precious.toml gains a perlcritic policy (InputOutput::ProhibitTwoArgOpen) to keep 2-arg open() out of the codebase going forward; t/lib/TestServer.pm was updated to satisfy it.

🤖 Generated with Claude Code

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 75.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 12.16%. Comparing base (6bef134) to head (32626d9).

Files with missing lines Patch % Lines
lib/HTTP/Daemon.pm 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #89      +/-   ##
==========================================
+ Coverage    8.57%   12.16%   +3.58%     
==========================================
  Files           1        1              
  Lines         338      337       -1     
  Branches       86       88       +2     
==========================================
+ Hits           29       41      +12     
+ Misses        304      287      -17     
- Partials        5        9       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

oalders and others added 7 commits May 19, 2026 21:13
HTTP::Daemon::ClientConn::send_file() used the 2-arg form
open(FILE, $file), which interprets shell-magic prefixes in the
path argument: '| cmd' (write pipe -- RCE), 'cmd |' (read pipe --
RCE plus response-body exfiltration via the sysread / print loop
below), '> path' (write-truncate -- arbitrary file write), and
'>> path', '+< path', '<&fd', and leading-whitespace variants of
the above.

Any HTTP::Daemon-based application that passed attacker-influenced
bytes to send_file($string) -- for example, a download endpoint
that derived the filename from a query parameter -- granted command
execution and/or arbitrary file write at the daemon's UID.

Switch to 3-arg open(my $fh, '<', $file): the explicit '<' mode
makes the path argument a literal filename, so every magic shape
above is opened (and fails, returning undef) as an ordinary file by
that exact name. The localized typeglob is no longer needed and is
replaced with a lexical filehandle.

Two collateral hardening changes ride along:

  - binmode() failure now closes the handle and returns undef,
    rather than streaming the file with a wrong PerlIO layer.

  - send_file() returns '0E0' (true zero) on a successful zero-byte
    transfer so callers using "send_file or die" can distinguish
    open failure (undef) from an empty-but-successful copy.

The POD now documents the new return-value contract and spells
out that the fix only neutralises 2-arg open() shell-magic;
callers remain responsible for validating attacker-influenced
paths against symlinks, character/block devices (e.g. /dev/zero),
named pipes, and document-root escapes.

Reported and patched by Stig Palmquist (stigtsp).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
t/send-file-magic-open.t exercises four 2-arg open() shell-magic
shapes that map to the CVE-2026-8450 harm table:

  - '| cmd'   (write-pipe)        -- RCE
  - 'cmd |'   (read-pipe)         -- RCE + response-body exfiltration
  - ' | cmd'  (leading-ws-pipe)   -- whitespace-stripping bypass
  - '> path'  (write-redirect)    -- arbitrary file write

plus a bare '<file' shape that under 2-arg open silently strips the
'<' and opens an existing file the caller never named, leaking its
contents to the response body.

For each case, send_file() is invoked on a real
HTTP::Daemon::ClientConn (built via socketpair() and bless) so any
future method dispatch on $self surfaces in the test rather than
silently no-oping against an unblessed scalar filehandle. The pipe
shapes use a marker file as the load-bearing oracle (the marker is
created by the would-be subprocess; absence proves the subprocess
never ran). The marker path is passed to the child via
$ENV{HTTPD_MAGIC_MARKER}, not shell-interpolated, so the test is
robust to spaces or quotes in $TMPDIR. The file is skipped on
Windows where cmd.exe does not honour POSIX single quoting.

A positive control confirms an ordinary file still streams through.
A return-value-contract section confirms send_file() returns '0E0'
(defined, true, numerically zero) for a successful zero-byte copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The standalone lint workflow set up a bare Perl via
shogo82148/actions-setup-perl and installed only Perl::Tidy and
App::perlvars — Perl::Critic was missing, so any precious config that
exercises perlcritic could not run in CI.

Move the lint job into build-and-test.yml as a job that needs build and
runs in the perldocker/perl-tester:5.42 container, which already ships
perlcritic and perltidy. App::perlvars (niche, not in the image) is
still installed via cpm; precious and omegasort still come from
oalders/install-ubi-action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a perlcritic command to precious.toml that runs only the
InputOutput::ProhibitTwoArgOpen policy. --noprofile keeps it from
loading any ambient ~/.perlcriticrc, so it runs purely that one policy.

Fix the 2-arg piped open the new rule flagged in t/lib/TestServer.pm,
switching it to the 3-arg '-|' form (equivalent: the multi-word command
string still goes through the shell).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
perl_cmd() now returns the perl binary plus -I flags as a list instead
of a single pre-quoted shell string. start() feeds that list to the
3-arg open '-|', so the daemon is exec'd directly with no shell, which
drops both the manual space-quoting logic and the shell itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
perl_cmd() had exactly one caller and was a one-line sub; fold its
body directly into start() and drop the sub.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pipe family (| cmd, cmd |, " | cmd") collapses to a single risk
under 3-arg open: a literal filename that fails to open. Keep one
pipe shape -- the leading-whitespace spelling, since it also exercises
2-arg open's leading-whitespace stripping -- next to the distinct
write-redirect shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@oalders oalders merged commit 7aa2827 into master May 19, 2026
32 checks passed
@oalders oalders deleted the advisory-fix-1 branch May 19, 2026 21:42
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