ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Egg.js Web安全实战:从CSRF到SSRF的加固指南

Egg.js Web安全实战:从CSRF到SSRF的加固指南

看到这个标题点进来的人,我猜你已经在这个15天系列里跟到第10天。前面9天,我们从环境搭建一路做到路由、控制器、模板渲染、服务层、中间件,再到日志、错误处理和单元测试,一个能跑的业务接口已经能怼到测试环境了。但真正上过线的人都知道,能让接口跑起来只是及格线,能让接口扛住各种恶意请求才算真正入门。第10天我想认真聊一个大多数新手教程都爱跳过的话题——Web安全,而且是和Egg.js强相关的那些细节。安全这东西,不遇到事的时候谁都懒得看,可一旦线上被人刷了一个恶意的提交,或者某个输出位被塞进脚本,你就知道补课成本远高于提前布防。

这一节适合两类人。一类是正在跟着这个系列学Egg.js的进阶者,基础概念你已经有了,正好借安全把框架的运行机制再串一遍;另一类是团队里负责Node后端、马上要上线的开发者,你可以直接把这篇文章当一份安全检查清单用。今天不搞纯理论,我会把攻击请求亲手复现一遍,再带着你把一个模拟项目加固到能上线的水准。顺手解决一个实际问题:当你真的在日志里看到"URL is not safe""csrf token error"这类报错时,接下来该做什么。

1. 第10天为什么必须把安全单拎一天

1.1 从一次线上"打脸"事故说起

先说我亲眼见过的一个翻车现场。某团队做了一个带用户登录的留言板应用,用户发表评论后直接展示在页面上。开发同学图省事,模板里用了一个不转义输出的语法,上线第一周就被人在昵称栏里塞了一小段脚本。结果那段脚本在每个人的浏览器里执行了一遍,虽然不是毁灭性攻击,但也足够让运营部门半夜打电话把开发从床上叫起来。

另一个例子更常见:后台管理接口没有做CSRF校验,表单和接口都裸奔。攻击者构造了一个自动提交的隐藏表单页面,只要管理员在登录状态下点开这个页面,表单就会自动向后台接口发送请求,完成审核、改配置等危险操作。整个过程管理员毫无感知。这类攻击不需要多高深的技术,几行HTML就能完成,可造成的后果往往非常严重。

我在前9天里反复强调接口能跑通,但"能跑通"和"能上线"之间隔着一条巨大的河,河名就叫安全。框架默认给你的保护不一定覆盖所有业务场景,更不代表你可以什么都不配就上线。第10天把安全单独拎出来,就是希望在常态开发节奏里给你踩一脚刹车:先学会威胁长什么样,再动手写防御。

1.2 前9天积累的底子,今天全部用得上

如果你是从第一天一直跟过来的,现在手里应该有一套比较完整的技能栈了。简单回顾一下这条学习线:

  • 第1-2天:环境搭建、快速初始化一个Egg项目、目录结构
  • 第3-4天:路由定义、控制器编写、参数校验
  • 第5天:中间件的机制与自定义中间件
  • 第6-7天:模板渲染、静态资源管理
  • 第8天:服务层拆分、数据访问与数据库操作
  • 第9天:日志体系、错误处理、单元测试

有了这些基础,理解今天的内容会非常顺。因为安全不是你额外学的一门新语言,而是穿插在上面所有层次里的一套防御策略。你要在路由层配白名单,在控制器层校验输入,在模板层确保输出被转义,在服务层防止数据被越权访问,在框架配置里打开CSRF和SSRF防护。今天相当于把前9天学过的每一个环节都检查一遍,看有没有漏洞。

1.3 今天结束之后你必须掌握的三件事

第一,能看懂Egg.js默认安全配置。不要稀里糊涂地用exports.security,你得清楚每一行是干什么的,哪些开关默认开着,哪些需要自己按场景调整。

