Skip to content

我的 API 一直在裸奔,而我几个月都没发现

Harry
Lang
原文 中文

一、那个让我突然清醒的问题

那天我和朋友在聊 LingoContext 的后端架构,他问了一句很朴素的问题:

“你这个 AI 接口,是不是只有插件能调?别人不能随便用吧?”

我下意识地说”当然啊,CORS 限制了 origin,只允许 chrome-extension:// 的请求”——但话说到一半我就开始心虚。因为我突然意识到,我没真正验证过这件事。

我打开终端,对着自己的生产 API 跑了一条:

curl -X POST https://lingo-context-api.vercel.app/api/analyze/stream \
  -H 'Content-Type: application/json' \
  -H 'Origin: chrome-extension://gjcgecdgmhbehagblealbdghojkoeakk' \
  -d '{"text":"hello"}'

返回了一段流式 JSON,里面是 Gemini 给我的回答。我刚刚用任何人都能写出来的一行 curl,调用了我自己花钱的 AI 服务。 那一刻我意识到,我对”扩展独占 API”的理解从一开始就是错的。这篇文章是我对这个误区的反思,以及我怎么把这件事修对。


二、一个常见但致命的误区:「只让我的扩展调用 API」

一上来想到的方案是这样的:

这套思路在浏览器的安全模型下有意义——但用来防止 API 滥用,它有一个致命缺陷

扩展 ID 是公开的

打开 Chrome Web Store,扩展的安装链接里就有 ID:

https://chromewebstore.google.com/detail/lingocontext-%E2%80%94-context-aw/gjcgecdgmhbehagblealbdghojkoeakk
																		   ────────────────────────────────
                                                                                      这就是扩展 ID

扩展代码是公开的

Chrome 扩展打包成 .crx,本质上是个 zip。任何人下载之后解压,content.js、background.js、manifest.json 都能直接读。所以”在客户端藏个秘密 key 用来验证”——这条路从一开始就不存在。

Origin header 可以被任意伪造

浏览器在发请求时会自动设置 Origin,但服务器收到的只是一段字符串。curl -H 'Origin: chrome-extension://任意ID' 一样能通过 CORS 白名单检查。CORS 是用来保护用户的浏览器不被恶意网站滥用——它不是用来防 curl 的。

真正的安全模型

把这三件事拼起来,结论就清晰了:

“只让我的扩展调 API” 在技术上不可达。能被信任的从来不是客户端,而是登录用户。

扩展只是 UI 层,是用户操作 API 的入口。被信任的主体是通过 Google OAuth 完成登录、持有 session cookie 的真实用户

修复方向因此也明确了:所有花钱的 API 都必须要求认证。CORS 留着,但作为深度防御的一层,不是唯一一层。


三、现状审计:哪些接口在裸奔

抱着重新审视的心态,我把所有路由扫了一遍:

