Passed
Push — master ( a46cb4...8ff499 )
by Michael
27:12 queued 15:45
created

SendmailRunner::validatePath()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 13
c 1
b 0
f 0
nc 8
nop 1
dl 0
loc 25
rs 8.4444
1
<?php
2
declare(strict_types=1);
3
4
namespace Xmf\Mail;
5
6
/**
7
 * SendmailRunner safely executes sendmail commands for email delivery.
8
 *
9
 * This final class validates sendmail binary paths against a strict allowlist
10
 * and ensures the binary is executable. It supports optional envelope sender
11
 * validation and normalizes message line endings to comply with RFC 5322.
12
 *
13
 * @category  Xmf\Mail
14
 * @package   Xmf
15
 * @author    XOOPS Development Team <
16
 * @copyright 2000-2025 XOOPS Project (https://xoops.org)
17
 * @license   GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
18
 * @link      https://xoops.org
19
 */
20
21
/**
22
 * Safe sendmail runner for XOOPS.
23
 *
24
 * - No shell: argv-only via proc_open([...], ..., ['bypass_shell' => true])
25
 * - Strict validation:
26
 *   • absolute ASCII path format
27
 *   • allowlist enforcement
28
 *   • canonical target check via realpath()
29
 *   • executable file check (optional symlink policy)
30
 * - Optional, validated envelope sender (-f)
31
 * - CRLF normalization (str_replace-based)
32
 * - Diagnostics: clipped stdout/stderr on failure; warns if stderr on success
33
 *
34
 * Customize:
35
 * - Pass a custom $allowlist (array of absolute paths) in the constructor.
36
 * - Toggle $allowSymlinks (default true) to allow symlinks that resolve
37
 *   to a canonical allowlisted target.
38
 * - Inject filesystem check callables (is_executable/is_link/is_file) for testing.
39
 */
40
final class SendmailRunner
41
{
42
    /** @var string[] absolute paths considered for allowlisting */
43
    private array $allowlist;
44
45
    /** @var string[] canonical realpaths of allowlisted binaries */
46
    private array $allowlistCanonical;
47
48
    /** @var bool allow symlinks that resolve to a canonical allowlist target */
49
    private bool $allowSymlinks;
50
51
    /** @var callable(string):bool */
52
    private $isExecutable;
53
    /** @var callable(string):bool */
54
    private $isLink;
55
    /** @var callable(string):bool */
56
    private $isFile;
57
58
    public function __construct(
59
        ?array $allowlist = null,
60
        ?callable $isExecutable = null,
61
        ?callable $isLink = null,
62
        ?callable $isFile = null,
63
        bool $allowSymlinks = true
64
    ) {
65
        $this->allowlist = $allowlist ?? [
66
            '/usr/sbin/sendmail',
67
            '/usr/lib/sendmail',
68
            '/usr/bin/sendmail',
69
            '/usr/bin/msmtp',
70
            '/usr/sbin/ssmtp',
71
            '/usr/local/sbin/sendmail',
72
            '/usr/local/bin/sendmail',
73
        ];
74
        $this->isExecutable  = $isExecutable ?? 'is_executable';
75
        $this->isLink        = $isLink       ?? 'is_link';
76
        $this->isFile        = $isFile       ?? 'is_file';
77
        $this->allowSymlinks = $allowSymlinks;
78
79
        // Build canonical allowlist by resolving real targets of allowlisted entries.
80
        $canon = [];
81
        foreach ($this->allowlist as $p) {
82
            $rp = realpath($p);               // string|false
83
            if (is_string($rp)) {
84
                $canon[$rp] = true;           // set-like de-dupe
85
            }
86
        }
87
        $this->allowlistCanonical = array_keys($canon);
88
    }
89
90
    /**
91
     * Discover installed, allowlisted binaries (literal allowlist entries that
92
     * currently exist and meet executable criteria). Symlinks accepted only if
93
     * they pass isValidBinary() and policy allows them.
94
     *
95
     * @return string[] list of literal paths from the allowlist that are valid
96
     */
97
    public function discover(): array
98
    {
99
        $found = [];
100
        foreach ($this->allowlist as $path) {
101
            $real = realpath($path); // string|false
102
            $ok   = $this->isValidBinary($path, is_string($real) ? $real : null);
103
            if ($ok) {
104
                $found[] = $path;
105
            }
106
        }
107
        // Keep literal paths for UI consistency; remove duplicates just in case.
108
        return array_values(array_unique($found));
109
    }
110
111
    /**
112
     * Validate an absolute ASCII path against format, allowlist policy,
113
     * canonical real target, and filesystem permissions.
114
     *
115
     * @return string|null the canonical (resolved) path if valid; null otherwise
116
     */
117
    public function validatePath(string $path): ?string
118
    {
119
        $path = trim($path);
120
        if (!preg_match('~^/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+$~', $path)) {
121
            return null;
122
        }
123
124
        $resolved = realpath($path); // string|false
125
        if (!is_string($resolved)) {
0 ignored issues
show
introduced by
The condition is_string($resolved) is always true.
Loading history...
126
            return null;
127
        }
128
129
        if ($resolved === $path) {
130
            // Not a symlink: the literal path must be allowlisted.
131
            if (!in_array($path, $this->allowlist, true)) {
132
                return null;
133
            }
134
        } else {
135
            // Symlink: allow only if policy permits and the resolved target is canonical-allowlisted.
136
            if (!$this->allowSymlinks || !in_array($resolved, $this->allowlistCanonical, true)) {
137
                return null;
138
            }
139
        }
140
141
        return $this->isValidBinary($path, $resolved) ? $resolved : null;
142
    }
143
144
    /**
145
     * Deliver an RFC 5322 message via sendmail -t -i, optionally with -f.
146
     *
147
     * @param string      $sendmailPath validated absolute path (literal form)
148
     * @param string      $rfc822       headers + CRLF CRLF + body
149
     * @param string|null $envelopeFrom optional envelope sender (validated)
150
     *
151
     * @throws \RuntimeException on failures to start, write, or non-zero exit
152
     */
153
    public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeFrom = null): void