第二,能亲手复现并防御至少三类高危攻击。我给这个系列定的主线是"知其所以然",所以今天会有攻击演示环节。你自己在本地也跑一遍,比看十遍文档管用。

第三,能给项目做一次安全自检。教程最后有一套自查脚本,跑一遍就知道哪些口子还敞着。这种东西放进团队流程里,能救很多人。

2. 摸清Egg.js安全机制的底牌

2.1 框架自带的安全能力全家福

Egg.js在安全这块不是靠开发者自己东拼西凑,而是内置了一个能力很强的安全插件。你在config/plugin.js里看不到它,因为它随框架一起加载了。这里先把全家福列出来,心里有个谱:

安全能力默认状态作用
CSRF Token校验开启防止跨站请求伪造,重点保护POST/PUT/DELETE
XSS输出转义模板默认防止存储型和反射型XSS脚本注入
安全重定向开启校验ctx.redirect跳转地址,防开放重定向钓鱼
SSRF出站地址检查开启拦截程序请求内网IP、回环地址,防服务端请求伪造
X-Frame-Options开启禁止页面被第三方iframe嵌入,防点击劫持
X-Content-Type-Options开启强制浏览器按声明类型解析,防MIME嗅探
隐藏X-Powered-By开启减少响应头信息泄露
HSTS关闭HTTPS环境下强制浏览器使用安全连接
CSP内容安全策略关闭按需配置,限制页面可加载的资源来源

这里要特别注意一个认知误区:不是开了框架就所有安全都自动到位了。CSRF默认开,但不代表你的表单自动带了token;SSRF默认开,但内网网段可能还需要你补充;CORS更是不在这个列表里,跨域要自己装egg-cors插件。框架给的是"默认配置安全",不是"免配置安全"。

2.2 默认配置文件长什么样

你可以打开项目里的config/config.default.js,搜索security相关的配置。如果没有显式配置,说明用的全是框架默认值。但强烈建议你显式写一遍,一方面让自己知道有哪些开关,另一方面给团队留一份可维护的声明。

// config/config.default.js exports.security = { // CSRF 防护 csrf: { enable: true, headerName: 'x-csrf-token', // Ajax 请求放 token 的请求头 cookieName: 'csrfToken', // token 下发到前端的 Cookie 名 sessionName: 'csrfToken', // 后端 session 中保存的 token 名 queryName: '_csrf', // URL 参数形式提交的 token 名 bodyName: '_csrf', // Form Body 形式提交的 token 名 }, // 安全响应头 xframe: { enable: true, value: 'SAMEORIGIN' }, noopen: { enable: true }, nosniff: { enable: true }, xPoweredBy: { enable: false }, // 安全重定向与 SSRF safeRedirect: { enable: true, domainWhiteList: [], // 外部跳转域名白名单 extendWhiteList: [], // 扩展白名单,可忽略 query 参数 }, ssrf: { enable: true, ipBlackList: [], // 补充自定义内网 IP 或网段 }, };

看到这段代码,很多人会直接跳到"我要不要改白名单",我建议先别急。你真正需要理解的是为什么会有这些配置。拿CSRF来说,Egg的思路是在session里存一个随机token,同时通过cookie下发到前端。前端提交请求时,把这个token原样带回来,后端比对session里的值和请求带回来的值是否一致。第三方恶意网站因为浏览器的同源策略,读不到你的cookie,自然也拿不到这个token,所以伪造的请求会被直接拒绝。这个机制的关键点在于:token必须放在第三方读不到的地方,同时能被你自己页面的JavaScript正常取到。

2.3 四个需要重点理解的防御阵地

把安全抽象成四个阵地,你的思路会清晰很多。

请求入口的CSRF。凡是绕过浏览器正常页面来提交的POST、PUT、DELETE请求,都可能被CSRF利用。Egg默认帮你拦一道,但前提是前端配合把token带上。

