· 24 分钟 · 安全 / 运维 / Cloudflare / SEO
上线之后:给网站做一次体检,以及 Cloudflare 能不能替代 VPS
功能都能用,不代表没有问题。这篇记录一次完整的上线后检查:自动检查、依赖漏洞、自己扮演攻击者测线上、安全响应头、工具查不出来的逻辑漏洞、搜索引擎,以及怎么修。最后讲清楚 Cloudflare 的免费服务能替代 VPS 到什么程度。
这是 Seabloom 建站记录的第三篇。第一篇搭起了静态网站,第二篇加上了登录、留言板和站长后台。
到这里,网站的功能都能用了。但「能用」和「没问题」是两回事:有些问题只有在有人故意找茬时才会暴露,有些问题根本不会报错,只是悄悄地让网站变差。
所以这一篇做了一件很多个人项目都会跳过的事:上线后,把整个项目从头到尾检查一遍。查出了 9 个问题,没有一个是严重漏洞,但每一个都值得修。
读法建议:
- 第一到八节、第十节按检查顺序讲,每一节都是「怎么查 → 查出了什么 → 怎么修 → 怎么验证修好了」。
- 第九、十一节回答两个常被问到的问题:Cloudflare 的免费存储怎么选,Cloudflare 能不能替代 VPS。
- 第十二节是汇总表,第十三节是面试问答。
一、为什么上线后还要体检
写代码的时候,我们关注的是「这个功能怎么实现」。检查的时候,要换一个角度:
- 用户会怎么用错? 比如已经登录了,又打开登录页。
- 攻击者会怎么滥用? 比如注册一个叫「站长」的账号。
- 时间长了会怎么样? 比如数据库里的过期数据越积越多。
- 别的程序会怎么看我们的网站? 比如搜索引擎、微信的链接预览。
这次检查分了四层,从全自动到全手工:
01
自动检查
格式、代码规范、类型检查、单元测试。工具几秒钟跑完,能抓到写错的代码,但抓不到设计上的问题。
02
依赖安全
用 pnpm audit 查我们用的第三方库有没有已知漏洞。漏洞不在我们的代码里,但一样会影响我们。
03
线上测试
用 curl 对着真实网站发各种「不正常」的请求:伪造来源、不登录访问后台、发格式错误的数据,看服务器怎么回应。
04
读代码
一个文件一个文件地看,带着「如果有人故意这样做会怎样」的问题。工具查不出来的逻辑漏洞,只能靠这一层。
检查结果按严重程度分成两类:
| 类别 | 问题 |
|---|---|
| 真正的问题(4) | 昵称可以冒充站长;页脚邮箱不存在;页面没有安全响应头;依赖里有低危漏洞 |
| 做得不好的(5) | 错误提示是英文;后台筛选会闪;登录后还显示登录表单;缺少 SEO;过期会话不清理 |
下面按检查顺序逐个讲。
二、自动检查:先让工具查一遍
第一篇讲过「代码质量的六道防线」,这里把它们一次性全跑一遍:
pnpm install --frozen-lockfile # 依赖和锁文件是否一致
pnpm format:check # 格式
pnpm lint # 代码规范
pnpm typecheck # 前端和后端的类型
pnpm test # 单元测试和接口测试
pnpm build # 能不能构建成功
全部通过。 这一层没查出问题,但它的作用不是「找出问题」,而是排除一大类问题:拼写错误、类型不对、改了 A 弄坏了 B,这些都不用再操心了,后面可以把注意力放在工具查不出来的地方。
--frozen-lockfile 在检查什么? package.json 里写的是版本范围(比如 ^7.0.1),pnpm-lock.yaml 里记的是实际装的精确版本。加上这个参数,如果两者对不上,安装会直接失败,而不是悄悄更新锁文件。Cloudflare 构建时用的就是这个模式,所以本地也要用它检查一遍,否则可能出现「本地能跑,线上构建失败」。
三、依赖安全:别人的代码也会有漏洞
怎么查
pnpm audit
它会把项目里每一个依赖(包括依赖的依赖)的版本,和公开的漏洞数据库比对。结果:
1 vulnerabilities found
Severity: 1 low
Package: katex
Vulnerable versions: <0.18.2
Paths: . > rehype-katex > katex
读懂这条结果
- katex 是渲染数学公式的库,博客文章里的公式就是它画的。
- 有意思的是,我们的
package.json里直接写的是 katex 0.19.1,已经是修复后的版本了。 - 问题出在
Paths: . > rehype-katex > katex这一行:rehype-katex这个插件自己又依赖了一份 katex,版本是 0.16.x。
这叫间接依赖(也叫传递依赖)。用 pnpm why katex 可以看得很清楚:
katex@0.16.47
├─┬ micromark-extension-math@3.1.0
│ └─┬ remark-math@6.0.0
└─┬ rehype-katex@7.0.1
katex@0.19.1
└── haiyangliu-web (dependencies)
项目里同时装着两个版本的 katex。我们直接用的是新版,但真正在构建时渲染公式的,是插件自带的旧版。
还有一个副作用:页面引入的公式样式(CSS)来自 0.19,渲染公式的却是 0.16。两个版本的 CSS 类名基本一致,所以没出问题,但这是一个隐患。
怎么修
插件的作者还没发新版,我们没法改它的 package.json。pnpm 提供了 overrides(覆盖):不管谁依赖 katex,都强制用我们指定的版本。写在 pnpm-workspace.yaml 里:
overrides:
katex: ^0.19.1
重新安装后:
$ pnpm why katex
katex@0.19.1 ← 只剩一个版本
$ pnpm audit
No known vulnerabilities found
为什么敢这样改
强制一个库用它没声明过的版本,是有风险的:如果新版改了接口,插件就会出错。这次敢改,是因为:
- katex 渲染公式的主函数
renderToString多年来一直没变; - 改完以后重新构建了整个网站,两篇带公式的文章都能正常显示。
这是一个通用原则:升级依赖后,验证的标准不是「装上了」,而是「用到它的地方都还正常」。
顺手还把 React 从 19.3.0 升到了 19.3.1。小版本(最后一位数字)一般只修 bug,可以放心升;大版本(第一位数字)会有不兼容的改动,比如 ESLint、TypeScript 都有新的大版本,这次没有升,要单独评估。
这个漏洞危险吗? 严重程度是「低」,而且公式只在构建时由我们自己写的文章生成,用户没法输入公式,攻击者够不着。但修复成本很低,没有理由留着。
四、线上测试:自己当一回攻击者
第二篇里写了很多安全措施,测试也都通过了。但测试跑在模拟环境里,线上的真实网站是不是也这样表现,要自己去敲一遍。工具是 curl,它可以发出任意内容的 HTTP 请求,比浏览器自由得多。
测试 1:伪造来源(CSRF)
# 假装从 evil.example 发起注册请求
curl -X POST https://haiyangliu.com/api/auth/register \
-H "Origin: https://evil.example" \
-H "Content-Type: application/json" \
-d '{"email":"x@x.com","password":"12345678","nickname":"x"}'
# → 403 {"error":{"code":"bad_origin","message":"请求来源不对"}}
再试不带 Origin 头的请求,同样是 403。✅
测试 2:不登录访问后台
curl https://haiyangliu.com/api/admin/stats
# → 401 {"error":{"code":"unauthenticated", ...}}
✅ 后台的页面虽然谁都能打开(它只是一个静态 HTML),但页面里的数据全部来自 /api/admin/*,没有权限就拿不到任何东西。
测试 3:发一个格式错误的请求
curl -X POST https://haiyangliu.com/api/auth/register \
-H "Origin: https://haiyangliu.com" \
-H "Content-Type: application/json" \
-d 'not json'
# → 400 {"error":{"message":"Invalid input: expected object, received null"}}
❌ 安全上没问题(请求被拒绝了),但错误信息是英文的,而且是给程序员看的。这是第一个「做得不好」的地方。
为什么会这样? 第二篇讲过,所有请求体都先过 zod 校验,每条规则我们都写了中文提示,比如「密码至少 8 位」。但请求体根本不是 JSON 时,代码会把它当成 null,null 连「是个对象」这一关都过不了,触发的是 zod 的内置错误,我们没写过中文提示,它就用了默认的英文。
怎么修: zod 4 提供了全局配置,可以给所有「没有单独写提示」的错误统一设置文案:
// worker/lib/validation.ts
z.config({ customError: () => "请求格式不对" });
zod 选择错误信息的优先级是:规则上单独写的 > 全局配置 > 内置默认。所以「密码至少 8 位」这类提示不受影响,只有原来会掉到英文默认值的情况,现在会显示「请求格式不对」。
加了一个测试,覆盖三种情况:请求体是 null、是一个字符串、字段类型不对(邮箱传了数字)。
测试 4:404 页面
访问一个不存在的地址,返回的是网站自己的 404 页面,状态码也是 404(不是 200)。✅
状态码很重要:如果「找不到」的页面返回 200,搜索引擎会以为这是一个正常页面,把它收录进去,这叫 soft 404。
五、安全响应头:告诉浏览器怎么保护页面
怎么查
curl -I https://haiyangliu.com/
-I 只看响应头,不看内容。结果里有 content-type、cache-control 这些常规的头,但一个安全相关的头都没有。
什么是安全响应头
浏览器有很多安全功能,但出于兼容性考虑,默认都是关着的。网站要在响应头里明确告诉浏览器「请开启」。最常用的几个:
| 响应头 | 防什么 |
|---|---|
X-Frame-Options: DENY | 点击劫持。坏网站用一个透明的 iframe 把我们的页面盖在它的按钮上面,你以为点的是「领红包」,实际点的是我们网站上的「删除留言」。设成 DENY 就不允许被任何网站嵌入 |
Content-Security-Policy: frame-ancestors 'none' | 同上,是新一代的写法。两个都写,新旧浏览器都能防 |
X-Content-Type-Options: nosniff | 类型猜测。服务器说「这是图片」,浏览器却「猜」它是脚本并执行。加上这个头,浏览器只信服务器说的类型 |
Referrer-Policy: strict-origin-when-cross-origin | 网址泄露。点外链时,浏览器默认会告诉对方「我是从哪个网址来的」。这个设置让外站只能看到 haiyangliu.com,看不到具体是哪一页 |
Permissions-Policy: camera=(), ... | 声明本站用不到摄像头、麦克风、定位、支付。万一页面被注入了恶意脚本,它也申请不到这些权限 |
Strict-Transport-Security: max-age=31536000 | HTTPS 降级。告诉浏览器:未来一年内,访问这个域名一律走 HTTPS,即使用户手动输入 http:// |
怎么修:页面
我们的页面是静态文件,不经过后端代码,没法在代码里加响应头。Cloudflare 为静态资源提供了一个约定:在输出目录里放一个叫 _headers 的文件。我们放在 public/ 下,构建时会被原样复制到 out/:
# public/_headers
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Strict-Transport-Security: max-age=31536000
/* 表示对所有路径生效。
为什么 CSP 只写了这么几项? 完整的 CSP(内容安全策略)可以规定「只允许执行哪些脚本」,是防 XSS 的最强手段。但 Next.js 会在页面里插入很多内联脚本(比如切换深浅色、加载页面数据),完整的 CSP 会把它们也拦掉,网站就坏了。要支持内联脚本,需要给每个脚本加一个随机数(nonce),而随机数必须每次请求都不一样,静态页面做不到。
所以这里只用了 CSP 里「不影响脚本」的几项:
frame-ancestors 'none':不许被嵌入;base-uri 'self':不许注入的代码改掉页面的基础网址;form-action 'self':表单只能提交到本站;object-src 'none':不许加载 Flash 这类插件。
我们防 XSS 主要靠另外两层(第二篇讲过):React 默认转义所有用户内容;登录 Cookie 是 HttpOnly 的。
为什么 HSTS 没有加 includeSubDomains 和 preload? 这两项的效果是「所有子域名也必须是 HTTPS」和「写进浏览器的内置名单」。一旦生效,撤回非常困难(preload 名单要几个月才能移除)。我们以后可能会有一些子域名(比如测试环境),现在没必要把路堵死。
怎么修:接口
/api/* 的请求经过后端代码,_headers 文件管不到。Hono 自带一个中间件,一行加上:
// worker/app.ts
app.use(secureHeaders());
app.use(async (c, next) => {
await next();
c.header("Cache-Control", "no-store");
});
第二个中间件是额外加的:接口返回的是个人数据(比如「当前登录的是谁」),要告诉浏览器和中间的代理服务器「不要缓存」。否则在网吧这类公共电脑上,下一个人可能看到上一个人的数据。
验证
在本地真实运行环境里(wrangler dev)用浏览器请求首页和接口,读出响应头:
页面 / x-frame-options: DENY
content-security-policy: frame-ancestors 'none'; ...
strict-transport-security: max-age=31536000
接口 /api/... x-content-type-options: nosniff
cache-control: no-store
上线后再用 curl -I 对着真实网站查一遍,结果一致。
六、逻辑漏洞:工具查不出来的问题
前面的问题,都是「某个东西没开」或者「某个版本太旧」,有工具或清单可以对照。接下来这个,没有任何工具会报错,代码完全按设计运行,问题出在设计本身。
发现问题
读注册接口的代码时,注意到昵称只检查了长度(1 到 20 个字)。然后问自己:如果有人故意起一个特别的昵称,会怎样?
- 冒充站长:注册一个叫「布布」(站长的昵称)或者「站长」的账号,在留言板上发「本站即将关闭,请加微信 xxx」。其他访客没有任何办法分辨谁是真的站长。
- 绕过敏感词:留言内容会过敏感词检查,但昵称不会。用脏话当昵称,每条留言都会公开展示。
这两个问题的共同点:每个功能单独看都是对的。昵称功能没问题,敏感词功能也没问题,问题在于两者之间漏了一环。这种问题只有带着「攻击者会怎么想」去读代码,才能发现。
修复:三层
第一层:昵称也过敏感词检查。 复用留言用的同一个函数,同一份词表。
第二层:保留词。 有些词本身不脏,但不能让普通用户用:
// worker/lib/moderation.ts
export const RESERVED_NICKNAME_WORDS = ["站长", "管理员", "官方", "admin", "seabloom"];
export function checkNickname(nickname: string) {
if (findBlockedWord(nickname)) return "昵称里有不允许使用的词";
if (findBlockedWord(nickname, normalizedReserved)) return "这个昵称容易和站长混淆,换一个吧";
return null;
}
匹配前会去掉空格和标点、统一小写(第二篇讲过这个归一化),所以「站 长」「Admin」「ADMIN」都拦得住。
一开始保留词里还有「管理」,后来删掉了:它会误伤「管理学小白」这种正常昵称。词表越宽,误伤越多,保留词只放真正会造成混淆的。
第三层:后端给出的身份标记。 前两层只能拦住「明显」的冒充,拦不住「布布」这个昵称本身(总不能不让别人叫布布)。真正的解决办法是:让真站长有一个别人伪造不了的标记。
// worker/routes/messages.ts,查留言时顺便查出作者的角色
author: { nickname: r.nickname, isAdmin: r.role === "admin" },
前端看到 isAdmin 为 true,就在昵称旁边显示一个深蓝底的「站长」小标签。
为什么别人伪造不了?因为 isAdmin 来自数据库里的 role 字段,而这个字段只能通过直接操作数据库修改(第二篇讲过,网页上没有任何入口)。不管昵称起成什么样,role 都是 user。
这其实是一个通用的安全原则:身份不能靠「用户自己说的」来判断,要靠「系统记录的」来判断。 昵称是用户自己填的,角色是系统记录的。
已有用户怎么办
新规则只管新注册的用户。上线前查了一下线上数据库,确认没有已经叫「站长」之类的账号:
wrangler d1 execute seabloom --remote --command \
"SELECT nickname FROM users WHERE nickname LIKE '%站长%' OR ..."
# → results: []
每次加新的校验规则,都要想一想已有的数据满不满足,否则可能出现「老用户的数据不合规、改资料时却改不了」这类问题。
测试
it("昵称不能含敏感词,也不能冒充站长", async () => {
for (const nickname of ["测试敏感词", "站 长", "小管理员", "Admin"]) {
const res = await post("/auth/register", { ...alice, nickname });
expect(res.status).toBe(400);
}
// 一个都没注册成功
expect(raw.prepare("SELECT COUNT(*) AS n FROM users").get()).toEqual({ n: 0 });
});
it("站长的留言带 isAdmin,普通用户的不带", async () => { ... });
七、其他「做得不好」的地方
过期会话不清理
第二篇的登录逻辑里有一行:登录时,删掉这个用户自己的过期会话。问题是,如果一个用户登录过一次就再也不来了,他的会话记录就永远留在数据库里。时间长了,sessions 表会越积越大。
改成每次有人登录,就清掉所有人的过期会话:
DELETE FROM sessions WHERE expires_at < ?
但这样有一个新问题:这条语句要找出所有过期的行,如果 expires_at 上没有索引,数据库就要把整张表从头扫到尾。会话越多,登录越慢。
所以加了一个新的 migration:
-- migrations/0004_session_expiry.sql
CREATE INDEX sessions_expires_at ON sessions(expires_at);
有了索引,数据库可以直接跳到「过期时间小于现在」的那一段,不管表有多大都很快。
为什么不用定时任务来清理? Cloudflare Workers 支持定时任务(Cron Triggers),也可以每天半夜清一次。但我们的网站登录频率不高,「登录时顺便清」已经足够,少一个需要维护的东西。网站大了再改成定时任务。
上线顺序还是第二篇讲的那条:先在线上数据库执行 migration,再推送代码。
后台切换筛选时闪一下
在站长后台点「待审核」这类筛选按钮时,会先闪出「没有符合条件的留言」,过一会儿列表才出来。
原因: 判断「显示空提示」的条件是「没在加载 并且 列表为空」。但「加载中」这个状态只在点「加载更多」时设置,切换筛选时没有设置。于是在新数据回来之前,界面以为加载已经结束了。
修法: 记下当前列表属于哪个筛选条件。筛选条件变了、新数据还没回来时,两者对不上,就显示「加载中…」,同时把旧列表变淡:
const queryKey = JSON.stringify([query.status, query.q, query.user?.id]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const switching = loadedKey !== queryKey; // 对不上 = 正在切换
为什么不直接在切换时 setLoading(true)?React 的规范不建议在 useEffect 里同步地修改状态,那样会多渲染一次,项目的 ESLint 规则也会报错。用「派生状态」(由已有状态算出来的值)更干净:switching 不需要单独维护,永远和真实情况一致。
验证的方法比较有意思:在浏览器里监听页面的每一次变化(MutationObserver),点一下筛选按钮,记录每一帧的内容。结果是第一帧就显示「加载中」,全程没有出现过「没有符合条件」。
已登录还显示登录表单
已经登录的人,再打开登录页或注册页,看到的还是空白表单。如果他再注册一次,会得到一个新账号,自己都不知道。
改成显示「你已经以 xxx 的身份登录了」,下面是回首页和去留言板的链接,以及「想换个账号,先在右上角退出」。
有一个细节:登录成功后,代码会先更新「当前用户」,再跳转页面。这中间有一瞬间,「已经登录」和「还在登录页」同时成立,如果不处理,会闪一下「你已经登录了」再跳走。所以条件是「已登录 并且 不是正在提交」。
页脚的邮箱不存在
页脚和隐私说明里都写着 hello@haiyangliu.com,但我们从来没开过这个邮箱,发过去会被退信。隐私说明里写的「想删除账号,请发邮件给站长」也就做不到。
修法见第十节。
八、搜索引擎和分享卡片
这一类问题不影响安全,也不影响功能,影响的是别的程序怎么理解我们的网站。
sitemap:告诉搜索引擎有哪些页面
https://haiyangliu.com/sitemap.xml 原来是 404。sitemap(站点地图)是一个 XML 文件,列出网站的所有页面,搜索引擎会优先按它来抓取。没有它,搜索引擎只能顺着链接一页页找,新文章可能很久才被收录。
Next.js 有专门的约定:在 app/ 下放一个 sitemap.ts,构建时自动生成 sitemap.xml:
// src/app/sitemap.ts
export const dynamic = "force-static"; // 静态导出必须加
const pages = [
"/",
"/about/",
"/projects/",
"/blog/",
"/photos/",
"/guitar/",
"/guestbook/",
"/privacy/",
];
export default async function sitemap() {
const posts = await getAllPosts();
return [
...pages.map((path) => ({ url: `${siteConfig.url}${path}` })),
...posts.map((post) => ({
url: `${siteConfig.url}/blog/${post.slug}/`,
lastModified: post.date,
})),
];
}
登录、注册、后台这几页故意不放进去:它们对搜索的人没有意义,页面上也已经声明了 noindex(不要收录)。
文章是从文章列表自动生成的,以后写新文章,sitemap 会自动更新,不用手动维护。
canonical:哪个才是正式地址
haiyangliu.com 和 www.haiyangliu.com 现在都能打开,内容一模一样。在搜索引擎看来,这是两个网站有完全相同的内容,会被判定为重复内容,两边的排名都会受影响。
解决办法是在每个页面的 <head> 里声明正式地址:
<link rel="canonical" href="https://haiyangliu.com/about/" />
意思是「不管你是从哪个地址访问到这一页的,它的正式地址是这个」。搜索引擎会把所有权重都算到正式地址上。
另一种做法是让 www 直接 301 跳转到不带 www 的地址,这样更彻底。但我们的静态页面不经过后端代码,要做跳转得在 Cloudflare 后台配规则。canonical 写在代码里,跟着 Git 走,换平台也不会丢,所以先用 canonical。
OpenGraph:链接被分享时显示什么
把网址发到推特、Telegram、Slack 这类平台时,它们会去抓页面,生成一张带标题和摘要的卡片。卡片的内容来自页面里的 OpenGraph 标签:
<meta property="og:title" content="关于 · Seabloom" />
<meta property="og:description" content="..." />
<meta property="og:url" content="https://haiyangliu.com/about/" />
<meta property="og:type" content="website" />
文章页的 og:type 是 article,并附上发布日期和标签。
(微信的链接预览不完全遵循 OpenGraph,它有自己的规则,要接入微信的 JS-SDK,个人网站一般做不了。)
实现:一个函数生成所有页面的 metadata
每个页面都要写 canonical、OpenGraph、Twitter 卡片,如果每页手写,很容易漏或写错。所以写了一个函数:
// src/lib/seo.ts
export function pageMetadata({ path, title, description, article }) {
return {
title,
description,
alternates: { canonical: path },
openGraph: { siteName: "Seabloom", locale: "zh_CN", url: path, title: ..., ... },
twitter: { card: "summary", title: ..., description },
};
}
// 每个页面只要一行
export const metadata = pageMetadata({ path: "/about/", title: "关于" });
path 是相对路径,Next.js 会用根布局里设置的 metadataBase(https://haiyangliu.com)补全成完整网址。
一个差点漏掉的 bug
第一版把首页的 canonical 写在了根布局里,想着「其他页面会覆盖它」。构建完检查生成的 HTML,发现:
login/index.html <link rel="canonical" href="https://haiyangliu.com/"/>
404.html <link rel="canonical" href="https://haiyangliu.com/"/>
登录页和 404 页没有调用 pageMetadata,于是继承了根布局的设置,声明自己的正式地址是首页。这会让搜索引擎以为「404 页就是首页」,属于明显的错误信号。
修法是把 canonical 从根布局移到首页自己的 page.tsx 里,根布局只保留所有页面都通用的部分(网站名、语言)。
教训: metadata 会从父布局继承到所有子页面,在布局里写「只属于某一页」的东西是危险的。这个 bug 不会报错,页面看起来也完全正常,只有去看构建产物才能发现。
九、Cloudflare 的免费存储:D1、KV、R2 怎么选
检查的时候被问到一个问题:「Cloudflare 有没有可以上传文件的免费服务?」这一节把 Cloudflare 能存东西的几个服务放在一起讲。
三种存储
| 服务 | 是什么 | 适合存 | 免费额度(大致) |
|---|---|---|---|
| D1 | SQLite 数据库 | 结构化数据:用户、留言、日志 | 总共 5 GB;每天读 500 万行、写 10 万行 |
| KV | 键值存储 | 读多写少的小数据:配置、缓存 | 1 GB;每天读 10 万次、写 1000 次 |
| R2 | 对象存储(文件) | 图片、视频、PDF 这类文件 | 10 GB;每月上传 100 万次、读取 1000 万次;下载流量不收费 |
(额度以 Cloudflare 官网为准,这里是 2026 年 10 月的大致数字。)
怎么选? 看数据长什么样:
- 有字段、要按条件查询、要排序分页 → D1。我们的用户和留言就在这里。
- 一个名字对应一个值,读的次数远多于写 → KV。比如「网站公告」这种站长改一次、所有人都要读的东西。
- 是一个完整的文件 → R2。
为什么文件不能存进数据库? 技术上可以,但很浪费:数据库是为「快速查找一小块数据」设计的,一张 5 MB 的照片放进去,每次查询都要搬动它,备份也会变得又大又慢。通常的做法是文件放对象存储,数据库里只存文件的地址。
R2 最大的特点:下载不收钱
传统的云存储(比如 AWS S3)有一项费用叫出口流量费:别人每下载一次你的文件,都按流量收钱。一个热门的图片网站,流量费往往比存储费贵得多。R2 不收这个钱,这对放照片的个人网站非常友好。
10 GB 大概能放两三千张压缩过的照片。
以后做照片上传,大致是什么架构
现在摄影页的照片是放在 Git 仓库里、构建时处理好的,每次加照片都要改代码。如果接上 R2,站长可以直接在后台上传:
01
后台选择照片
站长在后台选文件。浏览器先检查类型(只收 JPG、PNG、WebP)和大小,再压缩到合适的尺寸。
02
Worker 检查权限
和其他后台接口一样,先确认是站长。然后再检查一遍类型和大小,前端的检查可以被绕过,后端必须再查。
03
存进 R2
Worker 通过绑定(env.PHOTOS)把文件写进 R2,文件名用随机 ID,不用原文件名,避免路径注入和重名。
04
地址写进 D1
在 photos 表里记一行:文件 ID、标题、拍摄时间、上传人。和审计日志一样,放在同一个事务里。
05
摄影页读取
摄影页从接口拿到照片列表,图片地址指向 R2,由 Cloudflare 的 CDN 直接发给访客。
难点不在「存进去」,而在防止滥用:限制文件大小和类型,防止有人上传违规图片,图片压缩,以及删除时 R2 和 D1 要同步。所以这个功能会单独作为一期来做。
十、Email Routing:不开邮箱,也能用自己域名的邮件地址
回到第七节那个不存在的邮箱。要让 hello@haiyangliu.com 能收信,传统做法是开一个企业邮箱,一般要收费,还要多管一个账号。
Cloudflare 有一个免费功能叫 Email Routing(邮件转发):它不帮你存邮件,只是把发到你域名的邮件转发到你已有的邮箱。效果是:
- 对外公开的是
hello@haiyangliu.com; - 实际收信在自己的 Gmail 里;
- Gmail 地址不会暴露给发信人。
设置过程
wrangler 可以直接配置,不用进 Cloudflare 后台:
# 1. 添加目标邮箱(Cloudflare 会给它发一封验证邮件)
wrangler email routing addresses create 你的@gmail.com
# 2. 为域名开启邮件转发(会自动添加 MX、SPF、DKIM 这几条 DNS 记录)
wrangler email routing enable haiyangliu.com
# 3. 添加规则:发给 hello@ 的邮件转到 Gmail
wrangler email routing rules create haiyangliu.com \
--match-type literal --match-field to --match-value hello@haiyangliu.com \
--action-type forward --action-value 你的@gmail.com
最后一步需要人来做:去 Gmail 里点开验证邮件,确认这个邮箱是你的。不然任何人都能把别人的邮箱设成转发目标,用来发垃圾邮件。
自动添加的 DNS 记录是做什么的
| 记录 | 作用 |
|---|---|
| MX | Mail Exchanger。告诉全世界的邮件服务器:「发给 @haiyangliu.com 的信,请交给 Cloudflare 的这几台服务器」 |
| SPF | 一条 TXT 记录,声明「哪些服务器可以代表这个域名发信」。没有它,转发出去的信容易被当成伪造的 |
| DKIM | 一条 TXT 记录,里面是一个公钥。Cloudflare 转发时用私钥签名,收信方用这个公钥验证「信在路上没被改过」 |
第一篇讲 DNS 时只用到了 A 记录(域名指向哪个 IP)。这里的三种记录说明:DNS 不只管网站,域名的所有服务都靠它来声明。
十一、Serverless 能替代 VPS 吗
这一节回答检查时被问到的另一个问题:「感觉都不需要 VPS 了,Cloudflare 是不是已经替代了 VPS?」
对这个网站来说:是的
| 网站需要的 | 用 VPS 怎么做 | 现在怎么做(免费) |
|---|---|---|
| 放网页 | 装 Nginx | Workers 静态资源 |
| 后端接口 | 跑一个 Node.js 或 Python 程序 | Workers + Hono |
| 数据库 | 装 MySQL 或 PostgreSQL | D1 |
| 存文件 | 存在服务器硬盘上 | R2 |
| HTTPS 证书 | 申请,并且每 90 天续期 | 自动 |
| 发布新版本 | 登录服务器,拉代码,重启 | push 后自动部署 |
| 防攻击 | 自己配防火墙 | Cloudflare 自带 |
| 邮箱 | 自己搭邮件服务器(很难) | Email Routing 转发 |
而且有几个 VPS 给不了的好处:
- 不用运维:没有操作系统要打补丁,没有硬盘会坏,服务器不会半夜宕机叫你起来修。
- 就近访问:代码运行在全球几百个机房里,离访客最近的那个来处理请求。VPS 只在一个地方。
- 按量计费:没人访问时不花钱。VPS 不管有没有人用,每个月都要付钱。
这种「不用管服务器,只写函数,平台按请求运行」的模式叫 Serverless(无服务器)。服务器当然还是有的,只是不归你管。
但它不能完全替代 VPS
Serverless 的工作方式是:有请求来,才运行一小段代码,处理完就结束。这决定了它做不了这些事:
| 需求 | 为什么 Workers 做不了 |
|---|---|
| 一直在线的程序 | 比如聊天机器人、游戏服务器、爬虫。Workers 没有请求时根本不在运行 |
| 长时间计算 | 免费版每个请求只有大约 10 毫秒的 CPU 时间,处理一张大图都不够 |
| 装任意软件 | 只能跑 JavaScript/TypeScript(和 WebAssembly),不能装 Redis、Docker、Python 库 |
| 训练或运行大模型 | 需要 GPU。不过普通 VPS 也没有 GPU,这类事更适合学校的计算集群或 Colab |
| 读写本地文件 | 没有持久的硬盘,所有数据必须存进 D1、KV、R2 这类服务 |
另外两个要注意的点:
- 平台绑定:代码里用到了
env.DB这类 Cloudflare 特有的接口,换平台要改代码。第二篇第十二节列过迁移清单,我们尽量只用标准接口,迁移成本不高。 - 国内访问:Cloudflare 在中国大陆没有自己的节点(要和国内厂商合作的企业版才有),国内访问速度不稳定。数据库 D1 目前也在北美。如果主要访客在国内,国内云厂商加备案是更稳的选择。
什么时候真的需要 VPS
一个简单的判断标准:你的程序是「等别人来找它」,还是「自己一直在干活」?
- 等别人来找(网站、接口、表单) → Serverless 通常更好。
- 自己一直在干活(机器人、定时爬虫、长连接、后台任务队列) → 需要 VPS 或容器。
对学 AI 的人来说,还有一个常见情况:想在网站上加一个 AI 功能。如果只是调用大模型 API(比如做一个问答框),Workers 完全够用,它只负责转发请求;Cloudflare 还有 Workers AI,可以直接运行一些开源小模型,每天有免费额度。如果要部署自己训练的模型,就需要带 GPU 的机器了。
十二、检查结果汇总
| # | 问题 | 怎么发现的 | 怎么修 | 怎么验证 |
|---|---|---|---|---|
| 1 | 昵称冒充站长、绕过敏感词 | 读代码 | 敏感词 + 保留词 + 后端给出的站长标记 | 2 个新测试;本地浏览器看标记 |
| 2 | 页脚邮箱不存在 | 读代码 | Email Routing 转发到 Gmail | 发一封测试邮件 |
| 3 | 没有安全响应头 | curl -I | public/_headers + secureHeaders() | 1 个新测试;线上 curl -I |
| 4 | katex 低危漏洞 | pnpm audit | pnpm overrides | pnpm audit 无漏洞;文章公式正常 |
| 5 | 错误提示是英文 | curl 发错误格式 | zod 全局 customError | 1 个新测试 |
| 6 | 后台筛选闪烁 | 手动点 | 派生状态 switching | MutationObserver 逐帧记录 |
| 7 | 登录后还显示表单 | 手动点 | 已登录时显示提示 | 本地截图 |
| 8 | 缺少 sitemap、canonical | 访问 /sitemap.xml | sitemap.ts + pageMetadata() | 检查构建出的 HTML |
| 9 | 过期会话不清理 | 读代码 | 登录时全表清理 + expires_at 索引 | 1 个新测试 |
测试从 62 个增加到 67 个。
十三、面试怎么讲
一分钟介绍
网站上线后,我做了一次系统的检查,分四层:先跑格式、类型、测试这些自动检查;再用 pnpm audit 查依赖漏洞,发现一个插件间接依赖了旧版 katex,用 overrides 统一了版本;然后自己扮演攻击者,用 curl 对线上发伪造来源、越权、格式错误的请求;最后逐个文件读代码。最有价值的发现是一个逻辑漏洞:任何人都能注册叫「站长」的账号来冒充,工具查不出来,因为每个功能单独看都是对的。我的修法是昵称加保留词,并且让后端根据数据库里的角色返回一个前端伪造不了的标记。另外还补了安全响应头、sitemap 和 canonical,测试从 62 个加到 67 个。
可能被问到的问题
Q:常见的安全响应头有哪些?各防什么?
A:X-Frame-Options 和 CSP 的 frame-ancestors 防点击劫持;X-Content-Type-Options: nosniff 防浏览器猜测文件类型;Referrer-Policy 防止外链时泄露完整网址;Strict-Transport-Security 强制 HTTPS,防降级攻击;Permissions-Policy 关掉用不到的浏览器能力。CSP 是最强的防 XSS 手段,但我的网站是静态导出的 Next.js,有内联脚本,又没法每次请求生成 nonce,所以只用了不影响脚本的几项,XSS 主要靠 React 转义和 HttpOnly Cookie 防。
Q:什么是间接依赖?怎么处理间接依赖里的漏洞? A:我直接装的包 A 依赖了包 B,B 就是我的间接依赖。B 有漏洞时,最好的办法是升级 A 到修复后的版本;如果 A 还没更新,可以用包管理器的 overrides(npm 和 pnpm 都有,yarn 叫 resolutions)强制指定 B 的版本。风险是 A 可能不兼容新版 B,所以改完要重新构建、测试所有用到它的功能。
Q:怎么防止用户冒充管理员?
A:核心原则是身份不能靠用户自己填的信息判断,要靠系统记录的信息。昵称是用户填的,角色是数据库里记的,而且只能直接改数据库。所以接口返回留言时带上一个由角色算出来的 isAdmin,前端据此显示标记。保留词只是辅助,减少明显的混淆。
Q:canonical 是什么?什么情况下需要? A:告诉搜索引擎一个页面的正式地址。同一内容有多个网址时需要,比如带不带 www、带不带末尾斜杠、带不带追踪参数。没有它,搜索引擎会把它们当成重复内容,分散排名。要注意 metadata 的继承:如果在根布局里写了某一页的 canonical,所有没有覆盖它的子页面都会继承,我就遇到过 404 页声明自己是首页的问题。
Q:Serverless 和传统服务器各有什么优缺点? A:Serverless 的优点是不用运维、按请求计费、自动扩容、就近运行;缺点是有执行时间和运行环境的限制,不适合长时间运行的程序,冷启动可能有延迟,和平台绑定较深。传统服务器正好相反,什么都能装、什么都能跑,但要自己负责安全、备份、扩容。我的判断标准是:程序是被动响应请求,还是主动持续工作。
Q:为什么文件不存在数据库里? A:数据库是为快速查询小块结构化数据设计的,大文件放进去会拖慢查询、让备份变大,也没法直接用 CDN 分发。通常做法是文件放对象存储(S3、R2),数据库只存文件的地址和元数据。
Q:给一个已经有数据的表加约束,要注意什么? A:先查已有数据满不满足新约束。我给昵称加保留词之前,先查了线上数据库,确认没有冲突的昵称。如果有,要先决定怎么处理老数据,比如通知用户改名,或者只对新数据生效。数据库层面的约束(比如唯一索引)更要小心,有冲突数据时 migration 会直接失败。
十四、还没做的事
- 找回密码:需要发邮件的服务。Cloudflare 的 Email Sending 或者 Resend 都可以。
- 数据库搬到离国内更近的地方:D1 现在在北美西部。
- 照片上传:用 R2,第九节画了大致架构。(更新:R2 已经用上了,先做了站长文件空间,见第四篇。)
- 完整的 CSP:等哪天网站不再纯静态导出,就可以用 nonce 上完整的内容安全策略。
这次检查最大的收获是:能被工具发现的问题,往往不是最危险的问题。 依赖漏洞、缺少响应头,都有现成的工具和清单;而昵称冒充这种问题,代码一行错都没有,只有带着「如果有人故意这样做」的问题去读代码才能看出来。第二篇的结尾说「安全是每个接口在设计时就要回答的问题」,这一篇补上后半句:上线以后,还要定期回头再问一遍。