PHP 安全 E-mail
How email header injection works, how to stop it, and pre-send validation.
Key PointsNewlines inject extra headers — strip `\r\n` from anything entering headers.
Header injection
Headers are line-based plain text. A newline in user input creates a new field — a Bcc: turns your server into a spam relay.
PHP
<?php
// 攻击者提交的邮箱
$email = "victim@example.com\r\nBcc: a@evil.com, b@evil.com";
// 直接拼进头部
$headers = "From: " . $email;
echo str_replace("\r\n", "\\r\\n", $headers), "\n";
echo "字段数: " . (substr_count($headers, "\r\n") + 1), "\n";Output
From: victim@example.com\r\nBcc: a@evil.com, b@evil.com
字段数: 2One From became From plus Bcc. A single submission turns the server into a spam sender.
Defence
PHP
<?php
function buildMail(string $to, string $subject): array
{
if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("非法收件人");
}
if (preg_match("/[\r\n]/", $to . $subject)) {
throw new InvalidArgumentException("检测到头部注入");
}
if (mb_strlen($subject) > 100) {
throw new InvalidArgumentException("主题过长");
}
return ["to" => $to, "subject" => $subject];
}
$cases = [
["tom@example.com", "你好"],
["tom@example.com\r\nBcc: evil@x.com", "你好"],
["tom@example.com", "促销\r\nContent-Type: text/html"],
];
foreach ($cases as [$to, $sub]) {
try {
$m = buildMail($to, $sub);
echo "已构建: " . $m["to"] . " | " . $m["subject"] . "\n";
} catch (InvalidArgumentException $e) {
echo "拒绝: " . $e->getMessage() . "\n";
}
}Output
已构建: tom@example.com | 你好
拒绝: 检测到头部注入
拒绝: 检测到头部注入FILTER_VALIDATE_EMAIL already rejects newlines, so the first gate catches most. But subjects, display names and Reply-To need their own checks — no filter_var applies.A complete safe sender
PHP
<?php
declare(strict_types=1);
final class SafeMailer
{
public function __construct(private string $from, private string $fromName) {}
public function send(string $to, string $subject, string $body): bool
{
$this->assertNoInjection($to, $subject, $this->fromName);
if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("收件人格式错误");
}
$headers = [
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"From: " . $this->encodeName($this->fromName) . " <{$this->from}>",
];
return mail($to, $this->encodeName($subject), $body, implode("\r\n", $headers));
}
private function assertNoInjection(string ...$parts): void
{
foreach ($parts as $v) {
if (preg_match("/[\r\n]/", $v)) {
throw new InvalidArgumentException("检测到头部注入");
}
}
}
private function encodeName(string $v): string
{
return preg_match('/[^\x20-\x7e]/', $v)
? "=?UTF-8?B?" . base64_encode($v) . "?="
: $v;
}
}
$m = new SafeMailer("noreply@example.com", "我的站点");
try {
$m->send("tom@example.com", "账户激活", "请点击链接激活");
echo "已发送\n";
} catch (Throwable $e) {
echo "失败: " . $e->getMessage() . "\n";
}Output
已发送Other mail risks
| Risk | Mitigation |
|---|---|
| 被当成开放中继 | 收件人域名白名单、限流 |
| 表单被刷爆 | 验证码、IP 限流、蜜罐字段 |
| 钓鱼内容 | 正文不允许用户完全控制 |
| 附件带毒 | 服务端扫描,或干脆不支持附件 |
| 日志泄露邮箱 | 日志里脱敏 t***@example.com |
An unthrottled contact form becomes a spam gateway within hours and your IP gets blacklisted. Rate limiting is a launch requirement.
Validation order
- Length cap —— stop huge inputs before regex runs
- Newline check —— stop header injection
- Format check ——
filter_varor regex - Business rules —— blocklist, unsubscribed
- Throttle —— per IP and per address
邮件头注入
邮件头是「一行一个字段」的纯文本。如果用户输入里带换行,就能凭空多出一个字段——加个 Bcc: 就能把你的服务器当群发器。
PHP
<?php
// 攻击者提交的邮箱
$email = "victim@example.com\r\nBcc: a@evil.com, b@evil.com";
// 直接拼进头部
$headers = "From: " . $email;
echo str_replace("\r\n", "\\r\\n", $headers), "\n";
echo "字段数: " . (substr_count($headers, "\r\n") + 1), "\n";Output
From: victim@example.com\r\nBcc: a@evil.com, b@evil.com
字段数: 2一个 From 变成了 From + Bcc 两个字段。攻击者只需提交一次表单,就能让服务器替他发垃圾邮件。
防御
PHP
<?php
function buildMail(string $to, string $subject): array
{
if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("非法收件人");
}
if (preg_match("/[\r\n]/", $to . $subject)) {
throw new InvalidArgumentException("检测到头部注入");
}
if (mb_strlen($subject) > 100) {
throw new InvalidArgumentException("主题过长");
}
return ["to" => $to, "subject" => $subject];
}
$cases = [
["tom@example.com", "你好"],
["tom@example.com\r\nBcc: evil@x.com", "你好"],
["tom@example.com", "促销\r\nContent-Type: text/html"],
];
foreach ($cases as [$to, $sub]) {
try {
$m = buildMail($to, $sub);
echo "已构建: " . $m["to"] . " | " . $m["subject"] . "\n";
} catch (InvalidArgumentException $e) {
echo "拒绝: " . $e->getMessage() . "\n";
}
}Output
已构建: tom@example.com | 你好
拒绝: 检测到头部注入
拒绝: 检测到头部注入FILTER_VALIDATE_EMAIL 已经会拒绝含换行的地址,所以第一道关卡就能挡住大部分。但主题、发件人显示名、Reply-To 都要单独检查——那些地方没有 filter_var 可用。完整的安全发信函数
PHP
<?php
declare(strict_types=1);
final class SafeMailer
{
public function __construct(private string $from, private string $fromName) {}
public function send(string $to, string $subject, string $body): bool
{
$this->assertNoInjection($to, $subject, $this->fromName);
if (filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("收件人格式错误");
}
$headers = [
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"From: " . $this->encodeName($this->fromName) . " <{$this->from}>",
];
return mail($to, $this->encodeName($subject), $body, implode("\r\n", $headers));
}
private function assertNoInjection(string ...$parts): void
{
foreach ($parts as $v) {
if (preg_match("/[\r\n]/", $v)) {
throw new InvalidArgumentException("检测到头部注入");
}
}
}
private function encodeName(string $v): string
{
return preg_match('/[^\x20-\x7e]/', $v)
? "=?UTF-8?B?" . base64_encode($v) . "?="
: $v;
}
}
$m = new SafeMailer("noreply@example.com", "我的站点");
try {
$m->send("tom@example.com", "账户激活", "请点击链接激活");
echo "已发送\n";
} catch (Throwable $e) {
echo "失败: " . $e->getMessage() . "\n";
}Output
已发送其它发信风险
| 风险 | 对策 |
|---|---|
| 被当成开放中继 | 收件人域名白名单、限流 |
| 表单被刷爆 | 验证码、IP 限流、蜜罐字段 |
| 钓鱼内容 | 正文不允许用户完全控制 |
| 附件带毒 | 服务端扫描,或干脆不支持附件 |
| 日志泄露邮箱 | 日志里脱敏 t***@example.com |
公开的「联系我们」表单如果不限流,几小时内就会被脚本刷成垃圾邮件网关,然后你的 IP 进黑名单。限流不是优化项,是上线前提。
校验顺序
- 长度上限 —— 先挡住超长输入,避免正则跑太久
- 换行检查 —— 挡住头部注入
- 格式校验 ——
filter_var或正则 - 业务校验 —— 是否在黑名单、是否已退订
- 限流 —— 同 IP / 同邮箱的频率