输出出口的XSS。用户输入的数据,如果没有经过转义就渲染到页面上,就可能变成可执行的脚本。Egg模板引擎默认会转义,但如果你用了富文本、或者手动拼接HTML,防线就断了。

跳转行为的开放重定向。登录、支付、分享这类场景经常需要携带redirect参数,跳转前如果不校验目标域名,攻击者就能构造一个看似可信的链接,最后把你带到了钓鱼站点。

出站请求的SSRF。你的服务器主动去请求某个URL时,如果这个URL可以被用户控制,攻击者就可能诱导你的服务器去请求内网地址,探测内网服务。这是很隐蔽但危害很大的漏洞,Egg的HTTP客户端会先解析目标地址,发现是内网IP就拒绝请求。

四个阵地,对应四个拦截点,你在配置安全策略时心里始终要有这张地图。

3. 攻击与防御实战:亲手打一遍再修一遍

3.1 第一回合:CSRF攻击与token防护

先造一个可以被攻击的接口。假设模拟项目X里有一个订单接口POST /api/order,登录用户可以提交订单。攻击者的目标,是让一个毫不知情的用户,在已经登录的情况下,自动帮他提交一笔订单。

攻击页面长得极其简单,一个隐藏表单:

<!-- 攻击者放在自己的站点上的页面 --> <html> <body> <form action="http://localhost:7001/api/order" method="POST" id="hack"> <input type="hidden" name="goodsId" value="666" /> <input type="hidden" name="quantity" value="10" /> </form> <script>document.getElementById('hack').submit();</script> </body> </html>

这个页面放在攻击者的网站域名下,只要受害者在这个浏览器里登录了你的系统,再打开这个页面,表单就会自动跨域提交到你的接口。cookie会因为同源策略的限制在跨域请求时被正常携带吗?会,浏览器对大部分场景下的跨域请求依然会带上目标域名的cookie,这也是CSRF攻击能成立的根本原因。

如果你没有开启CSRF防护,这个请求就会畅通无阻地进入你的控制器,订单成功创建。如果你开启了,Egg会检查请求体里有没有合法的_csrf字段,没有就直接拒绝,返回403。

你的正常业务页面要做的不是取消防护,而是学会正确传token。两种主流姿势:

模板渲染的页面,在表单里直接放一个隐藏字段:

<form action="/api/order" method="POST"> <input type="hidden" name="_csrf" value="{{ ctx.csrf }}" /> <input type="text" name="goodsId" /> <button type="submit">提交订单</button> </form>

前端是Ajax请求的,把token放到请求头里:

// 假设你已经从 cookie 中取到名为 csrfToken 的值 fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-csrf-token': token }, body: JSON.stringify({ goodsId: 666, quantity: 10 }) });

你看,核心矛盾一下就清晰了:攻击者读不到你的token,所以带不上;你的页面能读到,所以正常业务不受影响。这个"读不到"完全依赖浏览器的同源策略,所以你自己千万不要把token打到某个全局的JSON接口里,让任何跨域请求都能拿到,那就等于把钥匙贴在了锁上。

3.2 第二回合:存储型XSS与输出转义

第二类高危攻击是XSS。模拟项目X有一个评论功能,用户发表评论后,别人的浏览器会看到这条评论。如果评论内容里包含了HTML标签,而服务端没有做任何处理,就会出现一个经典的存储型XSS。

试想用户在评论框里输入了这么一段:

<img src="x" onerror="alert(document.cookie)" />

如果你的模板引擎默认不转义,或者你在页面里用了某些"渲染HTML"的辅助方法,这段代码就会在别人的浏览器里执行。攻击者拿到的可不止一个弹窗,还可能是你的cookie、本地存储里的登录态、甚至借助登录态调接口做更多操作。

Egg的模板引擎在输出变量时默认做转义,比如Nunjucks模板里你写:

<div>{{ comment.content }}</div>

