【原创】CSRF实践

CSRF是指跨站请求伪造(Cross-site request forgery),利用网站对用户网页浏览器的信任,发送恶意请求。本文通过示例理解CSRF的原理,掌握CSRF的防范方法。

目录

  • CSRF攻击示例
  • CSRF分析
  • CSRF防范

CSRF攻击示例

假设有一个站点1,服务端代码如下:

const Koa = require('koa')
const Router = require('koa-router')
const render = require('koa-ejs')
const moment = require('moment')

const app = new Koa()
const router = new Router()
render(app, {
  root: __dirname,
  layout: false,
  viewExt: 'ejs',
  cache: false
})

const DEFAULT_TOKEN = 'abc123456'
let balance = 1000 // 余额

router.get('/', async (ctx) => {
  // 模拟已经登录过了,并且将token设置到cookies中
  ctx.cookies.set('token', DEFAULT_TOKEN)
  await ctx.render('site1')
})

router.post('/balance', (ctx) => {
  const token = ctx.cookies.get('token')
  // 验证token,如果没有token则拒绝访问
  if (token !== DEFAULT_TOKEN) {
    ctx.throw(403, 'permission deny')
  }
  balance += 100
  console.log(`${moment().format('YYYY-MM-DD HH:mm:ss')}: balance has changed to ${balance}. Origin: ${ctx.headers.origin}`)
  ctx.body = { balance }
})

app.use(router.routes()).use(router.allowedMethods())
app.listen(3000)

site1将token保存在cookie中,在调用/balance接口时先验证token再更新balance。

site1的页面模板如下:

<html>
  <head>
    <title>site1</title>
  </head>
  <body>
    <script>
      (async function () {
        const response = await fetch('/balance', {
          method: 'POST',
        });
        const account = await response.json();
        console.log(account);
      }())
      </script>
  </body>
</html>

在页面加载后,会请求/balance接口更新balance,由于页面是通过/接口生成的,localhost:3000的域下存在cookie,因此可以验证通过。

运行node site1.js,再访问localhost:3000,可以看到服务器日志如下:

2019-05-03 09:16:04: balance has changed to 1100. Origin: http://localhost:3000

另一个站点2,部署在localhost:3001,页面如下:

<html>
  <head>
    <title>site2</title>
  </head>
  <body>
    <script>
      (async function () {
        const response = await fetch('http://localhost:3000/balance', {
          method: 'POST',
          credentials: 'include',
        });
        const account = await response.json();
        console.log(account);
      }())
      </script>
  </body>
</html>

在页面加载完后直接访问了http://localhost:3000/balance,此时打开控制台可以看到如下的报错:

跨域请求异常

也就是说在localhost:3001的域下访问http://localhost:3000/balance为跨域访问,浏览器没有获取到Access-Control-Allow-Origin的头部,因此中断了该请求。然而,查看服务器日志如下:

2019-05-03 09:16:04: balance has changed to 1100. Origin: http://localhost:3000
2019-05-03 09:28:06: balance has changed to 1200. Origin: http://localhost:3001

请求正常执行,并且经过了token校验。也就是说站点2没有经过获取token的操作,在跨域的情况下利用站点1中的cookie信息成功调用了站点1的接口!!!尽管站点2因为跨域的原因获取不到响应结果,但还是修改了服务器的数据。

CSRF分析

回顾之前的操作,整理出CSRF如下的流程:

  1. 用户访问站点1,网站(通过登录或其他验证方式)生成授权信息,并保存在cookie中;
  2. 用户被诱导访问了站点2,站点2恶意请求了站点1的后端接口;
  3. 由于cookie存放在站点1的域下,因此访问站点1的后端浏览器会自动携带上站点1域下的cookie;
  4. 站点1的后端没有校验请求的来源,验证cookie中的授权信息通过;
  5. 服务器执行了来自站点2的恶意请求。

在以上的流程中,要实现CSRF攻击,需要具备如下几个条件:

  • 授权信息保存在cookie中
  • 用户在站点1授权后被诱导,访问了站点2
  • 后端服务只验证了cookie中的授权信息,没有校验请求来源

CSRF防范

针对以上的分析结果,作为服务器端能做的就是校验请求来源,如果不是受信任的域的请求,则拒绝该请求。如下介绍几种常用的方式来校验请求来源。

