AI 生成代码的隐藏风险:能跑不代表安全
深入分析 AI agent 生成的代码为何表面运行正常但存在系统集成风险,强调测试的关键作用。
深入分析 AI agent 生成的代码为何表面运行正常但存在系统集成风险,强调测试的关键作用。
我写全栈应用差不多十年了。我是菲律宾人,住在挪威,而这里从夏天到冬天,唯一会发生变化的,就是我调试代码时房间里的光线。十年下来,我形成了一个坚定、略带暴躁的观点:大多数后端安全事故并不高明,而是平淡得令人无语。有人忘了设置请求体大小限制;有人因为教程里这么写,就在启用凭证的同时把 CORS 设成了 *;有人写下 fetch(req.body.url),却从未想过这个 URL 能指向哪里。
这过去是一个“初级开发者”的问题。如今,它成了所有人的问题,因为大多数后端代码已经根本不是由人类亲手输入的了。你描述一个应用,AI 智能体安装四十个依赖、编写路由、运行测试,然后创建一个 PR。代码在理想路径上可以正常工作。它返回 200。不到一小时就能部署。而这恰恰就是陷阱。
一个能够启动并返回 200 的后端,看起来像是已经完成了。其实并没有。它只不过还没开始抱怨而已。
这篇文章讨论的是一个具体理念:安全路径应该成为默认路径,而危险操作应该只有在你有意识地启用时才会生效。为了具体说明这一点,我会使用一个名为 DaloyJS 的 TypeScript 框架,因为我一直围绕这一理念构建这个框架。我也会坦诚说明它无法保护你的地方,因为一篇只罗列优点的安全文章就是营销,而隔着三个屏幕你都能闻到营销的味道。
让任何代码助手生成“一个使用 Express、包含几个路由并会调用另一个服务的 Node API”,你得到的代码大致都会是这样。我不是在针对 Express。Express 没问题。我针对的是那些所有人都不加阅读便直接复制的默认配置。
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json()); // no size limit
app.use(cors()); // reflects any origin
app.get("/books/:id", (req, res) => {
res.json({ id: req.params.id, title: `Book ${req.params.id}` });
});
app.post("/fetch-cover", async (req, res) => {
const r = await fetch(req.body.url); // SSRF speedrun
const buf = await r.arrayBuffer();
res.set("content-type", r.headers.get("content-type"));
res.send(Buffer.from(buf));
});
app.post("/books", (req, res) => {
// no auth, no validation, trusts req.body shape
db.insert(req.body);
res.json(req.body);
});
app.listen(3000);
这不是稻草人论证,而是中位水平。让我逐一说明这里的问题,因为问题比人们预想的更多,而且几乎没有一个会在通过的测试中暴露出来。
没有设置限制的 express.json() 会欣然将数 MB 的请求体缓冲到内存中。一次同时发送几百个这样的请求,你就得到了一个几乎不需要攻击成本的拒绝服务攻击。未传入任何选项的 cors() 会反射请求的来源;如果再结合凭证,这就相当于在跨域场景中把钥匙留在门上。/fetch-cover 路由是教科书式的服务端请求伪造:传入 http://169.254.169.254/latest/meta-data/iam/security-credentials/,在许多云环境配置中,你就能直接窃取该实例的凭证。/books 路由完全信任 req.body,因此攻击者可以通过 __proto__ 实施原型污染,而且系统中甚至没有一个 schema 用来定义一本书究竟是什么。没有速率限制。请求 DELETE /books/:id 时返回的是 404,而不是真正的 405;这会悄悄地误导扫描器,让它们以为这个路由不存在,尽管它实际上存在。而当某处抛出异常时,在许多配置下,Express 的默认错误处理器会兴高采烈地把堆栈跟踪发送给客户端。
这些问题都不会导致测试失败。测试提交一本书,再把这本书取回来,然后亮起绿灯。AI 智能体报告成功。所有人继续做下一件事。漏洞就这样戴着一个小小的绿色对勾被发布到了生产环境。
这是一个我不断回归的理念。Supabase 和 Aikido 团队的一篇文章中有一句很精彩的话,我经常想到:“如果你要求 AI 让某个东西正常工作,它可能会移除那些原本在保护你的安全检查。”这种风险并非 AI 独有。人类也会这么做——凌晨两点,测试仍然是红的,而部署窗口即将关闭。区别在于,AI 智能体做得更快,而且它脑后没有那个小小的声音提醒它:“等等,为什么这里原本会有这项检查?”
所以,解决办法不是“更加小心”。在一个代码由不会感到恐惧的东西编写的世界里,更加小心是无法规模化的。真正的解决办法,是让框架的默认配置就是安全的,从而让“使其正常工作”和“使其安全”成为同一个动作。你应该只有在刻意绕远路时,才能把系统变得不安全。危险的开关应当默认关闭,直到你有意将其打开;更理想的情况是,当你的配置显然是在给自己埋雷时,框架应该直接拒绝启动。
这就是全部主张。接下来让我展示它在实践中的样子。
下面是在 DaloyJS 中实现的大致相同的接口。先看构造函数,因为有意思的部分就在那里。
import { z } from "zod";
import {
App,
NotFoundError,
bearerAuth,
cors,
rateLimit,
requestId,
secureHeaders,
} from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
const BookSchema = z.object({ id: z.string(), title: z.string() });
const app = new App({
bodyLimitBytes: 64 * 1024, // hard cap, streamed, Content-Length checked first
requestTimeoutMs: 5_000, // slow-loris and hung-handler protection
openapi: { info: { title: "Bookstore API", version: "1.0.0" } },
docs: true, // mounts GET /docs, /openapi.json, /openapi.yaml
})
.use(requestId()) // cryptographic correlation id per request
.use(secureHeaders()) // CSP, nosniff, frame-ancestors, COOP/CORP
.use(cors({ origin: "https://app.example.com", credentials: true }))
.use(rateLimit({ windowMs: 60_000, max: 120 }))
.route({
method: "GET",
path: "/books/:id",
operationId: "getBookById",
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: "Found", body: BookSchema },
404: { description: "Not found" },
},
handler: async ({ params }) => {
const book = books.get(params.id);
if (!book) throw new NotFoundError(`No book ${params.id}`);
return { status: 200 as const, body: book };
},
})
.route({
method: "POST",
path: "/books",
operationId: "createBook",
auth: { scheme: "bearer" },
hooks: bearerAuth({ validate: (t) => t === process.env.TOKEN }),
request: { body: BookSchema }, // unknown keys rejected, body validated
responses: {
201: { description: "Created", body: BookSchema },
401: { description: "Unauthorized" },
422: { description: "Validation error" },
},
handler: async ({ body }) => {
books.set(body.id, body);
return { status: 201 as const, body };
},
});
serve(app, { port: 3000 });
这里发生了几件你并未主动要求的事情。请求体被限制在一个硬性上限内,并以流的形式读取;在缓冲任何一个字节之前,系统会先检查 Content-Length。系统还设置了请求超时,因此某个卡住的处理器会被中止,而不是永远占用一个打开的连接。JSON 解析器通过 reviver 移除 __proto__、constructor 和 prototype,因此通过请求体实施的原型污染在默认情况下就已被封堵,而不依赖于某个你恰好记得添加的库。针对未声明方法的请求会返回真正的 405,并附带 Allow 响应头。错误会以 RFC 9457 problem+json 格式返回;在生产模式下,5xx 响应中的 detail 字段会被自动移除,因此你不会把内部信息泄露给那些正在试探 API 的人。
request: { body: BookSchema } 这一行承担着双重职责。它会验证传入的请求体并拒绝未知字段,同时也是生成 OpenAPI 文档以及可选的完整类型化客户端时所使用的唯一事实来源。数据结构只需编写一次。你不需要在验证器中写一遍、在文档中再写一遍,然后又在前端类型中写一遍。稍后我会再讨论为什么这一点对于安全尤其重要,因为“契约与验证不可能彼此偏离”是一项安全属性,而不只是改善开发者体验的小便利。
不过,我真正想深入讨论的是截图中看不到的部分:当你错误配置这个东西时,会发生什么。
我最喜欢的一类安全功能,是那些能把无声的运行时漏洞转变成响亮启动崩溃的功能。一个被发布出去的漏洞代价高昂,而 CI 中执行 pnpm start 时发生的崩溃则不需要任何成本。因此,在几种特定情况下,如果配置几乎肯定是错误的,DaloyJS 会拒绝启动。
第一个问题是 CORS。在发送凭证的同时反射每个来源(origin),这在开发环境中运行完美,但在生产环境中会悄悄地暴露每个会改变状态的跨域路由。因此在生产模式下,通配符来源会被直接拒绝:
// 在生产环境中这会在构造时直接抛出错误,不会启动:
app.use(cors({ origin: "*", credentials: true }));
// Error: cors({ origin: "*" }) refused in production: a wildcard CORS origin
// exposes every state-changing route cross-origin.
你不可能意外地部署这个。要么提供一个真实的白名单,要么应用根本无法运行。这就是正确的权衡。我宁愿被告知某个部署失败无法启动,也不想在周一早上读到自己公司的安全事件新闻。
第二个是弱密钥。如果你使用会话或任何需要密钥的子系统,而你给了它一个占位符、太短的值或单个重复字符,它会拒绝启动并精确告诉你怎么修复:
import { session } from "@daloyjs/core";
// 这些在生产环境都会拒绝启动:
session({ secret: "changeme" }); // 众所周知的占位符
session({ secret: "short" }); // 低于最小字节长度
session({ secret: "aaaaaaaaaaaaaaaa" }); // 单个重复字符
// 错误信息直接建议修复方案:
// session(): production secret is too short (5 bytes; require >= 32).
// Generate one with `openssl rand -base64 48` and load it from an env var.
我数不清有多少次安全泄露都追溯到某个打算"稍后再改"的密钥。"稍后"是安全的坟墓。让框架拒绝明显虚假的密钥意味着懒惰的方案和安全的方案现在指向同一个方向。
第三个很微妙,我对此感到骄傲。如果你建立了一个有状态的端点,会改变服务器状态但没有认证,在生产环境中,应用会拒绝启动。经典的例子是一个无需认证的 /metrics 或健康探针暴露了内部信息,或者一个完全没有保护的会改变状态的路由。框架的立场是:生产环境中一个匿名、会改变状态、公开可访问的路由更可能是一个错误而非有意的选择,所以你必须明确声明:
// 除非你给它一个令牌,否则在生产环境拒绝:
app.metrics();
// app.metrics() refused in production: provide opts.token to require auth.
app.metrics({ token: process.env.METRICS_TOKEN }); // 明确,可以
这个系列中的最后一个涉及代理。如果你的应用运行在负载均衡器后面,并通过读取 X-Forwarded-For 来决定客户端 IP(用于速率限制、日志记录、地理位置封禁),那么未配置的信任设置就是一个欺骗漏洞:任何人都可以发送虚假的 X-Forwarded-For 并绕过你的基于 IP 的限制。所以在生产环境中,如果请求带有 X-Forwarded-* 头而你没有告诉应用如何信任代理,它会拒绝处理。你明确配置信任边界或者根本无法读取这些头。
这四个问题的主题是一致的。框架假设生产环境中不安全的配置是一个 bug,而不是偏好,它让你自己证明相反的情况。对 AI 生成的代码来说这是巨大的优势,因为 AI 智能体不会像经验丰富的开发者那样对通配符 CORS 感到不安。智能体不会有那种不好的感觉。启动护栏不需要有这种感觉。
让我回到那个 /fetch-cover 路由,因为出站 fetch 是我看到被忽视最多的安全漏洞,而且情况在恶化,而不是改善。
关于 AI 智能体时代的情况是这样的:后端现在不断地发起出站 HTTP 调用,而且越来越多地调用 URL 来自于不可信的地方。用户粘贴一个链接来总结。webhook 订阅指向客户选择的 URL。一个"AI 智能体工具"按需获取一个页面。这些都是攻击者可以给你一个指向内部的 URL 的地方——指向你自己的云元数据端点、内部管理服务、localhost、或一个信任网络的数据库。经典的 Capital One 泄露本质上就是一个 SSRF,它访问了元数据服务。那个模式并没有消失。我们只是给了它更多的入口点。
天真的 fetch(url) 不会阻止这个。所以 DaloyJS 提供了一个受保护的 fetch,它是一个插入式替代品,默认拒绝危险目标:
import { fetchGuard, SsrfBlockedError } from "@daloyjs/core";
const safeFetch = fetchGuard(); // 安全默认值,无需配置
app.route({
method: "POST",
path: "/fetch-cover",
operationId: "fetchCover",
request: { body: z.object({ url: z.string().url() }) },
responses: {
200: { description: "Cover bytes" },
400: { description: "Blocked or invalid URL" },
},
handler: async ({ body }) => {
try {
const r = await safeFetch(body.url);
return { status: 200 as const, body: await r.arrayBuffer() };
} catch (e) {
if (e instanceof SsrfBlockedError) {
// URL 指向了不应该指向的地方。拒绝,不要重试。
return { status: 400 as const, body: { error: "blocked target" } };
}
throw e;
}
},
});
默认情况下,fetchGuard() 拒绝环回地址(127.0.0.0/8、::1)、RFC1918 私有范围、链路本地地址(包括每个已记录的云元数据 IP——AWS、Azure 和 DigitalOcean 的 169.254.169.254,加上 AWS ECS 和 EKS 变体)、GCP 的 metadata.google.internal、阿里巴巴的 100.100.100.200、Oracle 的 192.0.0.192、运营级 NAT 范围、IANA 保留块、多播和任何非 http 或 https 的协议。当你真正需要时,可以通过 allowLoopback 或 allowPrivate 等标志选择性地重新允许特定内容,但你必须明确请求。
我最关心的细节是重定向处理,因为这是许多 SSRF 护栏失效的地方。一个只检查第一个 URL 的天真白名单很容易被绕过:攻击者给你一个公开 URL,它用 302 Location: http://169.254.169.254/... 响应,你的 fetch 就直接跟着进入了元数据服务。fetchGuard() 手动跟随重定向,并对照拒绝规则重新验证每一跳,它还递归地重新检查 IPv4 映射的 IPv6 地址,所以你不能通过不同的记号方式隐藏一个被阻止的地址。只有在你之前被坑过才能把这种东西做对,这正好是 AI 智能体在快速写一个 fetch 包装器时不会想到的东西。
有一个诚实的警告我不会隐瞒:应用层的护栏是纵深防御,不是替代锁定元数据服务本身。你仍然应该在 AWS 上要求 IMDSv2 并在 VPC 或防火墙层阻止链路本地。应用护栏中和了常见的情况和懒惰的攻击者。它不能让你跳过基础设施工作。任何说仅靠库能解决 SSRF 的人都在兜售东西。
认证是另一个地方,危险的东西很容易做,安全的东西需要了解一个特定的陷阱。JWT 验证是规范的例子。有一类著名的攻击,其中接受对称(HS256)和非对称(RS256)算法的 API 可能被欺骗:攻击者拿到服务器的公开 RSA 密钥(它由定义就是公开的),并将其用作 HMAC 密钥来用 HS256 签署令牌。一个从令牌自己的头部选择算法的验证器会高兴地验证它。这就是"困惑的代理"或"算法混淆"攻击,它已经困扰了很多真实系统。
DaloyJS 通过拒绝构造一个甚至允许对称算法的验证器来关闭它,当你针对一个 JWKS 进行验证时:
import { jwk, requireScopes } from "@daloyjs/core";
const verify = jwk({
jwksUri: "https://login.example.com/.well-known/jwks.json",
algorithms: ["RS256"], // 仅非对称白名单,必需
issuer: "https://login.example.com/",
audience: "books-api",
});
// 这立即抛出异常,在任何请求被服务之前:
jwk({ jwksUri: "...", algorithms: ["HS256"] });
// jwk(): algorithm "HS256" is not asymmetric. Symmetric (HS*) algorithms
// are refused by jwk() to close the JWKS confused-deputy attack.
app.route({
method: "POST",
path: "/items",
operationId: "createItem",
hooks: [verify, requireScopes(["items:write"])],
request: { body: z.object({ name: z.string() }) },
responses: { 201: { description: "Created" }, 403: { description: "Forbidden" } },
handler: async ({ body }) => ({ status: 201 as const, body }),
});
算法白名单是必需的且非空的,发行人和受众被强制执行,验证器对令牌中的攻击者控制的声明应用相同的原型污染安全复兴器。你使用 requireScopes() 按路由进行授权。重点不在于在其他地方不可能出错。重点是,在这里出错会导致启动错误,并提供解释攻击的消息,而不是像在渗透测试中发现的那样默默接受。
还有一点值得明确说明,因为这是人们容易理解错的地方:DaloyJS 是资源服务器,而不是身份提供者。它验证和强制令牌。它不运行登录页面、管理用户或铸造令牌,也不应该。如果你需要登录,你应该引入真正的 OpenID Connect 提供者(托管的或自托管的),并验证其令牌。编写自己的授权服务器是那些看起来富有生产力但会毁掉职业生涯的决定之一。不要这样做。
我承诺会回到这个话题。单一事实来源契约对安全很重要的原因不仅仅是开发者的幸福感,而是漂移。在典型的堆栈中,你至少在三个地方描述请求体:运行时验证器、OpenAPI 文档和前端类型。这三个会随时间而分离。验证器说一件事,文档说另一件事,它们之间的差距就是 bug 的所在地。文档声称验证电子邮件但验证器实际上不验证的端点是等待发生的注入攻击。
当模式是路由定义时,这个差距无法打开。相同的 BookSchema 验证请求体、生成 OpenAPI 操作并类型化客户端。如果你忘记验证,文档不会对此撒谎,因为没有什么可以撒谎的。还有一个契约测试运行器来检查人类忘记的事情:
import { runContractTests } from "@daloyjs/core/contract";
const report = await runContractTests(app);
if (!report.ok) process.exit(1);
// Flags: declared examples that do not match their schema, duplicate or
// missing operationIds, dead routes, and body schemas on safe methods.
在 GET 路由上声明的请求体模式是一个小事情,但它是那种表示某人复制粘贴了路由而没有思考的小事情。在 CI 中捕获它很便宜。更广泛的想法是框架将你的 API 描述和运行时行为视为同一个对象,因此它们不能不同意,而分歧就是许多隐藏的漏洞所在。
上面的一切都是关于进入你的应用的请求。但在 2026 年,更流行的被攻击方式是通过你在单个请求到达之前安装的代码。我们经历过自我复制的 npm 蠕虫、在 npm install 上运行的恶意 postinstall 脚本、CI 缓存污染,还有一个新的我认为在一种黑色幽默的方式上非常有趣的:slopsquatting。这是攻击者注册一个 AI 智能体在统计上可能产生幻觉的包名称,然后等待有人直接从聊天窗口将不存在的导入复制到实际项目中。AI 智能体发明了 @types/superfast-json,攻击者已经发布了它,现在你有一个从未审查过的依赖,因为你甚至从未选择它。
你不能从 web 框架内部修复所有这些,但你可以缩小爆炸半径,并可以为你搭建的项目提供理智的默认值。核心包有零运行时依赖,这是可用的最有效的供应链决定:没有传递树可以被污染,因为没有树。它发布时带有 npm 出处和 CycloneDX 加 SPDX SBOM,所以你可以在安装时验证你获得的字节是从你认为的源构建的。
搭建的项目继承了一个我希望是行业默认标准的 pnpm 立场:
# .npmrc that create-daloy ships
ignore-scripts=true # postinstall scripts do not run on install
minimum-release-age=1440 # a package version must be >= 24h old to install
minimum-release-age 这一行是对快速妥协的最佳防御之一。大多数恶意包版本在发布后几小时内被捕获并删除。如果你的安装程序只是拒绝拉取早于一天的版本,依赖于每个人在它落地时立即安装被污染的 1.4.7 的蠕虫就无法到达你。你用二十四小时的"我不能立即获得最新发布"来换取大大降低你是患者零的机会。对于应用代码来说,这是一个容易的交易。
我想准确说明什么能传递,什么不能,因为这是诚实很重要的地方。运行时保护和发布的 SBOM 和出处随你构建的每个应用而传递,在任何 CI 主机上,GitHub 或不是。最强的安装时捆绑、发布年龄冷却期和工作区门依赖于你使用 pnpm,因为这些是 pnpm 功能。框架自己的发布管道上的强化、SHA-pinned 操作和 OIDC 发布等都保护框架,而不是自动保护你的应用。我不会假装安装一个包会使你的供应链防弹。它不会。它去除了一类风险并给你指向正确方向的默认值。
如果你只读一个章节,读这个,因为一个过度宣传自己的安全工具比没有工具更糟。它会让你自满。
安全默认值不能将你从你自己的逻辑中救出来。如果你写一个授权检查,说 if (user.id = req.params.id),只有一个等号,地球上没有框架能为你捕获这个,你会刚好进入一个破碎的访问控制 bug。破碎的对象级授权,即无聊的"用户 A 可以通过更改 URL 中的 id 来读取用户 B 的发票" bug,始终是野生中最常见的严重 API 漏洞,它完全存在于只有你可以正确编写的代码中。框架为你提供了很好地进行身份验证的工具。它不能为你进行授权。
它也是新的。在我写这篇文章时,DaloyJS 即将推出其第一个 1.0 测试版 (1.0.0-beta.0),这是 API 停止成为移动目标并开始成为我必须遵守的契约的时刻。那是一个真实的里程碑。