【原创】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的页面模板如下:


  
    site1
  
  
    
  

在页面加载后,会请求/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,页面如下:


  
    site2
  
  
    
  

在页面加载完后直接访问了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表单提交的时候,会自动将_csrf提交到后端校验,对实际的业务开发可以做到无感知。

总结

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

本文参考资源如下

  • 深入浅出Node.js#8.2.4
  • https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Referer
  • https://zhuanlan.zhihu.com/p/33359713
  • https://eggjs.org/zh-cn/core/security.html

你可能感兴趣的:(【原创】CSRF实践)