从零写一个 Typecho 主题和 MCP 插件:流程、25 个坑与心得
这个博客的样子和写作方式,都是我自己写的:一个 Typecho 主题(logbook)和一个 MCP 插件(LogbookMCP)。从第一行代码到上线,中间踩的坑比我预想的多得多——Typecho 是个很干净的博客程序,但它的核心有不少「读源码才知道」的行为,而这些行为恰好都长在「主题输出」和「对外写入」这两条路上。
这篇把整个过程摊开写:怎么起步、怎么分工、怎么测试,以及 25 个真实踩过的坑(每条都是「现象 → 根因 → 修法」)。如果你也在写 Typecho 主题或插件,希望至少帮你省掉其中几个。
0. 起点:为什么不直接用现成的
需求其实很朴素:一个文档风的双列博客,代码块要好看,手机上能用,能被搜索引擎和 AI 正确理解,最好还能用 AI 直接写文章。
现成方案不是没有,但总有几处不合意:主题要么依赖构建工具和 CDN,要么 SEO 部分只做到一半;「AI 写作」这条路在 Typecho 生态里几乎是空白——原生根本没有对外的写作 API。于是干脆自己写,顺带把过程记录下来。
给自己定了四条约束,后面几乎所有设计取舍都从这里推出来:
- 零构建、零 CDN:不引 npm,不发外链,站内所有资源自洽;
- 不碰核心:
var/、admin/、install/、index.php保持与官方 1.3.0 发布包逐字节一致——这样以后升级才安全; - 服务端渲染:AI 爬虫大多不执行 JS,正文必须在 HTML 里;
- 结论可复现:任何「已修复/没问题」都要有一条能重跑的命令。
1. 环境与准备
- Typecho 1.3.0(PHP 8.3)
- nginx + php-cgi + MariaDB 的便携组合(方便随时推倒重来,也方便我确认「不是我机器的问题」)
- 一个本地域名 + 自签 CA,用来跑和线上一致的真实域名场景
准备工作里最重要的一件事,是先把 Typecho 的相关源码读一遍,而不是照着网上的主题教程抄结构。要读的其实不多:
| 关注点 | 看哪里 |
|---|---|
| 模板是怎么被路由到组件的 | var/Widget/Archive.php(indexHandle / singleHandle / categoryHandle …) |
| 正文、摘要、缩略图怎么来的 | var/Widget/Base/Contents.php、var/Widget/Base/Comments.php |
| 后台保存内容时发生了什么 | var/Widget/Contents/EditTrait.php、var/Widget/Contents/Post/Edit.php |
| 插件钩子与组件机制 | var/Typecho/Plugin.php、var/Typecho/Widget.php |
| 参数从哪来 | var/Typecho/Request.php |
事后看,第 5 行的 Request.php 和 Widget.php 是最值的——插件篇里最恶心几个坑都出在这两个文件。
2. 主题开发:流程与阶段
实际推进顺序大致是这样,回头看这个顺序是合理的(先跑通,再加功能,最后统一口径):
- 最小骨架:
index.php+header/footer/list/card,先确认模板匹配与循环正确; - 功能迭代:深浅色 → 目录 → 代码高亮与复制 → 阅读时长 → 封面 → 灯箱 → 归档/友链/关于三个页面模板;
- 后台体验:20 项主题设置 + 4 个自定义字段 + 媒体选择增强;
- SEO / GEO:canonical / OG / Twitter / JSON-LD +
robots.txt/sitemap.xml/llms.txt三个端点; - 统一安全口径:把「哪里转义、哪里不转义」定死,然后回头逐个改;
- 黑盒审计:拿真实 HTTP 请求和 headless 浏览器把每条路由、每个视口跑一遍。
第 5 步是最容易被低估的。功能阶段每写一个模板就随手输出一次,等回头统一时,已经散落在二十多个文件里了。
3. 主题篇:12 个坑
坑 1:首页整页 500,而且错误里什么都不说
- 现象:首页直接 500,日志里只有一句
Typed property Widget\Archive::$archiveSlug must not be accessed before initialization。 - 根因:Typecho 1.3 的
Widget\Archive里,private string $archiveSlug;是没有默认值的类型化属性,它只在分类/标签/作者/日期/单页/搜索这些分支里被赋值,indexHandle(也就是首页)从来不赋值。PHP 8 访问未初始化的 typed property 直接抛Error。 - 修法:包一层访问器,失败就当空字符串:
function logbook_archive_slug(\Widget\Archive $archive): string
{
try {
return (string) $archive->getArchiveSlug();
} catch (\Throwable $e) {
return ''; // 首页等场景核心根本没赋值
}
}坑 2:属性读出来是 null,但一声不吭
- 现象:
og:title某一版全是空值。 - 根因:1.3 把
archiveTitle/archiveSlug/archiveType/archiveDescription都改成了 getter/setter,不再提供___xxx()魔术方法。于是$archive->archiveTitle不再抛错,而是安静地返回null。 - 修法:一律走 getter(
getArchiveTitle()),并且在模板里对空值兜底。这个坑的教训是:PHP 里「从报错变成静默」的改动最危险,因为它会穿过所有测试。
坑 3:contentEx 钩子做正文后处理,完全不生效
- 现象:给正文标题补锚点、给图片加懒加载,挂
contentEx钩子后毫无效果。 - 根因:
Archive::singleHandle()在加载主题的 functions.php 之前就用正文生成 meta 描述了,正文被提前渲染并缓存,钩子挂上去的时候已经晚了。 - 修法:放弃钩子,改在模板层做输出缓冲——时序完全可控:
function logbook_content(\Widget\Archive $archive)
{
ob_start();
$archive->content();
echo logbook_enhance((string) ob_get_clean());
}坑 4:同一个页面出现两个 canonical
- 现象:文章页里 canonical 有两条,指向同一个地址。SEO 上这是互相矛盾的信号。
- 根因:Typecho 的
$this->header()在「单页且非自定义首页」的情况下,已经输出过一条 canonical 了。 - 修法:只在核心没输出时补,并且把「404 页不输出 canonical」也一并处理:
$typechoHasCanonical = $archive->is('single') && !$archive->is('index');
if (!$is404 && !$typechoHasCanonical) { /* 输出自己的 */ }验证方式是数一遍:8 类页面的 canonical 计数全部等于 1,404 页等于 0。
坑 5:标题里的 </script> 直接截断 JSON-LD
- 现象:一篇文章标题里带了
</script>,页面的<script>标签数量从 6 涨到 19,结构化数据全废,还顺手开了个 XSS 口子。 - 根因:
json_encode()带了JSON_UNESCAPED_SLASHES,</script>被原样输出,提前闭合了脚本块。 - 修法:加
JSON_HEX_TAG(把<变成\u003C)。JSON 依然是合法且可读的。
坑 6:主题里好几处标题没转义
- 现象:审计时实测出多个泄漏点:
<title>、feed 的<link title="…">、侧栏最新文章、相关文章、404 的「顺便看看这些」、上下篇导航、导航栏里的页面名与分类名。 - 根因:核心的
$archive->title()、$item->name()是输出型且不转义的方法;更麻烦的是核心的thePrev()/theNext()还会把标题塞进title="…"属性里。 - 修法:所有列表与导航统一
htmlspecialchars(..., ENT_QUOTES, 'UTF-8');上下篇干脆自己实现(自己查库、自己转义,顺带过滤掉加密文章和非publish状态);<title>与三条 feed link 也自己输出。
顺带说一个反向的坑:修 <title> 的时候我引入了 zxfblog · zxfblog · 写代码,也记录生活。 这种重复,最后靠「页标题与站点名相同时不重复追加」修掉。修转义的时候很容易顺手制造重复。
坑 7:转义口径搞错会双重转义
- 现象:
Tom & Jerry在前台变成Tom &amp; Jerry。 - 根因:一开始我写的是「入库
htmlspecialchars+ 输出再转义」。 - 修法:定死口径——库里存原文(写入侧只做字符集收敛)+ 输出时转义,与 Typecho 核心一致。这样 feed、JSON-LD、搜索等其它消费方拿到的也是干净文本。
这条其实不是技术难题,是口径问题:一定要在写第一个模板之前定下来,否则就是全站返工。
坑 8:mail 绝对不能转义
- 现象:统一转义时顺手把评论邮箱也转了,结果头像全变成默认图。
- 根因:gravatar 用
md5(mail)取图,转义后 md5 当然变了。 - 修法:邮箱只做「剥离危险字符 + 限长」,不参与 HTML 转义。URL 同理,只保留 http(s)。
坑 9:空标题让结构化数据缺必填项
- 现象:Typecho 允许无标题文章,此时
og:title和 JSON-LD 的headline(必填)都是空串。 - 修法:写一个兜底函数,标题为空时退回「站点名」,单页再追加日期,实测输出
zxfblog · 2026-09-26。
坑 10:上线当天才发现的卡片标题 bug
- 现象:首页每张卡片的标题都是「无标题文章」,分类页每张都显示分类名。
- 根因:卡片的标题走了
logbook_title_of(),而这个函数在非单页场景返回的是getArchiveTitle()——也就是归档标题(首页为空、分类页是分类名),而不是当前文章标题。卡片上这处是itemprop="name headline",属于 BlogPosting 结构化数据,标题错了 SEO 也跟着错。 - 修法:
card.php只在列表循环里被引入,直接取当前行标题即可:
$lbTitle = trim((string) $this->title); // 循环内即当前文章标题
echo htmlspecialchars($lbTitle !== '' ? $lbTitle : _t('无标题文章'), ENT_QUOTES, 'UTF-8');值得单独说这条,因为它是上线部署时顺手发现的,而不是测试发现的。发现后我先在本地环境复现了同一个首页——输出完全一致,才确认它是既有缺陷、不是部署引入的。这个「先复现再动手」的习惯,比任何断言都省事。
坑 11:移动端配色按钮停在正中间
- 现象:≤860px 时,页头的配色按钮悬在屏幕中央,旁边空空荡荡。
- 根因:页头是 flex,
.head-tools和.nav-toggle都写了margin-left:auto,剩余空间被两个 auto 边距平分了。 - 修法:只给排在前面的那个吃 auto,后面的自然紧跟:
@media (max-width: 860px) {
.head-tools { order: 2; margin-left: auto; }
.nav-toggle { order: 3; }
}这类问题肉眼很难判断「到底差了几个像素」,我最后是用 CDP 量 left/right 和间距来确认的。
坑 12:没有 JS 时,手机端完全没法导航
- 现象:禁用 JS 后,≤860px 的导航是
visibility:hidden,靠 JS 加.is-open才显示;目录也是 JS 生成的,于是整站无法导航。 - 修法:用
<noscript>覆盖样式——菜单静态展开、隐藏必然是空的目录盒。降级不追求好看,但必须能用。
4. 插件篇:从 0 到 /mcp-api
4.1 架构与两个取舍
链路很简单:
AI 客户端 → mcp/server.js → HTTPS(Bearer) → /mcp-api → Api.php(鉴权/分发) → Service.php(业务) → Typecho 核心 / MySQL两个刻意的取舍:
- 写入走核心,读取直接查库。写入交给
PostEdit/PageEdit,保证分类、标签、附件、字段、钩子这套逻辑和后台完全一致;读取则直接 SQL,因为我要能读到草稿和私密内容(后台的组件默认会帮你过滤掉)。 - 单一端点 + 一个
action参数。没有为每个功能开一个路由,Service::handle()里一个switch分发 23 个 action,路由注册只有一行:
\Utils\Helper::addRoute(self::ROUTE_NAME, self::ROUTE_PATH, 'LogbookMCP_Api', 'dispatch');另外,主题自定义字段不写死在 MCP 里:接口只暴露一个通用的 fields 对象,能填什么由 site.info 返回的 themeFields 决定。这样换主题不用改 MCP。
4.2 插件篇:13 个坑
坑 13:对已发布文章传 status: "draft",它不会下架
- 现象:接口返回成功,
status看着也对,但线上文章还在。 - 根因:Typecho 核心在保存时,如果内容已存在,会新建一条
type='revision'的修订草稿挂在它下面(EditTrait::save()),原文章的status一动不动。 - 修法:不改核心语义(这是合理设计),而是在返回值里把这件事说清楚:加
draftAsRevision与revisionCid两个字段。接口的诚实程度决定了调用方会不会误判。
坑 14:post.update {password:'x'} 返回成功,但文章没被保护
- 现象:给文章设密码,接口说成功了,打开链接却不需要密码。
- 根因:
EditTrait::checkStatus()只要visibility是private/waiting/publish/hidden之一,就无条件清空密码;想真正设置密码,必须把visibility传成字面量'password'。 - 修法:密码非空就强制
visibility = 'password'。同时处理了反向问题:密码文章的status在库里是publish,如果局部更新时不带密码,按 status 推出visibility='publish',核心就会把密码清掉——等于「随手改个摘要就把加密解了」。所以密码必须「不传则沿用」。
坑 15:刚发布立刻访问是 404
- 现象:接口返回
permalink,紧接着curl过去 404。 - 根因:前台的可见性条件是
created < 当前请求时间(var/Widget/Archive.php:646),发布时间等于当前这一秒就不可见,要等下一秒。 - 修法:新建且未显式指定
created时,写time() - 1。显式传未来时间仍然保留定时发布的语义,不做干预。
坑 16:finally 里的 return 吃掉了异常,报错永远是假的
- 现象:标题 151 个字符(超
varchar(150))时,接口返回 500,错误信息是「请检查插件设置里的『以哪个用户发布』」——和真实原因毫无关系。 - 根因:
Typecho\Widget::widget()的finally块里有return $widget;。PHP 语义下,finally中的 return 会丢弃正在传播的异常。于是 MySQL 的SQLSTATE[22001] … 1406 Data too long for column 'title'被吞掉,调用方只看到$widget->cid没被设置 → 0 → 落到兜底分支。 - 修法:入口主动校验,给调用方一个明确的 400。注意要按转义之后的长度算,因为
&→&会变长:
$escapedLength = mb_strlen(htmlspecialchars($title, ENT_QUOTES, 'UTF-8'), 'UTF-8');
if ($escapedLength > self::MAX_TITLE_LENGTH) {
throw new ApiError('bad_request', 'title 过长:转义后 ' . $escapedLength . ' 个字符…');
}根治要绕过 widget() 复刻写入流程,我没做——所以同类问题(例如 slug 唯一键冲突)目前仍表现为「写入失败」。这是已知的短板。
坑 17:接口返回成功,但分类、标签、自定义字段没写进去
- 现象:偶尔(尤其是短时间内连续两次更新)分类、标签、字段静默不生效。
- 根因:核心
publish()/save()里是if ($this->update($contents, $where)) { setCategories(); setTags(); applyFields(); },而这个判断用的是 MySQL 的 affected_rows。当这次 UPDATE 每一列都和库里一模一样时(最常见的就是两次更新落在同一秒、连modified都相同),affected_rows 是 0,后面三步全被跳过。 - 修法:不能改核心,就在写完之后做一次幂等核对与补写:
ensureFields()+repairTaxonomy()。「接口返回成功」和「数据真的变了」是两件事,写操作后必须回读核对。
坑 18:一个空数组,让整个 JSON 响应变成不可解析
- 现象:某些调用返回的内容前面多了一段
<br /><b>Deprecated</b>: trim(): Passing null to parameter #1…,客户端JSON.parse直接失败。 - 根因:插件把
category传成了空数组[]。Request::getArray()里!empty($result)为假、$exists为真,于是走到return [$this->get($key)](get()没传默认值)→ 空数组变成[null]→EditTrait::setCategories()的array_map('trim', $categories)在 PHP 8.1+ 抛弃用警告。 - 修法:两头都堵——
fallbackCategories()永不返回空数组(默认分类 → 现有第一个 → 新建),Api::dispatch()开输出缓冲把杂音拦掉。对外接口的输出缓冲是必需品,不是保险。
坑 19:array_merge() 一个 TypeError,让后台保存插件设置必 500
- 现象:插件设置页能打开,一保存就 500。
- 根因:
Plugin::all()返回的是\Typecho\Config对象(extends \stdClass implements \Iterator, \ArrayAccess),把它喂给array_merge()在 PHP 8 直接抛TypeError。 - 修法:
$base = $current instanceof \Typecho\Config ? $current->toArray() : (array) $current;
这条最值得记住的不是修法,而是它为什么一直没被发现:既有测试只 GET 过配置页,从来没有 POST 过。所以整个 configHandle() 从未被执行。教训:测试要覆盖写入路径,只读一遍页面等于没测。
坑 20:停用插件留下孤儿钩子,后台全站 500
- 现象:停用 CommentNotifier 后,前台完全正常,后台每个页面都 500。
- 根因:Typecho 停用插件时,靠
activated[插件名]['handles']这本账去摘掉回调;而这本账只收录「组件名以插件名:开头」的钩子。CommentNotifier 全挂在核心组件上(Widget_Feedback:finishComment、admin/footer.php:end…),一个都没记上 → 摘不掉 → 全局 handles 里留着指向已不再加载的类,其中最致命的admin/footer.php:end每个后台页面都触发。 - 修法:写了一个
plugin-handles-repair.php,按「回调所属插件是否还在activated里」清孤儿并重建记账;安装路径改成用「安装前后快照差集」记账。顺带发现 XCaptcha 的记账形态也是错的,一旦停用,前台评论也会 500。
坑 21:重新启用插件,静默轮换掉所有客户端的令牌
- 现象:只要在后台点一次「停用 → 启用」,所有已配置的 MCP 客户端集体 401,而且报错和「令牌填错」无法区分。
- 根因:
activate()每次都makeToken()并整体覆盖配置行。 - 修法:激活时合并已有配置,令牌只在不存在时生成。令牌这类凭证,默认行为应该是「保持不变」。
坑 22:启用插件不建配置行,后台又 500
- 现象:
.php脚本方式启用插件后,后台报「插件 X 的配置信息没有找到」。 - 根因:禁用会删除
plugin:<插件名>这条配置行,而插件运行期Helper::options()->plugin('X')读不到就抛PluginException。更绕的是:直接调config($form)也建不起来,因为插件靠$_GET['activate']判断自己正处于激活流程。 - 修法:启用后若缺配置行,就设
$_GET['activate']、构建config()表单、把默认值写库。
坑 23:白名单里的一个笔误,把整个白名单废掉
- 现象:IP 白名单写
127.0.0.1/abc(手滑),结果放行了所有 IP。 - 根因:位宽解析是
(int) $bitsRaw,'abc'被转成0,语义就变成了/0——即匹配全网。 - 修法:用
inet_pton()做二进制前缀比较,IPv4/IPv6 同一套逻辑;地址族与位宽都严格校验,解析失败一律不放行(fail-closed)。/999、/-1、/abc、/7、/25全部进了单测。
安全判断里,「解析失败」必须往「拒绝」那一侧倒,否则一个笔误就是全网开放。
坑 24:评论作者可以注入活的 <img onerror>
- 现象:通过插件的评论回复接口写入的评论,作者名里的 HTML 会被浏览器真正执行(存储型 XSS)。
- 根因:一条三段链——插件的 reply 分支把
author无过滤直接写库,绕过了Widget\Feedback上的xssCheck规则;而核心Widget\Base\Comments::author()是裸echo,gravatar()还会把作者名塞进<img alt="…">。作者的「安全」完全依赖入口校验。 - 修法:双层处理。写入侧按
Validate::xssCheck的字符集清洗(剔除( ) \ " < >与控制字符)并限长 150;输出侧在渲染前一行把所有行数据里的 author 换成转义值——Widget::__set()会覆盖row['author']、__get()优先读它,于是<cite>和头像alt两个泄漏点一处堵死。
插件是「绕开核心校验的写入方」时,必须自己把校验补上。
坑 25:未鉴权的 ping 把站点指纹送出去
- 现象:一个不需要令牌的健康检查接口,返回了 PHP 版本、Typecho 版本、站点地址、站点标题、时区。
- 修法:静默判断令牌(
tokenAccepted()),没带令牌时只回插件名与版本——足够回答「路由通了没、插件在不在」,但不值得为它暴露指纹。
5. 其余零散坑(不编号,但都真实发生过)
- CLI 脚本只认一种序列化格式,把整个插件注册表清空了:后台写的是
json_encode(),脚本只@unserialize(),解析失败就把$plugins置空数组写回。运行期其实是两种都支持的(Options::___plugins()里按a:前缀分流)。修法:两种都试,解析不出就中止,写回保持原格式。 - 空字符串环境变量挡住了配置文件回落(Node 端):
url/token用||能正确回落,但insecure用了??——空字符串不是 nullish。统一改成envOr(),「空字符串 = 未设置」。 - 协议版本协商原来等于没协商:
initialize把客户端上报的版本原样回显,客户端报9999-01-01也照抄。改成支持列表协商(2025-06-18/2025-03-26/2024-11-05)。 - 不带换行结尾的消息会永久滞留:stdio 只在遇到
\n时解析,残留缓冲既不解析也不报错也不超时,症状是「服务端看着正常就是不回」。修法:空闲 120ms 后,只在残留内容能JSON.parse成功时才消费,避免误伤分片慢发的客户端。 - 测试中断留下垃圾,让「零漂移」结论不可信:清理依赖内存里登记的 cid,进程一死就丢。改成所有测试内容标题统一带
#DEEPTEST标记,清理只认标记,并且「开头预清理 +finally兜底」各跑一次。 sitemap.xml开头多了 3 个字节:config.inc.php被写进了 BOM,而三个端点都会include它。根因是 Windows PowerShell 5.1 的Set-Content -Encoding UTF8会写 BOM。修法:改用[System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($false)))。给 PHP 写文件时,BOM 是隐形的炸弹。
6. 测试与审计:把「我觉得没问题」变成可复现的结论
主题和插件分别做了几轮,最后的口径是552 项断言全过、站点数据零残留。手段其实是混着用的:
- 黑盒 HTTP 断言:28 条路由、状态码、canonical / title / description / OG / JSON-LD 各只出现一次、sitemap 每条 URL 逐条请求;
- headless 浏览器(CDP):真实交互(发布文章、勾验证码评论)、几何测量(页头按钮位置)、多视口零横向溢出、截图留档;
- 数据库直查:断言「数据真的变成期望的样子」,而不是只看接口返回值;
- 与官方包逐文件 SHA256 比对:证明核心没被改过;
- 恶意/边界注入:空标题、无分类、加密文章、
<!--more-->、超长标题、超长关键词、分页越界、畸形 JSON。
其中真正抓到大鱼的是最后两条。基线 438 项全绿之后,我回头逐行读测试代码找盲区,补了 5 个套件共 58 项断言,结果挖出 2 高危 / 3 中危 / 4 低危(包括 </script> 截断和评论作者 XSS),修复过程中又被新单测抓出一个位宽 fail-open。
三条经验:
- 测试全绿 ≠ 没问题,它只说明「被测到的地方没问题」。要定期反问:哪些路径我从没 POST 过?(坑 19 就是这么漏掉的)
- 写入类断言必须回读核对,因为核心有几处「affected_rows = 0 就跳过后续写入」的行为(坑 17);
- 测试自己会留垃圾,清理策略不能依赖进程内存(否则给站点留下孤儿数据,还得出「零漂移」的假结论)。
7. 上线(宝塔)当天的四个坑
主题和插件都在本地跑通之后,搬到宝塔服务器上部署,又补了四条:
- 写操作接口不能无脑重试:建站请求第一次其实已经成功,客户端因为一次瞬时 404 自动重试,第二次撞上「域名已存在」报错,看起来像失败。非幂等的写操作必须关掉自动重试(
retries=0),否则你会对着一个「已经成功」的错误查半天。 - 伪静态是 server 级 rewrite,先于 location 匹配执行:我把
/robots.txt → robots.php写在location = /robots.txt里,一直 404——因为 Typecho 的兜底规则先把不存在的路径改写成/index.php/robots.txt了。SEO 端点必须写在同一个 rewrite 阶段、并且在伪静态 include 之前。 - nginx 的
error_page 404 /404.html;会盖掉主题的 404 页:站点里没有404.html,于是所有 404 都变成 nginx 的白板页。注释掉这一行,主题的404.php才生效。 - 插件里写死的相对日志路径会撞
open_basedir:日志目录原本取<站点根>/../server/logs(本地便携栈的约定),线上不存在,于是每次调用都往 nginx 错误日志里刷一条警告。要么把日志开关关掉,要么把路径改成站点根目录内的可写目录。
还有一条不算坑但很重要:部署后我用本地环境复现了同一个页面,正是这次对比让「卡片标题 bug」被定性为既有缺陷,而不是新引入的回归。
8. 十条经验
- 先读源码,再写模板。 Typecho 的坑几乎全在「你以为的时序」和「实际的时序」之间。
- 口径要先行。 转义、时间、可见性、错误文案——定下来再动手,返工成本差一个量级。
- 安全判断 fail-closed。 解析失败就拒绝,不要让一个笔误把防护变成装饰。
- 写操作后回读核对。 「返回成功」不等于「数据变了」。
- 对外接口永远开输出缓冲。 一条 PHP 弃用警告就能毁掉整个 JSON 响应。
- 不要改核心。 与官方包逐字节一致,是未来能安全升级的唯一前提。
- 输出层做兜底转义。 即使入口承诺校验过,历史脏数据和第三方写入方也会绕过它。
- 别只测读路径。 配置保存、字段写入、删除清理这些「写」的地方最容易没人碰。
- 测试的清理策略要和进程生命周期解耦,否则你会得到假的「零残留」。
- 结论必须可复现。 每条「已修复」后面都该跟一条能重跑的命令;做不到的,就在报告里写「未覆盖」。
9. 还没做的
写清楚边界比写得漂亮更重要。目前明确没做或没覆盖的:
- 多用户与权限:令牌等价于管理员,没有细粒度授权;
- 真实第三方验证码:交互式组件的真实 token 无法自动化,只验证了「渲染与提交链路」;
- SMTP 发信、评论分页、XML-RPC;
- 1000+ 文章的性能:当前数据量下没有可测的性能曲线;
- 老浏览器:CSS 用到了
:has()、color-mix(),很旧的浏览器样式会缺失(功能不受影响,因为 JS 有降级); widget()吞异常导致的同类问题(如 slug 唯一键冲突)仍表现为写入失败,没有根治。
这些不是「以后再说」,而是已经知道自己不知道的部分——写在这里,免得下次自己也被「552 项全过」这句话骗过去。
暂无评论
评论 RSS本文的评论功能已关闭