站内三处最容易出事的输入:富文本回显、CSRF 与文件上传的落地检查

所有讲建站安全的文章都会写一句「过滤用户输入」,然后就跳到下一节。可真正做站的时候,你很快会卡在一个具体的地方:后台那个「文章修订」功能要把上次提交的一段 HTML 重新显示在编辑器里,如果原样输出,就等于主动给了别人一个 XSS 入口;如果全部转义,编辑器里的格式标签会变成一堆 <p>。这就是存储型 XSS 在一个个人站上最可能出现的真实位置——不是留言板,而是站长的编辑器本身。

这篇文章从三个个人站最容易踩的场景讲起:编辑器与富文本回显、跨站请求伪造、文件上传。然后用响应头做一层兜底,最后给出一份可以直接照着改的检查顺序。所有例子都用 PHP 与 Nginx,目标是把「看起来没问题」变成「能说清楚为什么安全」。

一、富文本回显:白名单过滤,而不是转义与信任做二选一

处理用户提交的 HTML 只有两个正确方向。其一是完全不允许 HTML,输出时一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8'),这是最简单的安全姿势,留言、评论、昵称、搜索关键词都应该走这条。其二是确实需要保留格式(文章正文、后台编辑器),那就要做白名单过滤:把内容按 HTML 标签解析出来,只保留允许的标签与属性,其余全部丢弃。

白名单过滤的关键是三个「不」:不用正则去删 <script>(绕过方式太多,比如 <scr<script>ipt> 拼接、属性内事件、编码变形);不用黑名单列危险标签(漏一个就完);不信任 on* 之外的属性(href="javascript:..." 同样危险)。PHP 下最省事的是成熟库:

<?php
// composer require ezyang/htmlpurifier
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed',
    'p,br,h2,h3,h4,ul,ol,li,strong,em,code,pre,blockquote,'
  . 'a[href|title|rel],img[src|alt|width|height]');
$config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);
$config->set('HTML.TargetBlank', true);
$config->set('Attr.EnableID', false);

$clean = (new HTMLPurifier($config))->purify($_POST['content']);

注意 Attr.EnableID 关闭这一项:如果允许任意 id 属性,别人可以在正文里塞 id="login-form" 之类的元素,配合页面上的 JS 逻辑制造 DOM 覆盖(dom clobbering),这类攻击不需要执行脚本就已经能篡改前端行为。

然后是最容易被忽略的一步:存储与输出分开处理。入库时存过滤后的 HTML,出库时对「正文」这一处直接输出(因为它已经被过滤过),而对其他所有从数据库来的字段——标题、作者名、标签、分类名——一律 htmlspecialchars 后再输出。很多事故不是正文出问题,而是标题里被人塞了 <img src=x onerror=alert(1)>,出现在列表页、RSS、搜索结果里。

还有一个方向极易漏:HTML 之外的上下文。同一个用户输入如果被放进 <script> 块里的 JSON、放进 onclick 属性、放进 <textarea>、放进 URL 的 href,需要的是不同的转义规则(JS 转义、URL 编码)。判断标准很简单:这段内容最终落在哪种语法环境里,就用那种环境的转义。最稳的做法是避免把用户输入拼进脚本和内联事件,改成用 data-* 属性传递,再让静态 JS 去读。

二、CSRF:为什么光有登录态不够

跨站请求伪造的本质是:浏览器会自动带上目标域的 Cookie,所以「用户已登录」这件事本身不能证明请求是用户本人发起的。别人在论坛里贴一张 <img src="https://你的站/admin/delete.php?id=123">,任何浏览过这张图的已登录站长,都会在不知情的情况下删掉一篇文章。后台没有 SameSite Cookie、表单没有 token 时,这类请求与正常请求在服务端看起来一模一样。

防御有三层,建议全上,因为每一层都有失效场景:

第一层,Cookie 加 SameSite=Lax。现代浏览器默认已经是 Lax,跨站的 POST 请求不会带上会话 Cookie,绝大多数 CSRF 就此失效。但如果你的站有跨域 POST 的业务(比如从子域提交表单),设成 None 就必须配合 Secure,而那一瞬间 CSRF 防护就退化了。

第二层,表单 token。每个会话生成一个随机 token,藏在 session 里,同时写进表单隐藏域;提交时比对。要点是三点:token 必须用 random_bytes(不是 rand、不是 uniqid),比对必须用 hash_equals 做恒定时间比较,token 至少每个会话一个、有条件的话每个表单一次性。

<?php
session_start();
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf'];
// 输出: <input type="hidden" name="csrf" value="<?= htmlspecialchars($token) ?>">

// 校验(所有非 GET 请求统一入口处)
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    $sent = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!is_string($sent) || !hash_equals($_SESSION['csrf'], $sent)) {
        http_response_code(403);
        exit('CSRF token mismatch');
    }
}

第三层,把状态变更全部限制在 POST。GET 请求应该只读。如果你的删除操作是 admin.php?action=delete&id=5 这种,那 SameSite 也救不了——Lax 允许顶级导航的 GET 带 Cookie,别人发一个链接让你点,或者在页面里放 <a href> 诱导点击,请求照样成立。所以第一件事是审计后台:把所有「写」的动作改成 POST,并且校验 Referer/Origin(作为辅助,不作为唯一依据)。