154
    {
155
        $validatedPath = $this->validatePath($sendmailPath);
156
        if ($validatedPath === null) {
157
            throw new \RuntimeException('Invalid sendmail path.');
158
        }
159
160
        // Normalize line endings to CRLF for RFC 5322 compliance (two-step, no double expansion).
161
        $rfc822 = str_replace("\r\n", "\n", $rfc822);
162
        $rfc822 = str_replace("\n", "\r\n", $rfc822);
163
164
        // Prefer the literal path if it resolves to the same canonical target; else use canonical.
165
        $literal  = $sendmailPath;
166
        $resolved = realpath($literal);
167
        if (!is_string($resolved) || $resolved !== $validatedPath) {
0 ignored issues
show
introduced by
The condition is_string($resolved) is always true.
Loading history...
168
            $literal = $validatedPath;
169
        }
170
171
        $argv = [$literal];
172
173
        // Optional, strictly-validated envelope sender (-f).
174
        $validatedEnvelopeFrom = $this->validateEnvelopeFrom($envelopeFrom);
175
        if ($validatedEnvelopeFrom !== null) {
176
            $argv[] = '-f';
177
            $argv[] = $validatedEnvelopeFrom;
178
        }
179
180
        // Safe flags only.
181
        $argv[] = '-t';
182
        $argv[] = '-i';
183
184
        $spec = [
185
            0 => ['pipe', 'w'], // stdin
186
            1 => ['pipe', 'w'], // stdout
187
            2 => ['pipe', 'w'], // stderr
188
        ];
189
190
        $proc = proc_open($argv, $spec, $pipes, null, null, ['bypass_shell' => true]);
191
        if (!is_resource($proc)) {
192
            throw new \RuntimeException('Failed to start sendmail process.');
193
        }
194
195
        $stdout = '';
196
        $stderr = '';
197
        $code   = null;
198
199
        try {
200
            // Robust write loop (handle partial writes / broken pipe)
201
            $len = strlen($rfc822);
202
            $off = 0;
203
            while ($off < $len) {
204
                $chunk = substr($rfc822, $off);
205
                $n     = fwrite($pipes[0], $chunk);
206
                if ($n === false) {
207
                    throw new \RuntimeException('Failed to write message to sendmail (broken pipe).');
208
                }
209
                if ($n === 0) {
210
                    if (!is_resource($pipes[0]) || feof($pipes[0])) {
211
                        throw new \RuntimeException('sendmail closed the input pipe prematurely.');
212
                    }
213
                    usleep(10000);
214
                    continue;
215
                }
216
                $off += $n;
217
            }
218
            fclose($pipes[0]);
219
220
            $stdout = stream_get_contents($pipes[1]) ?: '';
221
            $stderr = stream_get_contents($pipes[2]) ?: '';
222
            fclose($pipes[1]);
223
            fclose($pipes[2]);
224
        } finally {
225
            if (is_resource($proc)) {
226
                $code = proc_close($proc);
227
            }
228
        }
229
230
        // Warn if stderr contains content despite success.
231
        if ($code === 0 && $stderr !== '') {
232
            error_log('sendmail warning (success): ' . $this->clipForLog($stderr));
233
        }
234
235
        if ($code !== 0) {
236
            $sOut  = $this->clipForLog($stdout);
237
            $sErr  = $this->clipForLog($stderr);
238
            $first = $this->firstLine($stderr);
239
            error_log("sendmail failure: path={$literal} code={$code} stderr=\"{$sErr}\" stdout=\"{$sOut}\"");
240
            throw new \RuntimeException('Sendmail exited with code ' . $code . ($first !== '' ? ': ' . $first : ''));
241
        }
242
    }
