CSRF
问题是前端安全领域老生常谈的问题了,针对它的技术方案也有很多,今天我们跟随egg-security来了解一下成熟的Web
框架是如何处理这个问题的。
CSRF 问题简介
Cross-site request forgery
(跨站请求伪造):在b.com
发起a.com
的请求,会自动带上a.com
的cookie
,如果cookie
中有敏感的票据,会有攻击者伪造用户发送请求的安全问题
解决思路一:验证请求Referrer
在大部分情况下,验证请求Referrer
在合法的域名列表内,能阻止 90% 的CSRF
问题。
但也有一些特殊情况,比如:
HTTPS
降级到HTTP
,Referrer
会丢失(No Referrer When Downgrade
)(可搜索Referrer Policy
了解详细内容)- 业务要求,需要支持空
Referrer
访问
在这些场景下,验证Referrer
并不是 100% 好用。
此时我们需要引入 CSRF Token
进一步校验
解决思路二:CSRF Token
解决问题的思路其实就是请求携带一个攻击者无法获取到的令牌,服务端通过校验请求是否携带了合法的令牌,来判断是否是正常合法的请求
总结一下,核心逻辑主要有三块:token
生成、token
传输、token
校验
下面我们就来看一下 egg-security
如何实现这三个主要部分
文件入口分析
还是从入口JS index.js
进行排查,发现 CSRF
相关逻辑入口:
接下来进入 ./lib/middlewares/csrf.js
:
可以看到,中间件的逻辑非常简单,除了一些分支判断,主要执行的是 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] && !rotate) return;
debug('ensure csrf secret, exists: %s, rotate; %s', this[CSRF_SECRET], rotate);
const secret = tokens.secretSync();
this[NEW_CSRF_SECRET] = secret;
let { useSession, sessionName, cookieDomain, cookieName } = this.app.config.security.csrf;
if (useSession) {
this.session[sessionName] = secret;
} else {
const cookieOpts = {
domain: cookieDomain && cookieDomain(this),
signed: false,
httpOnly: false,
overwrite: true,
};
// cookieName support array. so we can change csrf cookie name smoothly
if (!Array.isArray(cookieName)) cookieName = [ cookieName ];
for (const name of cookieName) {
this.cookies.set(name, secret, cookieOpts);
}
}
},
复制代码
通过代码可以看到,ensureCsrfSecret
方法的核心功能是:调用tokens.secretSync()
方法生成secret
并进行缓存,当开启useSession
配置时,secret
会缓存在session
中,否则存在cookie
中
这是我们发现了一个新的tokens
对象,找到它的定义处
明确了,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 attacks
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';
}
},
复制代码
AJAX
请求从cookie
中获取csrf token
,在这种情况下token === secret
(实际业务可以更灵活,见下文总结处)- 同步表单请求的令牌总是在变化(通过刷新页面)以防止
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 cookie
if (useSession) {
this[_CSRF_SECRET] = this.session[sessionName] || '';
} else {
// cookieName support array. so we can change csrf cookie name smoothly
if (!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
,上面问题的答案显而易见
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 first
const 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
是否一致
结合业务实际我们需要注意两点:
- 在
csrf
的源码中,secret
也是一种随机生成的方式。结合到我们业务,我们可以选取跟登录态强相关的cookie
,也方便前后端分离的项目进行通信 - 在
egg-security
的README.md
在中,ctx.csrf
变量只是注入到了form
表单的模板中,实际业务可以更灵活一些,通过统一封装的请求库将每个异步请求也带上token
,而不是异步请求只是带上cookie
中的secret