Sending mail from PHP-FPM when AppArmor says no
The contact form at the bottom of this site does not call mail(). It
opens a socket to 127.0.0.1:25 and speaks SMTP by hand — EHLO,
STARTTLS, MAIL FROM, RCPT TO, DATA, the lot. That started as a workaround for
something I could not change. It ended as the design I would now pick first.
The symptom
mail() returned false. Not an exception, not a PHP
warning worth reading, not a line in the Postfix log — because Postfix was never
reached. Nothing in the mail queue, nothing in /var/log/mail.log, no
bounce. The message simply did not exist.
PHP's mail() does not know how to send mail. It shells out to
sendmail_path, which on a Debian box with Postfix is
/usr/sbin/sendmail -t -i. So the question is never "why did PHP fail
to send mail" but "why did PHP fail to execute a binary", and the answer was in
/var/log/syslog:
apparmor="DENIED" operation="exec"
profile="php-fpm" name="/usr/sbin/postdrop"
comm="sendmail" requested_mask="x" denied_mask="x"
The php-fpm AppArmor profile does not permit exec of the sendmail
chain. That is not a bug. A PHP-FPM pool that can execute arbitrary setgid
binaries is a considerably more interesting target than one that cannot, and
postdrop is setgid postdrop by design.
The three options
Widen the AppArmor profile. Two lines, works immediately, and hands the web application the ability to exec a setgid binary — permanently, for every script in the pool, to solve one form on one page. The cost is not paid today; it is paid the day something else in that pool is compromised.
Add a mail library. PHPMailer or Symfony Mailer will do this correctly and are perfectly good software. But this is a static site with one form. Pulling in Composer, a vendor tree and a dependency to update forever, in order to write six protocol verbs to a socket, is a poor trade — and every one of those dependencies is code that runs with the same privileges as the form.
Speak SMTP directly. Postfix is already listening on loopback. It is a mail server; talking to it in its own protocol is not a hack, it is the interface. About sixty lines, no dependencies, no privilege escalation, and the failure modes are visible.
What the third option actually looks like
The shape of it, minus the error handling:
$sock = stream_socket_client('tcp://127.0.0.1:25', $errno, $errstr, 5);
stream_set_timeout($sock, 5);
expect('220');
send('EHLO bitwarelabs.com'); expect('250');
send('STARTTLS'); expect('220');
stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
send('EHLO bitwarelabs.com'); expect('250'); // re-issue after TLS
send("MAIL FROM:<$from>"); expect('250');
send("RCPT TO:<$to>"); expect('250');
send('DATA'); expect('354');
send($headers . "\r\n\r\n" . $body . "\r\n.");
expect('250');
send('QUIT');
Four details in there are not optional, and each cost me something to learn.
Re-issue EHLO after STARTTLS
Everything the server told you before the TLS handshake is discarded. RFC 3207 is
explicit about this: the client must issue a fresh EHLO, and the server's
capability list may legitimately differ. Skip it and you will get a
503 5.5.1 Error: send HELO/EHLO first that looks like the session
reset for no reason.
Multiline replies are the normal case, not the edge case
An SMTP reply is multiline when the fourth character is a hyphen —
250-PIPELINING then 250-SIZE then finally
250 DSN. A naive fgets() reads the first line, sees
250, declares success, and leaves the rest of the capability list
sitting in the buffer. It will then be read as the response to your next
command, and every reply from then on is off by one. This desynchronisation is
the single most common way a hand-rolled SMTP client fails, and it fails
intermittently, depending on which capabilities the server advertises.
do {
$line = fgets($sock, 512);
if ($line === false) return false;
} while (isset($line[3]) && $line[3] === '-'); // drain continuation lines
return strncmp($line, $code, 3) === 0;
Dot-stuffing
A line consisting of a single . terminates DATA. If a user's message
contains such a line — and eventually one will, because people paste things — the
remainder of their message gets interpreted as SMTP commands. Every line
beginning with . must be sent with the dot doubled:
$data = preg_replace('/^\./m', '..', $body);
This is the injection vector in the design, and it is one line to close. Alongside
it: reject \r and \n in anything that reaches a header.
The name field goes into Subject:; a newline there lets a sender add
arbitrary headers, including Bcc:.
Peer verification on loopback
The certificate is issued for the mail hostname, not for 127.0.0.1,
so verification fails on name mismatch. Disabling it here is defensible because
the traffic never leaves the machine — but "we turned off certificate
verification" deserves a comment in the source explaining exactly why, or the
next person to read it (which is usually you) has to re-derive whether it was
reasoned or lazy.
Why I would now choose this first
The workaround turned out to be better than the thing it replaced, for reasons that have nothing to do with AppArmor.
mail() returns a boolean that means "the sendmail binary exited
zero". That is not delivery, or acceptance, or queueing — it is the exit status of
a subprocess. Speaking SMTP, I get the actual response code for every stage, so
"connection refused", "mailbox unavailable" and "message accepted" are three
distinguishable outcomes instead of one false. The form can honestly
tell a visitor which one happened.
It also does not need a hardening exception. The AppArmor profile stays as narrow as the distribution shipped it, and the web application's answer to "can you execute a setgid binary" stays no — which is worth considerably more than the hour the workaround cost.
When a sandbox refuses something, the useful question is not how to widen the sandbox. It is why the sandbox thought that was worth refusing.