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
更新
解决 CSRF 问题的核心并不是加密算法,而是把浏览器会自动匹配携带发送的数据改为在业务逻辑中进行携带发送,从而让攻击者无法通过钓鱼网站拿到敏感数据