<和>会被转成&lt;和&gt;,脚本自然就无法执行了。但如果开发时为了省事,在模板里暗示了某个字段是安全的、需要原样渲染输出,那段脚本就会穿透所有防线。

这里必须给一个明确的操作纪律:用户可控的内容,一律默认转义输出。如果业务上确实需要支持富文本,不要自己写黑名单,而是用白名单过滤的方式,比如引入一个清HTML标签和事件属性的库。你只需要保留标签和基础属性,onerror这类事件处理器必须无条件剥掉。我见过太多自以为安全的正则过滤,最后都被各种编码变体绕过去,白名单加白名单默认拒绝才比较稳。

3.3 第三回合:开放重定向与内网探测

开放重定向是特别容易被忽略的一个点。很多系统在登录成功后会跳回用户来的页面,于是URL上会带一个redirect参数。攻击者完全可以构造这样一个链接:

http://localhost:7001/login?redirect=http://evil.example.com/phishing

用户看到的是你系统的登录地址,觉得可信,输入账号密码之后,被悄悄带到了钓鱼网站。如果你的登录逻辑里写了ctx.redirect(ctx.query.redirect),那这个跳转就成了攻击者的跳板。

Egg默认的安全策略里,ctx.redirect会先做安全校验。当你在代码里直接使用这个写法时,如果跳转地址不在白名单里,Egg会直接抛错。这个错误日志在线上非常常见,我敢说不少同学都见过"URL is not safe"这个报错,却不知道是为什么。原因就是框架拦截了一个非白名单的跳转目标。

正确做法分成两层。第一层,业务跳转只允许相对路径或者同域地址;第二层,确实需要跳转到可信的外部站点,就把域名加进白名单:

exports.security = { safeRedirect: { enable: true, domainWhiteList: [ 'example.com', 'shop.example.com' ], }, };

还有一个隐蔽的攻击面是SSRF。Egg里一旦你写了服务端HTTP请求到用户可控的URL,就进入了SSRF的射程。攻击者把URL参数指向http://127.0.0.1:7001/private-api,如果你的服务器去请求这个地址,就等于是攻击者的代理,帮他去探测你的内网服务。Egg的HTTP客户端默认会做IP黑名单校验,回环地址、内网网段都会被拦下来。你可以在ssrf.ipBlackList里补充自定义的网段,比如10.0.0.0/8、172.16.0.0/12这些,配合默认名单一起用。遇到合法的内网调用需求时,再在白名单配置里放行,不要为了图省事全局关掉SSRF检查。

3.4 第四回合:跨域配置与CORS白名单

跨域问题本身不算攻击,但如果配置不合理,就成了安全漏洞。常见错误是图省事,把CORS的origin设成*,于是任何网站都能带着你的cookie跨域发请求。这个场景下CSRF防线会被直接绕出一道口子。

正确的跨域配置是明确白名单。模拟项目X有一个前端站点https://shop.example.com,需要跨域调用API,配置就写成:

// config/plugin.js exports.cors = { enable: true, package: 'egg-cors', }; // config/config.default.js exports.cors = { origin: [ 'https://shop.example.com' ], credentials: true, allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH', };

注意一件事:credentials如果为true,origin不能是一个宽泛的*,必须指定具体域名。因为带上cookie的跨域请求,如果允许任意来源,就相当于把用户在系统中的登录态暴露给所有站点。开发环境下你可能觉得跨域限制很烦,上线之前一定要把*收回来,改成明确的可信域名列表。

4. 第10天实操:把模拟项目X加固到能上线

4.1 项目现状与加固清单

为了把今天学的东西落到实处,我在本地准备了一个模拟项目X:一个带用户登录的商品下单后台,包含发表评论、提交订单、登录跳转三个典型业务。加固前先看一眼现状,问题基本就是前面几轮攻击演示里出现过的那些,这里整理成一张加固清单:

