把站点从「能看」推到「能扛」:一次安全、性能与可发现性的系统加固
一、起因:我以为「能打开」就是没事
博客推到公网之后,我的验收标准一直只有一条:浏览器里敲进去,页面出来了,图能看见,字能读。这条标准支撑了我很久的安心感——站点确实是活的,18 篇文章、6 个分类、26 个标签都在那儿,Typecho 1.3 加 sagiri 主题,跑在一台 2 核 2G 的云服务器上(Ubuntu 22.04),同一个机器上还养着一只 QQ 机器人。没挂过,我甚至管这叫「稳定」。
回头看,问题不在我不懂这些知识,而在我从来没给「没事」下过一个可检验的定义。
这轮加固是从一次很随意的检查开始的:我想量一下首页到底传了多少字节。量完顺手往下翻了几层目录,然后发现,「能打开」和「能扛」之间隔的不是一条沟,是好几个我从来没想过要去捅的地方。下面按被我捅破的顺序写。
二、第一个洞:数据库文件就躺在站点目录里
这个洞毫无技术含量。我在站点目录里翻,看见一个 .db 文件,188KB,位置在 /usr/xxx.db 这种「只要猜得出路径就能直接请求」的地方。用一个没登录的会话请求了一次,HTTP 200,整个 SQLite 文件就被下载下来了。188KB 里装的是整站数据:文章、评论、用户表——用户表里有账号和口令哈希。
那种荒谬感很强。我为了防注入、防爆破折腾了半天,结果门根本没锁,是我自己把保险柜放在了门口。
修法没什么好说的:文件移出 webroot,顺手加一条 deny。真正值得写的是下一条,因为那才是我这轮里最丢人的地方。
三、我自己写的 deny 正则,只匹配「以 .bak 结尾」
加完 deny 我做了「验证」:请求一下 xxx.db.bak,403;请求一下 config.inc.php.bak,403。绿灯,收工。
这个验证的毛病在于,我验证的是「我以为的备份文件名」,不是「服务器上真实存在的备份文件名」。
后面我用 find 把 webroot 下所有非预期文件列了一遍,出来 4 个形如 header.php.bak.1792302906 的东西——时间戳后缀,某次改模板时留下的。每个 2.8KB 到 10KB,全是 PHP 源码明文。随便请求一个,HTTP 200,源码原样吐出来:连接参数、内部路径、模板逻辑,一页一页全在里面。
根因就在我写的那条正则上。我当时的规则大意是「匹配以 .bak 结尾的请求」。以 .bak 结尾,意味着 header.php.bak 会被拦,header.php.bak.1792302906 不会——尾巴上多一个后缀,整条规则就失效了。我写的不是一条防护规则,是一条针对我自己想象中文件名的特判。攻击者不需要猜我的正则,他只需要猜「人会怎么备份」。
修法两条,缺一不可。第一条是把「后缀黑名单」改成「包含式匹配」,文件名里出现下面这些片段就拦:
location ~* \.(db|sqlite3?|sql|bak|orig|old|save|swp|log|ini|tar|gz|zip)([.\-_~]|$) {
deny all;
}关键是最后那个 ([.\-_~]|$):它要求这些片段后面要么紧跟一个分隔符(.、-、_、~),要么就是文件名结尾。header.php.bak.1792302906 里的 .bak 后面跟的是 .,命中;backup.zip 命中;而正常文件不会因为名字里恰好带一段 log 就被误伤。
回头看,这条正则之所以写成那样,是因为我脑子里有一个很具体的敌人形象:某个脚本拿着字典,去撞一堆 index.php.bak、config.php.bak 这种教科书式文件名。但真实世界里留在服务器上的备份,名字是我自己随手打的——带时间戳、带日期、带个 copy。防护规则一旦写成对自己「备份命名习惯」的复刻,就等于只防住了我记得的那一次。
第二条才是真正保底的:把备份、源码、数据库文件全部搬出 webroot。nginx 的 deny 是第二道闸,不该是唯一一道——我这一轮的两次事故,都是因为把「第二道闸」当成了「唯一的墙」。
四、403 不是 404:验证时别被自己骗
这条单独成节,因为我在验证阶段结结实实骗了自己一次。
加完 deny 我去复查那 4 个文件,返回的是 403。403 说明「被规则拦了」,看起来很好。但它同时说明不了另一件更要紧的事:文件还在不在。因为 nginx 的匹配顺序里,location 级别的 deny 在「文件是否存在」的检查之前生效——路径先命中规则,直接 deny,压根走不到文件系统去看这个文件。只要规则还在,文件在不在都是 403。
所以正确的验证是两条独立观测。一条从 HTTP 侧看规则生效(403),一条从文件系统侧看文件真没了:
[ -e /path/to/webroot/header.php.bak.1792302906 ] && echo still-there || echo gone后来我把这一步写成脚本,两条断言各占一行,跑完直接输出 rule=blocked file=gone 才算过。人肉验证的毛病是它会累,累了就只跑一半——而我恰好就是在只跑了一半的那一次上骗过了自己。
这件事的教训比 403 和 404 的区别更大:一个测试如果无论「修好了」还是「没修」都通过,它就不是测试,是安慰剂。后来我给这轮加固列验收项时,每一项都要求两个现象能分别被观测到——改之前的坏现象,改之后的好现象。
五、防火墙:从全开收到只留四个洞,以及那个我以为开着的密码登录
ufw status 一开始是 inactive。也就是说这台机器上所有监听端口,只要进程绑在对外地址上,公网就能敲。
我数了一下,除了 22 和博客入口,还有几个我以为「反正没人知道端口号」的服务:一个高位端口的面板、一个调试服务、一个本地开发用的端口。「没人知道端口号」这个假设在公网上活不过一天,端口扫描是背景噪音,不是攻击行为。
修法是把默认策略反过来,再白名单放行:
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw enable22 单独先放行,是为了不在启用规则那一瞬间把自己锁在外面;剩下三个业务入口随后逐条加,每加一条都从外部验一次连通性。最终只放行 22/8080/8443/9443。验证是从外部对 8081、3210、3000 各打一次,结果从「连得上」变成「超时」——超时(drop)比拒绝(reject)好,少泄露一点「这个端口上其实有服务」的信息。
同一批里我还准备改 sshd,因为主配置里明明是 PasswordAuthentication yes。手快按下之前多看了一眼实际生效值:
sshd -T | grep -i passwordauthentication结果是 no。原因很简单:Ubuntu 22.04 的 sshd 配置是分层的,主文件里那一行只是「默认值的样子」,真正覆盖它的是 sshd_config.d/*.conf 里预置的条目。sshd -T 看的是合并后的最终配置,grep 主文件看的是我以为的配置。
这里没有洞要补,但它是这轮里最有价值的一次「不改成」。如果我没查 sshd -T 就直接在末尾追加一行 no,结果一模一样,但我不会知道它本来就是关的——而「不知道」意味着换一台机器时我还得重新猜一遍。加固的过程里,把「我以为」变成「我验证过」,比多写一条规则值钱得多。
六、首屏 703KB → 48.8KB:把最大的那个库请出首屏
性能这块本来没打算动,直到量出 /blog/ 的首屏传输量是 703KB。一台 2 核 2G 的机器,一个内容站首页,703KB,不合理。
拆开就清楚了:最大的一块是看板娘库 l2d-widget.min.js,646KB。703 减 646,剩下的才是真正的页面。也就是说,为了右下角那个小人,每个访客都得先下 646KB 才轮到看正文——这个交易在任何流量下都不划算。
修法不是删掉它,是挪加载时机:页面 load 之后,在空闲时再注入 script。首屏关键路径上不再有它,看板娘该出现还是会出现,只是晚几百毫秒。结果首屏 703KB → 48.8KB。
顺手做了两件配套的:静态资源开 gzip,加 Cache-Control: max-age=2592000。前者省带宽,后者省往返——回访用户第二次进来,图片和样式基本不出网。
然后把版式也重排了一遍:容器 1110px → 1240px,内容区 772px → 937px;卡片固定三列,每列 299px,内边距从 40px 收到 20px;侧栏 280px 加 position: sticky。侧栏里词云 22 个词,字号按文章数从 12px 线性映射到 26px,8 色循环;下面分类 6 个、标签 16 个胶囊。
这里有个取舍值得说:容器从 1110 加到 1240、内容区从 772 涨到 937,多出来的宽度没拿去塞更多东西,而是让三列各自松一口气——每列 299px、内边距 20px,正文行长回到一个能连续读下去的长度。首屏变小靠的是把 646KB 挪出关键路径,版式反而更宽,这两件事不矛盾。
看板娘跟着改。它原来 300px 宽、锚在视口右下,实测压在侧栏上——盖掉一张 150×79 的数据卡和一整块 150×152 的词云区。改成桌面 240px、锚定内容区右边缘(跟着内容走,不跟视口走),手机 200px。改完侧栏终于是能看的。
七、移动端、无限滚动,以及「验证工具自己会骗人」
侧栏改完我拿手机看了一眼——侧栏是空的。
查下去,根因在主题的 isPc():它用一条普通 User-Agent 正则判断是不是桌面端,是就输出侧栏 HTML,不是就整块不输出。所以之前手机上「没有侧栏」从来不是样式问题,是 HTML 压根没生成。我把这块改成移动端也输出之后,问题从「没有」变成了「空的」:模板默认激活的侧栏模块是「文章目录」,而主题的 JS 在移动端不构建 TOC。空侧栏比没侧栏更难看,这一改等于把一个隐藏问题换成了一个显性问题。
无限滚动踩了三下,都是同一类「状态没人维护」:
IntersectionObserver的哨兵如果一直留在视口里,回调不会重复触发——加载完一页就停住,看起来像「没有更多了」。得在每次触发后重建哨兵,或者先移除再插回,强制重新观测。- 注入的脚本被主题里前一句吞掉,报错是
setInterval(...) is not a function——上一条语句末尾少了分号,下一行被当成接着调用。加个前导分号就解决。 - 后插入的图片,主题的懒加载不接管,因为它的初始化只在首屏那次跑过。新节点得手动补一次初始化。
最后是验证工具本身。我一开始用 page.mouse 模拟双击,测出来是通的,真机不通。换成 Playwright 的真触屏 page.touchscreen.tap 才复现出来。原因在 CDP:单次 touch 事件往返 300–600ms,而我那个双击的两次点击间隔小于 320ms,走 CDP 根本来不及,只能在页内直接派发真实的 TouchEvent。
还有个更气人的:position: sticky 的元素在 fullPage 截图里整块不绘制,截图看着像元素凭空消失了,实际是那层没被拍进去。这类「工具骗人」在验证前端时比真 bug 更耗时间。
这几条连起来看其实是同一件事:「状态」散落在主题 JS、浏览器和测试工具三处,任何一处没人维护,问题都会以「看起来正常」的形式留下来。
八、让搜索引擎终于看得见:robots、sitemap、canonical、h1
安全和性能弄完,我才想起来查可发现性。/robots.txt 404,/sitemap.xml 404。两个都 404 意味着:爬虫来了没有任何指引,我也没主动告诉它站点里有什么。
补了两件事:写 robots.txt 指向 sitemap,生成 sitemap.xml 覆盖 29 条 URL——18 篇文章、5 个页面、6 个分类。顺手补了 canonical,免得列表页和详情页在索引里互相打架。
sitemap 只收 29 条,没把 26 个标签页都塞进去:标签是聚合视图,内容重复度高,塞进去只会稀释抓取预算。分类 6 个进、标签不进,是我能说清理由的一次取舍。
然后是一个纯语义的错误:列表页上有 5 个 <h1>。Typecho 的默认模板把每篇文章标题都渲染成 h1——在详情页这是对的,在列表页就是 5 个同级最高标题,爬虫没法判断这一页的主题到底是什么。改成 h2 之后,列表页的 h1 数量是 0。这个改动对肉眼零影响,对索引结构影响很大,属于典型的「不查不知道、知道了五分钟改完」。
九、收尾:三句话
这轮下来我最想留住的是三条做法。
观测优先。数据库那个洞、703KB 那个数字、5 个 h1——它们都不是我「想」出来的,是我量出来的。加固的第一步不是改配置,是先把现状变成一个能比较的数字或状态。
改动可回滚。文件搬走而不是删掉,配置先备份再改,规则加在独立文件里而不是揉进主配置。加固本身也会引入故障,回滚能力是它的一部分。
验证要能证伪。403 那件事教我的就一句:一个测试如果「修好了」和「没修」都能过,它就没在测任何东西。每条改动都配一对观测——坏现象在改之前看得到,好现象在改之后看得到。
从「能看」到「能扛」,中间隔的不是技术难度,是「我以为」和「我验证过」之间的那段距离。