三、上传:真正需要防的是「被当成脚本执行」

文件上传漏洞的危害等级取决于文件能不能被当作代码执行。上传一个 .php 到可访问目录,再访问它,就等于别人在你的服务器上跑任意代码。所以整个上传链路的防线都围绕一个目标:让上传目录里的东西绝对不可能变成脚本执行。

第一道是内容校验,而不是后缀校验。后缀可以伪造,shell.php.jpg、大小写变形、双扩展名、.phtml/.php5/.phar 这些都得考虑。可靠的方式是读文件真实类型:

<?php
$f = $_FILES['file'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK) exit('upload failed');
if ($f['size'] > 2 * 1024 * 1024) exit('too large');

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($f['tmp_name']);   // 读文件头,不看后缀
$allow = ['image/jpeg' => 'jpg', 'image/png' => 'png',
          'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($allow[$mime])) exit('bad type');

$ext  = $allow[$mime];
$name = bin2hex(random_bytes(12)) . '.' . $ext;   // 文件名完全由服务端决定

文件名由服务端重新生成,好处有三:彻底规避了后缀注入与路径穿越(../../)、避免同名覆盖、避免 UTF-8 文件名在磁盘与 URL 编码上的各种怪问题。原始文件名只作为展示字段存进数据库,并且存之前要 htmlspecialchars。

第二道是目录策略。上传目录必须放在 web 根之外,由 PHP 或 Nginx 的一个明确 location 提供访问;退一步放在 web 根内,也要在那个目录关闭脚本解析。Nginx 的写法是:

location ^~ /uploads/ {
    # 只允许静态文件,任何脚本后缀直接 403
    location ~* \.(php|phtml|php\d|phar|pl|py|cgi|sh|htaccess)$ {
        return 403;
    }
    add_header X-Content-Type-Options nosniff always;
    add_header Content-Disposition "inline" always;
    expires 30d;
}

nosniff 这一行很重要:它阻止浏览器「猜」内容类型。没有它,一个伪装成图片的 HTML 文件在旧版浏览器里可能被当页面渲染,从而在你自己域下拉取脚本。

第三道常被忘记:图片重编码。即使是合法 JPEG,也可以塞进恶意 payload(虽然现代浏览器基本不再执行 EXIF 里的内容,但图片处理库本身可能是攻击面)。个人站的稳妥做法是上传后统一用 GD 或 ImageMagick 重新生成一遍缩略图与原图,走一遍解码编码,顺带完成压缩(图片管线的细节在另一篇讲性能的文章里)。这样上传目录里最终留下的文件一定是你自己生成的。

四、响应头兜底:把「万一」的伤害压到最小

代码层面的过滤难免有漏网之鱼,响应头是最后一层保险。四条最关键:

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

CSP 是唯一能直接把 XSS 打残的手段:default-src 'self' 之后,内联脚本与外域脚本都被拒绝,即使有人成功注入 <script>,浏览器也不会执行。代价是老站几乎一定会有内联脚本、统计代码、CDN 资源,所以不要一上来就上严模式。正确路径是先以报告模式(Content-Security-Policy-Report-Only)跑一到两周,收集违规报告,把确实需要的来源补进去,再切成强制模式。这个过程要单独排期,不要和「本周要发三篇文章」挤在一起。

X-Frame-Options 与 CSP 的 frame-ancestors 作用重叠,现代浏览器看后者,老的看前者,两个都写不冲突。它们的意义是防点击劫持:别人把你的后台用 iframe 嵌进他的页面,上面盖一个透明按钮,诱导你点「删除」。

五、上线前的自查顺序

改动安全相关代码时,按这个顺序过一遍,能挡住绝大多数低级错误:

  1. 全站搜索 $_GET / $_POST / $_REQUEST 的直接输出点,逐个确认已经过 htmlspecialchars 或白名单过滤。
  2. 搜索所有 SQL 拼接字符串(形如 "... WHERE id=$id"),改造成预处理语句;PDO::ATTR_EMULATE_PREPARES = false。
  3. 审计后台所有写操作:是否 POST、是否有 CSRF token、是否校验了权限(不只是「已登录」,而是「是不是作者本人」)。
  4. 检查上传目录:是否可执行脚本、文件名是否服务端生成、类型判定是否看文件头。
  5. 用浏览器实际提交一次 <script>alert(1)</script> 与 <img src=x onerror=alert(1)>,看它出现在列表页、详情页、后台编辑框时分别是什么表现。
  6. 确认上述四个响应头在 curl -I 里都能看到,且 404/500 页面同样带(always 参数保证这一点)。

最后一句经验:安全这件事在个人站上的敌人不是高手,是「顺手」。一次顺手在模板里 echo 了一个变量、一次顺手用 GET 做删除、一次顺手把上传目录设成 777,后面所有防护都白搭。把上面这些检查写成固定的上线清单,每次改完照着走一遍,比事后补救便宜太多。

Last modification:November 11th, 2026 at 08:24 pm

Leave a Comment