检查项现状风险级别处置动作
CSRF token表单未携带token高表单加隐藏字段,Ajax加请求头
评论输出转义模板默认转义,但富文本未处理高关闭富文本原样输出,统一转义
登录重定向redirect参数未校验白名单中开启safeRedirect并配置白名单
跨域配置开发期用*高改为域名白名单
响应头信息泄露未隐藏版本号低开启xPoweredBy隐藏
HTTPS环境未开启HSTS低确认证书后开启

4.2 加固前先复现一次攻击

这个环节非常推荐,它能让你对漏洞有真实体感。先启动项目,然后打开攻击者页面,登录状态下模拟提交订单,你会看到订单真的创建成功了。再直接访问评论接口,手工插入一段测试脚本,然后刷新前台页面,脚本直接执行。把这两个过程记录下来,这是你的加固基线。

这里插一句,本地复现攻击时千万不要在你自己的真实生产环境上打,也不要对别人不属你的系统做任何探测,本地模拟足够看清原理。攻击演示只用来自测。

4.3 逐个击破的加固步骤

加固第一步,CSRF校验。保持config.default.js里security.csrf.enable: true,然后改造模板和前端。模板表单里加上隐藏字段_csrf,Ajax请求统一在封装好的请求方法里从cookie读取csrfToken,放到x-csrf-token请求头中。改完再跑一次攻击表单,应该无法提交成功。

加固第二步,评论输出。把评论页面模板里的输出做一次全面检查,确认所有用户可控字段走的都是转义输出路径。如果项目里存在需要原样渲染富文本的地方,先确认是否有白名单过滤,如果没有,宁可先关闭富文本功能再上线。

加固第三步,登录重定向白名单。在配置里定义外部跳转域名白名单:

exports.security = { safeRedirect: { enable: true, domainWhiteList: [ 'shop.example.com' ], }, };

这样业务里的外部跳转只认这一个域名,其他人再造redirect=http://evil.example.com的链接,会被框架直接拦截。

加固第四步,跨域白名单。把config.default.js里的cors.origin从*收窄到前端真实域名。在开发环境下可以单独维护一份开发配置,但上线包里的config.prod.js必须是明确的域名列表。

加固第五步,响应头和HSTS。确认隐藏了x-powered-by响应头,并在HTTPS环境下配置HSTS。如果你的站还没有完全切HTTPS,先不要开HSTS,否则浏览器会强制拦截HTTP请求,造成意外故障。

4.4 回归验证与自动化安全检查

加固完不能拍脑袋说"应该没问题了",要跑一轮回归。这一步建议写成一个安全检查脚本,每次发布前跑一遍。

#!/usr/bin/env bash # scripts/security-check.sh BASE_URL="https://localhost:7001" echo "[1] 检查响应头是否隐藏版本信息" curl -skI $BASE_URL/ -o /dev/null -w "%header{x-powered-by}\n" if curl -skI $BASE_URL/ | grep -qi "x-powered-by"; then echo "FAIL: 响应头暴露框架版本" else echo "PASS: 响应头已隐藏" fi echo "[2] 检查跨域配置是否覆盖敏感接口" curl -sk -X OPTIONS $BASE_URL/api/order \ -H "Origin: https://evil.example.com" \ -H "Access-Control-Request-Method: POST" \ -i | grep -i "access-control-allow-origin" || echo "PASS: 不受信来源未放行" echo "[3] 检查CSRF校验是否生效" curl -sk -X POST $BASE_URL/api/order \ -H "Content-Type: application/json" \ -d '{"goodsId":1}' -w "\nHTTP_CODE:%{http_code}\n"

如果CSRF校验生效,第三步的请求应该返回403而不是200。如果返回200,说明还有接口漏在防护网外,赶紧查路由的ignore配置。

5. 踩坑实录:安全加固路上的高频事故

5.1 问题排查速查表

这几年我帮团队排查安全相关的问题不少,真正高频踩坑的场景非常集中,直接整理成表格,你可以贴到团队Wiki里。