243
244
    /* ====================== helpers ====================== */
245
246
    /**
247
     * Filesystem checks for the target binary.
248
     * Uses $real (canonical target) when provided; otherwise uses $path.
249
     */
250
    private function isValidBinary(string $path, ?string $real = null): bool
251
    {
252
        $target = $real ?? $path;
253
254
        if (!($this->isFile)($target) || !($this->isExecutable)($target)) {
255
            return false;
256
        }
257
        // If symlinks are globally disallowed, reject when the input is a symlink.
258
        if (!$this->allowSymlinks && ($this->isLink)($path)) {
259
            return false;
260
        }
261
        return true;
262
    }
263
264
    /**
265
     * Validate an email address for use in -f (envelope sender).
266
     * Returns sanitized address or null if unusable.
267
     */
268
    private function validateEnvelopeFrom(?string $addr): ?string
269
    {
270
        if ($addr === null || $addr === '') {
271
            return null;
272
        }
273
        // Extract <email@host> if a "Name <email>" form was supplied.
274
        if (preg_match('/<([^>]+)>/', $addr, $m)) {
275
            $addr = $m[1];
276
        }
277
        // Forbid any whitespace/control to prevent header/arg injection.
278
        if (preg_match('/\s/', $addr) || preg_match('/[\r\n]/', $addr)) {
279
            return null;
280
        }
281
        return filter_var($addr, FILTER_VALIDATE_EMAIL) ? $addr : null;
282
    }
283
284
    /** Clip a string for logs (remove most control chars, escape line breaks, limit length). */
285
    private function clipForLog(string $s, int $max = 400): string
286
    {
287
        $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $s) ?? '';
288
        $s = str_replace(["\r", "\n"], ['\\r', '\\n'], $s);
289
        if (strlen($s) > $max) {
290
            $s = substr($s, 0, $max) . '…';
291
        }
292
        return $s;
293
    }
294
295
    /** Get the first (non-empty) line from a blob, for concise error messages. */
296
    private function firstLine(string $s): string
297
    {
298
        $pos  = strpos($s, "\n");
299
        $line = $pos === false ? $s : substr($s, 0, $pos);
300
        return $this->clipForLog($line, 200);
301
    }
302
}
303