校验Referer请求头部

Referer是HTTP请求header的一部分,当浏览器(或者模拟浏览器行为)向web 服务器发送请求的时候,头信息里有包含Referer,如下所示。

Referer请求头部

通过校验Referer头部判断请求是否来源受信任的域,该方法比较容易实现,但是由于Referer头部是由浏览器实现的,并不完全可靠。比如以下情况Referer是不存在的:

  • 来源页面采用的协议为表示本地文件的 "file" 或者 "data" URI
  • 当前请求页面采用的是非安全协议,而来源页面采用的是安全协议(HTTPS)

并且Referer可能被伪造,参考https://zhuanlan.zhihu.com/p/33359713,因此尽量不要完全依赖该方法来做CSRF安全校验,除非项目难以使用下面介绍的方法。

使用额外的csrfToken校验请求来源

由于目前很多项目是前后端分离的,前端页面不是由后端动态生成的,因此可以在授权时生成一个随机的csrfToken,并设置到cookie中,在后续的请求中,要求前端从cookie中读取到csrfToken并设置到header中,之后后端验证cookie中的csrfToken和header中的x-csrf-token是否一致来判断请求来源是否合法。

不在同一个域下的其他站点由于不能直接读取到目标站点cookie中的csrfToken,因此无法发送带有有效头部的请求。

以下简单实现了生成csrfToken和校验的过程:

后端在生成验证信息的同时生成csrfToken

function generateCsrfToken () {
  const chars = [
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
  ]
  return Array.from({ length: 15 })
    .map(() => chars[Math.ceil(Math.random() * (chars.length - 1))])
    .join('')
}
router.get('/', async (ctx) => {
  // 模拟已经登录过了,并且将token设置到cookies中
  const csrfToken = generateCsrfToken()
  ctx.cookies.set('token', DEFAULT_TOKEN)
  // 设置httpOnly为false,使前端通过js能读取到
  ctx.cookies.set('csrfToken', csrfToken, { httpOnly: false })
  await ctx.render('site1')
})

添加中间件来验证csrfToken

app.use(async (ctx, next) => {
  if (ctx.path === '/') {
    await next()
  } else {
    const csrfToken1 = ctx.cookies.get('csrfToken')
    const csrfToken2 = ctx.headers['x-csrf-token']
    if (!csrfToken1 || !csrfToken2) {
      ctx.throw(403, 'please provide csrfToken')
    }
    if (csrfToken1 !== csrfToken2) {
      ctx.throw(403, 'invalid csrfToken')
    }
    await next()
  }
})

前端从cookie中读取csrfToken并设置到header中

(async function () {
  const getCookie = (name) => {
    const arr = document.cookie.split(';')
    let value = null
    arr.find(one => {
      const [n, v] = one.trim().split('=')
      if (n === name) {
        value = v
        return true
      }
      return false
    })
    return value
  }
  const response = await fetch('/balance', {
    method: 'POST',
    headers: {
      'x-csrf-token': getCookie('csrfToken')
    }
  })
  const account = await response.json()
  console.log(account)
}())

运行node site1.js,再访问localhost:3000,从控制台中看到请求如下:

site1请求

后端校验通过,正确执行业务代码

2019-05-03 16:39:19: balance has changed to 1100. Origin: http://localhost:3000

此时再打开localhost:3001,会获得403的响应,查看后端日志,后端的业务代码也未执行。

site2请求

除了将csrfToken放在header中,也可以放在请求参数或者body中传递。

form表单自动生成token

如果前端页面是由后端动态生成,则可以在生成form表单的同时,携带上一个csrf的隐藏表单域,如:

<form id="test" method="POST" action="http://localhost:3000/balance">
  <input type="hidden" name="_csrf" value="<%=_csrf%>" />
</form>

在form表单提交的时候,会自动将_csrf提交到后端校验,对实际的业务开发可以做到无感知。

总结

CSRF是WEB安全中比较常见的问题,主要是利用用户的cookie信息伪造用户请求向网站发起恶意请求。本文通过示例介绍了CSRF的原理,希望在实际的项目中能够注意到此类的安全问题以及通过合理的方式防患于未然。

本文参考资源如下

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容