现象常见原因处理方式
POST请求一直403前端没带CSRF token,或token从body取但请求头用了错误属性名检查headerName/bodyName配置与前端实际提交位置是否一致
提交表单偶尔403,刷新一下就好了多标签页共用同一个token,某个标签页触发了刷新机制检查业务里是否重复初始化token;多标签页场景可考虑读取cookie里的最新token
Nginx代理后CSRF总是失效反向代理丢掉了cookie,或域名不一致确认proxy_set_header Host、proxy_pass、Cookie转发配置
登录跳转一直报"URL is not safe"跳转域名不在白名单按业务需求加入domainWhiteList,而不是关闭校验
跨域请求带上cookie后不通过origin配了*,但credentials为true改成具体域名列表
内网服务间调用被SSRF拦截默认黑名单包含回环地址和部分内网网段在ssrf.whiteList或专门的地址合法性判断中放行可信来源
富文本内容显示成一堆标签输出走到转义路径确认该字段是否真的需要富文本;需要则另走白名单filter

5.2 容易被忽略的五个细节

第一个细节,日志别打token和密码。排查问题的时候,习惯性地把整个请求body打进日志,一旦body里带着CSRF token或登录密码,日志系统又恰好被读取,这些敏感信息就全出去了。我自己在日志打印上养成了脱敏的习惯。

第二个细节,safeRedirect.enable不要轻易设成false。很多同学被"URL is not safe"搞烦了,直接关掉开关。但你关掉的不只是报错,还有整条开放重定向防线。宁可花五分钟配白名单,也不要把框架的安全防线整个拆卸。

第三个细节,内网调用也要走白名单。微服务之间互相调用时,有些接口会请求内网地址,默认的SSRF检查会让你难受。但这种难受是安全的代价,正确做法是明确放行可信的服务地址,而不是全局关闭检查。

第四个细节,静态资源和API接口要分开策略。静态文件的CSRF校验不是必需,但API接口必需。Egg提供match和ignore配置,可以只对特定路由生效。不要图方便,在全局把CSRF关了,那等于把前门锁全卸了。

第五个细节,安全配置要跟着环境走。开发环境可能为了调试方便关闭了一些限制,但线上配置一定是收紧状态。这个动作要自动做,不要靠某个人上线前记得改。

5.3 一个值得养成的习惯:安全自检脚本进发布流程

我今天写的那个安全检查脚本,是我在项目里真实在用的一个思路。它不复杂,但价值很高。每次发布前跑一遍,能抓住很多"我以为没问题"的问题。

脚本还可以进一步扩展,比如检查依赖版本的已知漏洞:

npx egg-born-scan --vuln 2>/dev/null || echo "请检查依赖安全性"

具体的扫描工具可以按团队技术栈去选,核心思路是:把人工安全审查变成自动化门禁。人总会忘,脚本不会。

6. 实操体会与剩余5天怎么走

写到这,第10天的主要内容就结束了。最后分享一点我自己在项目里的真实体会:安全加固这件事,最难的从来不是配置本身,而是你愿不愿意在上线前多花那半个小时去验证。很多时候,"这个接口只有内部人能用"这种想法害死人,你以为的内网,在攻击者眼里可能只是几条命令的事。

还有一点是我反复强调的:安全不是第10天学完就结束了,而是从今天起,你在写每一个接口之前都要下意识问一句,这个接口能不能被第三方恶意调用?这个输出位如果被塞脚本会怎样?这个跳转地址会不会成为钓鱼跳板?带着这些问题写代码,比你出了事再翻文档补救要有效十倍。

这个系列还剩5天,后面的内容会延续今天的基调,把更多能直接落地的经验拆开讲。今天布置的课后作业很简单:把你手头正在开发的项目对照4.1节那张加固清单逐项检查一遍,发现问题就在评论区留言你的处理方案。动手改过的知识,才是真正属于你的知识。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进