路由是否要求登录备注
/api/words/*ensureAuthenticated词汇本 CRUD
/api/user/*ensureAuthenticated用户偏好
/api/analyze裸奔调用 Gemini / DeepSeek
/api/analyze/stream裸奔流式调用 Gemini / DeepSeek
/api/tts裸奔Edge TTS,消耗带宽
/api/word-definition裸奔调用 AI 做快速翻译
/api/furigana❌ 开放本地 kuromoji,便宜
/api/dictionary❌ 开放调用免费第三方词典

数据库 + 词汇本是好的,但所有真正烧钱的 AI 接口都是开放的。开发初期我把认证加在了”用户数据相关”的路由上,但忘了”AI 调用 = 用户数据相关”这件事。从攻击者视角,AI 接口反而是最有动机滥用的——别人不一定关心你的词汇本,但绝对愿意白嫖你的 Gemini 配额。


四、修复方案:三层防御

我没有采用单一的”修一把就完事”的思路,而是把这件事拆成三层:

第一层:认证(最关键)

给四条烧钱路由都加上 ensureAuthenticated 中间件。一旦请求没有有效的 session cookie,直接 401,根本不进入业务逻辑:

// server/index.js
app.use(
  "/api/analyze",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  analyzeRoutes
);
app.use(
  "/api/analyze/stream",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  analyzeStreamRoutes
);
app.use(
  "/api/word-definition",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  wordDefinitionRoutes
);
app.use("/api/tts", ensureAuthenticated, ttsPerMinute, ttsRoutes);

这一层挡住的是没有 OAuth 登录身份的所有请求。curl 想绕过它,必须先完成完整的 Google OAuth 流程——而 OAuth 流程要求人在浏览器上和 Google 交互,没法自动化。

同时我把 401 响应体改成结构化形式,方便客户端识别和展示:

res.status(401).json({
  error: "Unauthorized. Please login.",
  message: "Please sign in via the LingoContext popup to use this feature.",
  code: "AUTH_REQUIRED",
});

旧版只有 { error: '...' },前端只能展示一句模糊的话。新版加了 code 字段,前端可以根据 AUTH_REQUIRED 直接渲染一个”登录”按钮,而不是”重试”按钮——错误响应本身也是 UX。

第二层:每用户限速

认证只能挡住”没有身份的人”。但如果某个用户的 session 泄露了,或者某个用户自己写脚本试图滥用,光靠认证不够。

引入 express-rate-limit,键设计是关键:

function keyByUserOrIp(req) {
  if (req.user && req.user.id != null) {
    return `u:${req.user.id}`; // 优先按用户 ID
  }
  return `ip:${req.ip || "unknown"}`; // 未登录场景按 IP
}

为什么不直接用 IP?因为 IP 不稳定——用户切换 WiFi、走 VPN、共享办公室 IP,都会让按 IP 限速错杀正常人。用户 ID 才是”配额单位”的本体

具体的限制:

限制器窗口默认值控制变量
AI 接口/分钟60s30RATE_LIMIT_AI_PER_MIN
AI 接口/天24h1500RATE_LIMIT_AI_PER_DAY
TTS/分钟60s60RATE_LIMIT_TTS_PER_MIN
公开接口/分钟60s60/IPRATE_LIMIT_PUBLIC_PER_MIN

所有阈值都走环境变量,所以如果哪天我突然被 DDoS,或者 Gemini 配额吃紧,改一个环境变量就能立刻收紧,不用重新部署代码。

429 响应也是结构化的:

res.status(429).json({
  error: "rate_limited",
  message:
    "You're sending requests too quickly. Please wait a moment and try again.",
  code: "RATE_LIMITED",
  limiter: name,
  retry_after_seconds: retryAfter,
});

Retry-After header,客户端可以决定要不要自动重试。

第三层:CORS + CSRF 不变

很多文章会建议”既然 CORS 防不住 curl,就别配了”。我不同意。CORS 防的是另一个场景:

用户在浏览器里同时打开了我的扩展和一个恶意网站。恶意网站的 JS 想偷偷调我的 API(带上用户的 session cookie)。

这种场景下,Origin 是浏览器自动设置的,恶意网站没法伪造。CORS + CSRF origin 检查能在这层挡住攻击。

所以三层防御是分工的:

少一层都不行。


五、最容易翻车的部分:不破坏现有功能

光把锁加上去不难,难的是不破坏现有用户的体验。这部分我花的时间比加锁本身还多。

第一步:确认客户端真的在带 session

如果扩展的 fetch 没有 credentials: 'include',那即使用户已经登录,请求里也不会带 cookie,加锁后所有人都会被 401 挡掉。

我把 background.js 里所有的 fetch 调用都扫了一遍:

const response = await fetch(`${backendUrl}/analyze/stream`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ... }),
    credentials: 'include'   // ← 确认四个调用都有
});

四个烧钱接口(analyze/stream、furigana、word-definition、tts)都已经带了 credentials: 'include'。这意味着登录用户在加锁前后是完全无感知的——cookie 自动跟过来,session 自动验证通过。

第二步:审计每个接口的失败降级路径

扩展原本就为很多接口写了优雅的降级路径,只是我之前没注意。

接口失败时扩展的反应加锁后的影响
/api/tts自动降级到 Web Speech API (speakWithWebSpeech)零影响——未登录用户听到的是浏览器自带 TTS
/api/word-definition静默返回空数组,跳过 quick definition 区域零影响——只是少了一个加分项
/api/furigana静默返回 null,跳过快速振假名零影响——AI 分析回来后仍会渲染振假名
/api/analyze显示错误 UI需要改——不能让用户看到”Backend Error: 401”

也就是说,TTS / word-definition / furigana 三条路即使被锁了,扩展的整体体验都不会断Web Speech 降级让我可以心安理得地锁掉 TTS——如果一个功能没有降级路径,加 auth 之前必须先想清楚 401 时的用户体验。

第三步:把 analyze 的 401 转成”登录”提示

只有 /api/analyze/stream 的失败会被用户直接看到(错误弹窗)。这里需要专门处理。

background.js 把 401 的 code 透传给 content script:

if (!response.ok) {
  const error = await response.json().catch(() => ({}));
  port.postMessage({
    error: true,
    status: response.status,
    code: error.code, // ← 新增
    message:
      error.message || error.error || `Backend Error: ${response.status}`,
  });
}

content.js 的 stream handler 识别 code === 'AUTH_REQUIRED',渲染一个带”登录”按钮的错误界面,而不是常规的”重试”:

const isAuth = msg.code === "AUTH_REQUIRED" || msg.status === 401;
popup.innerHTML = renderError(msg.message, { authRequired: isAuth });

renderError 接收一个 authRequired 选项,决定显示 🔒 + “Sign in” 还是 ⚠️ + “Try Again”,按钮的 data-action 也跟着切换:

const actionAttr = authRequired ? 'data-action="login"' : 'data-action="retry"';
const actionLabel = authRequired ? loginBtnStr : tryAgainStr;
const icon = authRequired ? "🔒" : "⚠️";

而 “Sign in” 按钮的点击行为复用扩展早就存在的 OPEN_LOGIN 消息流——没有新增任何 IPC、没有改 popup.html、没有改 dashboard。整个改动只在两个文件、不到 20 行内闭合。

取舍:哪些路由该锁,哪些不该

不是所有路由都该锁。我对每条做了独立判断:

路由锁不锁理由
/api/analyze*调用 Gemini/DeepSeek,每次调用都是真金白银
/api/word-definition同上,虽然 max tokens 是 100,但量大也烧
/api/ttsEdge TTS 走带宽;且客户端已有 Web Speech 降级
/api/furigana不锁,只限速本地 kuromoji,零外部成本;扩展未登录时也会触发;按 IP 限速够用
/api/dictionary不锁,只限速调用免费的 jisho/dictionaryapi;同上

功能本身的成本结构 + 客户端有没有降级路径 是决定要不要锁的两个维度。无脑全锁很安全但损失体验,无脑全开放又是裸奔。


六、验证:测试 + 端到端冒烟测试

光改完不算完,还得验证:

单元测试

旧的 authMiddleware.test.js 写死了 401 body 的精确形状:

expect(res.json).toHaveBeenCalledWith({ error: "Unauthorized. Please login." });

我把它改成结构化匹配,把”扩展能识别认证错误并切换 UI”这件事变成测试可表达的契约:

expect(res.json).toHaveBeenCalledWith(
  expect.objectContaining({
    error: "Unauthorized. Please login.",
    code: "AUTH_REQUIRED",
    message: expect.stringMatching(/sign in/i),
  })
);

index.test.js 里加了 4 个新 case,每条烧钱路由都断言匿名访问会返回 401 + code: 'AUTH_REQUIRED'

意外的发现:analyzeRoute.test.jsanalyzeStreamRoute.test.js 从一开始就在 mock 中间件里挂着 ensureAuthenticated——也就是说,写测试的那个我早就以为这些路由是受保护的。生产代码反而是异常值。测试驱动暴露了实现和意图之间的不一致

最终:210/210 server tests + 13/13 root tests 全部通过。

6.2 端到端烟雾测试

我写了个最小的 Express harness,挂载和生产完全一样的中间件,用 mock 的 passport 模拟”未登录”状态,然后 curl:

=== Cost-bearing routes (expect 401)
{"error":"Unauthorized. Please login.","message":"Please sign in via the LingoContext popup to use this feature.","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401

=== Open public routes (expect 200)
HTTP 200  POST /api/furigana
HTTP 200  GET  /api/dictionary?word=x

完美匹配预期。


七、这次重构留下的几个原则

这次问题表面上是一个 CORS 误解,但本质上是一次安全意识的补课。把它抽象出来,我觉得有几个原则以后可以反复复用。

1. 不要信任任何客户端

只要代码跑在用户设备上,它就不能被当成秘密。浏览器扩展、移动 App、桌面客户端,本质上都只是 API 的入口,而不是可信身份本身。

真正能被服务端信任的,不是“这个请求看起来像是从我的扩展发来的”,而是:

这个请求是否来自一个经过认证的用户。

所以如果你发现自己在设计“只允许某个客户端调用 API”,最好停下来重新想一遍:这件事能不能改成“只允许某类用户身份调用 API”。

2.VibeCoding 确实解放了生产力,但也会放大安全盲区

现在用 AI 写代码、补功能、生成接口都非常快,生产力确实被释放了很多。但问题也在这里:代码写得越快,越容易默认它是安全的。

尤其是安全问题,很多时候不是“功能不能用”,而是“功能太容易被滥用”。这种问题非专业人员很难第一时间发现,因为它不会像 bug 一样马上报错。

所以我现在觉得,AI 不只是用来写代码的,也应该用来做安全 review。比如在上线前,可以专门问几个不同的 AI:

这个接口有没有可能被绕过?
如果你是攻击者,你会怎么滥用它?
这里有没有认证、限速、CORS、CSRF 相关的问题?
哪些接口可能产生真实成本?

让多个 AI 从不同角度 review 一遍,不一定能保证绝对安全,但至少能帮我暴露很多自己没意识到的盲区。

3. 错误响应也是 UX

{ error: 'Unauthorized' }{ error, message, code: 'AUTH_REQUIRED' } 对前端来说完全不是一个级别的东西。

前者只能告诉用户“出错了”,后者可以让前端明确知道:这是认证问题,应该展示登录按钮,而不是重试按钮。

所以错误响应不是随便返回一个字符串就结束了。它其实也是产品体验的一部分。

4. 防御要分层,每层职责要清楚

CORS 防的是恶意网站,认证防的是匿名调用,限速防的是身份滥用。

这三层解决的不是同一个问题。CORS 防不住 curl,不代表 CORS 没用;认证挡住了匿名请求,也不代表可以不做限速。

安全不是靠某一个“万能方案”解决的,而是要让每一层都知道自己在防什么。


八、写在最后

这件事让我有点惭愧——一个我每天都在维护的项目,居然有这么明显的漏洞我视而不见了几个月。但更让我反思的是:我对”我的 API 只有我的扩展能调”这个信念,从来没有被验证过。我没跑过 curl,没扮演过攻击者,没真正问过自己”如果我自己想滥用这个 API,我会怎么做”。

写代码和保护代码是两种不同的思维方式。前者问”它能用吗”,后者问”它能被滥用吗”。我以前更熟悉前者。

如果你正在做一个有后端的浏览器扩展、移动 app、桌面客户端——请花十分钟,对着自己的 API 跑一遍 curl。看看哪些接口理应需要登录但实际上可以裸调。你会很惊讶。

Previous
把「决定读什么」外包出去:我给阅读系统做了个微内核
Next
面试里的低配得感:为什么我总是在过度准备