防护的第一步不是装工具,是认清对手。这一节把 Node.js 服务最常见的五类威胁——XSS、CSRF、注入、路径穿越、越权——画成一张图谱:每一类讲清楚攻击怎么发生、在 Koa 的哪个位置被拦、以及那条最容易踩的开发红线。图谱建好,后两节的工具与实现才有挂靠点。
攻击原理:用户提交的内容被原样渲染进页面,攻击者借此注入脚本。存储型 XSS 的经典路径:攻击者在评论里写入一段脚本标签,服务器存库,其他用户浏览评论时脚本执行——偷 Cookie、伪造操作,都在受害者浏览器里发生。
Koa 侧对策:XSS 的防治主战场在渲染侧(模板自动转义、输出编码),服务端的责任是两件事——出层的 CSP 响应头限制脚本的来源,以及绝不给不可信 HTML 开绿灯(确需富文本时用白名单净化库处理后再入库)。
// CSP:限制页面里脚本的合法来源,即使注入成功也执行不了 app.use(async (ctx, next) => { await next(); ctx.set('Content-Security-Policy', "default-src 'self'; script-src 'self'"); });
开发红线:字符串拼 HTML。所有模板库的自动转义机制都只在"用模板"的前提下生效,一旦 '<div>' + userInput + '</div>',防线全空。
攻击原理:用户登录了银行站点 A,浏览器保存了会话 Cookie;随后访问恶意页面 B,B 里藏着一行自动提交到 A 的表单——浏览器发请求时会自动带上 A 的 Cookie,A 无法区分这是用户点的还是 B 伪造的。
Koa 侧对策:CSRF token——服务端在表单页签发随机 token,提交时校验,攻击者拿不到 token 就伪造不出合法请求(7.2 节给实现)。纯 JSON API 服务(凭证放 Authorization 头而非 Cookie)天然免疫——因为浏览器不会自动附带自定义头,攻击面不存在;一旦你的接口用 Cookie 做会话,CSRF 防护就从可选变成必选。
开发红线:把会话凭证放进 URL 参数——链接一转发,登录态就送人了。
攻击原理:输入被拼进可执行上下文。SQL 注入是代表:登录接口把用户输入直接拼进查询串,攻击者在用户名字段填 ' OR '1'='1,拼接结果让条件恒真,免密登录。同类手法对 shell(命令注入)、路径拼接(见下一条)都成立。
Koa 侧对策:参数化查询加输入校验(7.3 节收口)。参数化是根本解——SQL 引擎把参数当数据而非代码:
// 红线写法:字符串拼接,输入即代码 const rows = await db.query( `SELECT * FROM users WHERE name = '${ctx.request.body.name}'` ); // 正确写法:参数化占位,输入永远是数据 const [rows] = await db.execute( 'SELECT * FROM users WHERE name = ?', [ctx.request.body.name] );
开发红线:任何"先拼字符串再执行"的数据库、shell、模板调用。ORM 的查询构造器默认参数化,但原生 query 接口一到手,红线就在眼前。
.. 逃出目录攻击原理:接口用客户端输入拼文件路径时,../ 让访问跳出预期目录。第 4.4 节的下载接口如果写成 ctx.body = fs.createReadStream('./uploads/' + ctx.params.name),攻击者传 ..%2f..%2fetc%2fpasswd 就能读任意文件。
Koa 侧对策:两道锁——文件名由服务端生成(4.4 节的随机命名),以及路径归一化后校验前缀:
const path = require('path'); function safeJoin(base, userInput) { const target = path.resolve(base, userInput); // 归一化,消化所有相对跳转 if (!target.startsWith(path.resolve(base) + path.sep)) { throw new BadRequest('非法路径'); } return target; }
开发红线:把客户端输入直接拼进任何文件系统、对象存储的 key 里。
攻击原理:越权分纵、横两向。垂直越权是普通用户调用了管理员接口(靠角色检查拦);水平越权更隐蔽——两个同级别用户,A 把 URL 里的订单 id 换成 B 的,接口照样返回,因为它只验了"你登录了",没验"这单是你的"。真实世界泄露数据的头号来源正是水平越权,因为它不触发任何报错。
Koa 侧对策:资源归属校验写在业务层入口——查到资源后第一件事就是比对归属:
router.get('/orders/:id', auth(), async (ctx) => { const order = await repo.find(ctx.params.id); if (!order) throw new NotFound(); if (order.userId !== ctx.state.user.id && !ctx.state.user.isAdmin) { throw new NotFound(); // 注意:回 404 而非 403,不向探测者确认资源存在 } ctx.body = order; });
开发红线:路由级挂了 auth() 就默认"安全了"。auth 解决"你是谁",归属校验解决"这是不是你的",两码事,缺一不可。

不用安全专家资质,两个问题就能完成接口级建模。**问题一:这个接口吃什么输入?**每一项输入都过一遍图谱——进路径的防穿越、进 SQL 的防注入、进页面的防 XSS。**问题二:这个接口暴露谁的资源?**有 id 参数的都问"凭什么访问",答不出归属校验在哪,水平越权就开着口。把这两问写进 code review 清单,五类威胁的绝大多数口子在合并前就能拦下。
💡 关键直觉:五类威胁共享同一个根因——信任放错了位置。把输入当数据还是代码、把登录态当授权还是身份、把路径当建议还是边界,每条红线都是一次信任的错置。安全清单背不完,"多问一句凭什么信任"这个习惯比任何工具都可靠。
图谱建好了,下一节把它落到具体层:安全头的批量装配、CSRF token 的实现,以及全册最完整的 JWT 鉴权中间件。