CSRF
问题是前端安全领域老生常谈的问题了,针对它的技术方案也有很多,今天我们跟随egg-security来了解一下成熟的Web
框架是如何处理这个问题的。
CSRF 问题简介
Cross-site request forgery
(跨站请求伪造):在b.com
发起a.com
的请求,会自动带上a.com
的cookie
,如果cookie
中有敏感的票据,会有攻击者伪造用户发送请求的安全问题
解决思路一:验证请求Referrer
在大部分情况下,验证请求Referrer
在合法的域名列表内,能阻止 90% 的CSRF
问题。
但也有一些特殊情况,比如:
1.HTTPS
降级到HTTP
,Referrer
会丢失(No Referrer When Downgrade
)(可搜索Referrer Policy
了解详细内容)
2.业务要求,需要支持空Referrer
访问
在这些场景下,验证Referrer
并不是 100% 好用。
此时我们需要引入 CSRF Token
进一步校验
解决思路二:CSRF Token
解决问题的思路其实就是请求携带一个攻击者无法获取到的令牌,服务端通过校验请求是否携带了合法的令牌,来判断是否是正常合法的请求
总结一下,核心逻辑主要有三块:token
生成、token
传输、token
校验
下面我们就来看一下 egg-security
如何实现这三个主要部分
文件入口分析
还是从入口JS index.js
进行排查,发现 CSRF
相关逻辑入口:
![](https://img-blog.csdnimg.cn/img_convert/0600143baa31a597885035d1de18ec7f.png)
接下来进入 ./lib/middlewares/csrf.js
:
![](https://img-blog.csdnimg.cn/img_convert/9a61890f4eacf256fb7b332f42207de3.png)
可以看到,中间件的逻辑非常简单,除了一些分支判断,主要执行的是 ctx.ensureCsrfSecret
和 ctx.assertCsrf
两个方法
看到了 ctx.
,我们就知道核心处理逻辑一定在 app/extend/context.js
,既对egg.js
提供的上下文对象进行扩展
ensureCsrfSecret
我们找到上面两个核心方法的实现(核心方法的解读会采用粘贴源码而不是截图的方式,方便大家进行阅读):
/** * ensure csrf secret exists in session or cookie. * @param {Boolean} rotate reset secret even if the secret exists * @public */ensureCsrfSecret(rotate) {if (this[CSRF_SECRET] && <img src="httpOnly: false,overwrite: true,};// cookieName support array. so we can change csrf cookie name smoothlyif (!Array.isArray(cookieName)) cookieName = [ cookieName ];for (const name of cookieName) {this.cookies.set(name, secret, cookieOpts);}}}," style="margin: auto" />
通过代码可以看到,ensureCsrfSecret
方法的核心功能是:调用tokens.secretSync()
方法生成secret
并进行缓存,当开启useSession
配置时,secret
会缓存在session
中,否则存在cookie
中
这是我们发现了一个新的tokens
对象,找到它的定义处
![](https://img-blog.csdnimg.cn/img_convert/b88e4fec4aec3c70c1ae5d8bc70141b8.png)
明确了,egg-security
核心计算逻辑依赖csrf库实现
/**
* Create a new secret key synchronously.
* @public
*/
Tokens.prototype.secretSync = function secretSync () {return uid.sync(this.secretLength)
}
secretSync
方法比较简单,也是一个固定长度的随机
assertCsrf
/** * assert csrf token/referer is present * @public */assertCsrf() {if (utils.checkIfIgnore(this.app.config.security.csrf, this)) {debug('%s, ignore by csrf options', this.path);return;}const { type } = this.app.config.security.csrf;let message;const messages = [];switch (type) {case 'ctoken':message = this[CSRF_CTOKEN_CHECK]();if (message) this.throw(403, message);break;case 'referer':message = this[CSRF_REFERER_CHECK]();if (message) this.throw(403, message);break;case 'all':message = this[CSRF_CTOKEN_CHECK]();if (message) this.throw(403, message);message = this[CSRF_REFERER_CHECK]();if (message) this.throw(403, message);break;case 'any':message = this[CSRF_CTOKEN_CHECK]();if (!message) return;messages.push(message);message = this[CSRF_REFERER_CHECK]();if (!message) return;messages.push(message);this.throw(403, `both ctoken and referer check error: ${messages.join(', ')}`);break;default:this.throw(`invalid type ${type}`);}},
assertCsrf
顾名思义,会进行一些断言处理。
我们直接看ctoken
分支,调用了this[CSRF_CTOKEN_CHECK]()
方法
[CSRF_CTOKEN_CHECK]() {if (!this[CSRF_SECRET]) {debug('missing csrf token');this[LOG_CSRF_NOTICE]('missing csrf token');return 'missing csrf token';}const token = this[INPUT_TOKEN];// AJAX requests get csrf token from cookie, in this situation token will equal to secret// synchronize form requests' token always changing to protect against BREACH attacksif (token !== this[CSRF_SECRET] && !tokens.verify(this[CSRF_SECRET], token)) {debug('verify secret and token error');this[LOG_CSRF_NOTICE]('invalid csrf token');return 'invalid csrf token';}},
1.AJAX
请求从 cookie
中获取 csrf token
,在这种情况下token === secret
(实际业务可以更灵活,见下文总结处)
2.同步表单请求的令牌总是在变化(通过刷新页面)以防止 BREACH
攻击
同时我们可以看到,在[CSRF_CTOKEN_CHECK]
方法中触发了多个变量的getter
,我们来详细看一下
客户端传输 token
- [INPUT_TOKEN]
get [INPUT_TOKEN]() {const { headerName, bodyName, queryName } = this.app.config.security.csrf;const token = findToken(this.query, queryName) || findToken(this.request.body, bodyName) ||(headerName && this.get(headerName));debug('get token %s, secret', token, this[CSRF_SECRET]);return token;},
可以看到[INPUT_TOKEN]
的逻辑非常简单:从请求Query
/请求Body
/请求Header
中取到想要的token
或secret
服务端获取 secret
缓存 - [CSRF_SECRET]
get [CSRF_SECRET]() {if (this[_CSRF_SECRET]) return this[_CSRF_SECRET];let { useSession, cookieName, sessionName } = this.app.config.security.csrf;// get secret from session or cookieif (useSession) {this[_CSRF_SECRET] = this.session[sessionName] || '';} else {// cookieName support array. so we can change csrf cookie name smoothlyif (!Array.isArray(cookieName)) cookieName = [ cookieName ];for (const name of cookieName) {this[_CSRF_SECRET] = this.cookies.get(name, { signed: false }) || '';if (this[_CSRF_SECRET]) break;}}return this[_CSRF_SECRET];},
服务端取缓存的方式与ensureCsrfSecret
方法是对应的:即当开启useSession
时,从session
中取;否则从cookie
中取指定的值
校验比对
if (token !== this[CSRF_SECRET] && !tokens.verify(this[CSRF_SECRET], token)) {debug('verify secret and token error');this[LOG_CSRF_NOTICE]('invalid csrf token');return 'invalid csrf token';
}
这步会涉及到tokens
对象中的多个方法,我们再来看下
Tokens.prototype.verify = function verify (secret, token) {if (!secret || typeof secret !== 'string') {return false}if (!token || typeof token !== 'string') {return false}var index = token.indexOf('-')if (index === -1) {return false}var salt = token.substr(0, index)var expected = this._tokenize(secret, salt)return compare(token, expected)
}
Tokens.prototype._tokenize = function tokenize (secret, salt) {return salt + '-' + hash(salt + '-' + secret)
}
可以看到,verify
方法就是根据传入的secret
,重新计算生成token
,并与传入的token
进行比对
而生成token
的格式为:${salt}-${hash(salt-secret)}
到此我们已经清楚的了解 CSRF Token
的传入、缓存、校验逻辑,还剩下两个问题,token
什么时候生成?如何注入页面?
生成 token
通过egg-security
的READMEmd
,上面问题的答案显而易见
![](https://img-blog.csdnimg.cn/img_convert/b8bf7263c630e9c096c10906aaa216fe.png)
token
生成在ctx.csrf
变量上- 通过模板进行注入,附加到
Form
表单的提交上
看到 ctx.csrf
,我们就知道还是去context.js
找它的getter
,如下:
/** * get csrf token, general use in template * @return {String} csrf token * @public */get csrf() {// csrfSecret can be rotate, use NEW_CSRF_SECRET firstconst secret = this[NEW_CSRF_SECRET] || this[CSRF_SECRET];debug('get csrf token, NEW_CSRF_SECRET: %s, _CSRF_SECRET: %s', this[NEW_CSRF_SECRET], this[CSRF_SECRET]);//In order to protect against BREACH attacks,//the token is not simply the secret;//a random salt is prepended to the secret and used to scramble it.//http://breachattack.com/return secret ? tokens.create(secret) : '';},
通过源码可得:获取缓存的secret
,调用tokens.create(secret)
生成token
,并返回
Tokens.prototype.create = function create(secret) {if (!secret || typeof secret !== "string") {
throw new TypeError("argument secret is required");}return this._tokenize(secret, rndm(this.saltLength));
};
create
方法与verify
方法在调用_tokenize
的不同在于,create
调用_tokenize
传入的salt
是随机生成的;verify
调用_tokenize
传入的salt
是通过token
反解出来的。
根据上面环节的分析,我们终于了解了token
从生成 --> 传输 --> 获取 --> 校验
的完整流程
结合业务实际的思考
我们来结合业务实际对egg-security
整个CSRF
防御流程进行总结
token
生成方式:动态salt
+加密算法(secret + salt)
。其中,salt
为每次生成token
随机生成,secret
与登录状态绑定(每次登录重新生成),缓存到session
中或写入cookie
中*token
传递方式:*请求Query
中 / 请求Body
中 / 请求Header
中都可携带*token
验证方式:服务端从session
或cookie
中取到secret
,在token
中反解出salt
值,使用相同的加密算法进行计算,对比计算结果与传递的token
是否一致结合业务实际我们需要注意两点:
1.在csrf
的源码中,secret
也是一种随机生成的方式。结合到我们业务,我们可以选取跟登录态强相关的cookie
,也方便前后端分离的项目进行通信
2.在egg-security
的README.md
在中,ctx.csrf
变量只是注入到了form
表单的模板中,实际业务可以更灵活一些,通过统一封装的请求库将每个异步请求也带上token
,而不是异步请求只是带上cookie
中的secret
更新
解决 CSRF
问题的核心并不是加密算法,而是把浏览器会自动匹配携带发送的数据改为在业务逻辑中进行携带发送,从而让攻击者无法通过钓鱼网站拿到敏感数据