CSRF

CSRF(Cross-site request forgery)跨站请求伪造,也被称为“One Click Attack”或者Session Riding,通常缩写为CSRF或者XSRF,是一种对网站的恶意利用。

摘自百科:攻击通过在授权用户访问的页面中包含链接或者脚本的方式工作。
例 如:一个网站用户Bob可能正在浏览聊天论坛,而同时另一个用户Alice也在此论坛中,并且后者刚刚发布了一个具有Bob银行链接的图片消息。设想一下,Alice编写了一个在Bob的银行站点上进行取款的form提交的链接,并将此链接作为图片src。如果Bob的银行在cookie中保存他的授权信息,并且此cookie没有过期,那么当Bob的浏览器尝试装载图片时将提交这个取款form和他的cookie,这样在没经Bob同意的情况下便授权了这次事务。 CSRF是一种依赖web浏览器的、被混淆过的代理人攻击(deputy attack)。在上面银行示例中的代理人是Bob的web浏览器,它被混淆后误将Bob的授权直接交给了Alice使用。

具体的细节和危害可参考百科

django & csrf

django CSRF保护机制原理

django通过token的校验来保证请求来自同一用户。django 在第一次收到某个客户端的请求时,会在服务器端随机生成一个 token ,并且把这个 token 保存在 cookie 里。后面每次 POST 请求都会带上这个 token,django在后端验证token是否一致,这样就能避免被 CSRF 攻击。 基本流程如下:

  • 返回Http Response相应时,django会在后端随机生成一个csrftoken,放在响应response的cookie里。
  • 用户发起表单 POST请求时,表单里必须包含一个 csrfmiddlewaretoken 字段 ,该字段不需要用户计算,可以直接用tag完成,具体见下面。
  • 在处理 POST 请求之前,django 会验证这个提交的表单里的 csrfmiddlewaretoken 字段的值和请求的 cookie 里的 csrftoken 字段的值和是否一致。如果一致,那么接收这个请求,否则,这个可能是一个来自csrf 攻击的非法请求,返回 403 Forbidden. 早期的版本里,csrftoken和csrfmiddlewaretoken是相等的,但是最新的版本里,这两个值中间经过了一定的算法转发,需要通过算法去匹配是否一致。
  • 如果是 ajax POST 请求,可以添加一个 X-CSRFTOKEN header,其值为 cookie 里的 csrftoken 的值

Django如何使用CSRF

  1. CSRF中间件在 MIDDLEWARE设置里是默认激活的,如果你要重写设置文件,记得把'django.middleware.csrf.CsrfViewMiddleware'放在你要处理CSRF攻击的middleware前面。如果你想去掉这个middleware(不推荐),记得在你想包含的函数上使用 csrf_protect()
  2. 在所有的POST表单里,如果是内部URL,在<form>里添加 csrf_token tag,它会帮忙生成csrfmiddlewaretoken字段,这个是隐藏字段。如果是外部URL,不要使用这种方法,因为它会导致token泄露,具体解决方法有专门章节讲解。
  3. 在相应的函数视图里,使用 RequestContext去渲染模板响应。RequestContext 会处理 csrf_token 这个 tag, 从而自动为表单添加一个名为 csrfmiddlewaretoken 的 input。如果你用 render()函数,generic view或者contrib app,它们已默认使用RequestContext,不需要特殊处理。

AJAX POST

上一节介绍了CSRF使用的基本方法。对于AJAX请求,处理上会比较复杂一些。详细可参考https://docs.djangoproject.com/en/2.0/ref/csrf/

post方式不同于get方式可以被django直接得到,因为django为post加入了csrf保护, 详细的文档地址https://docs.djangoproject.com/en/dev/ref/csrf/

添加middleware

注意: 在最新版本中,在setting.py里'django.middleware.csrf.CsrfViewMiddleware',默认是使用中的,如果没有请自行添加,并且确保此引用在其他所有viewware前面

MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',# this line is vsrf 
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_cas.middleware.CASMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)

在ajax方法触发前加入一段js

function getCookie(name) {
    var cookieValue = null;
    if (document.cookie && document.cookie != '') {
        var cookies = document.cookie.split(';');
        for (var i = 0; i < cookies.length; i++) {
            var cookie = jQuery.trim(cookies[i]);
            // Does this cookie string begin with the name we want?
            if (cookie.substring(0, name.length + 1) == (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

function csrfSafeMethod(method) {
    // these HTTP methods do not require CSRF protection
    return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}

实现ajax的beforeSend函数,头里面添加添加X-CSRFToken,值为csrftoken

$.ajax({
 url: '',
 data: {
  storage_location: storage_location,
 },
 dataType : 'json',
 type : 'post',
 beforeSend: function (xhr, settings) {
  var csrftoken = getCookie('csrftoken'); 
  if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
   xhr.setRequestHeader("X-CSRFToken", csrftoken); 
  }

  // CODE
 },
 success: function (data) {
  // CODE
  console.log(data);
 },
 complete: function(){
  // console.log("complete");
 },
 error: function (jqXHR, textStatus, errorThrown) {
  // CODE
  console.log("error"); 
 } 
});

视图中的处理举例,摘自网上

view.py:

def ticket_handler(request):  

if request.method == 'POST':
  if request.GET['action'] == 'get_vmlist':
    try:
      d = {}  
      env = request.POST['env'] 

      print env # 这里就可以看到env的值被正确传递给后台了

      d['Result'] = 'Fail'
      s = json.dumps(d)
      return HttpResponse(s)

避开CSRF检查

  1. 在 settings.py 中 MIDDLEWARE_CLASSES 中 注释掉'django.middleware.csrf.CsrfViewMiddleware'

  2. 在需要在views.py里要出发post请求的函数前加入@csrf_exempt ,之前要引入这个装饰器

from django.views.decorators.csrf import csrf_exempt

单独CSRF检查

然后,显式针对单个view函数进行csrf保护

from django.views.decorators.csrf import csrf_protect 
@csrf_protect
def my_view(request):
    c = {}
    # ...
    return render(request, "a_template.html", c)

代码走读

下面是两个基本调用函数,用户设置和获取token函数

Token存储在http消息的cookie里,token值的读写是围绕着个cookie进行的。

CSRF_COOKIE_NAME = 'csrftoken'
    def _get_token(self, request):
        if settings.CSRF_USE_SESSIONS:
            try:
                return request.session.get(CSRF_SESSION_KEY)
            except AttributeError:
                raise ImproperlyConfigured(
                    'CSRF_USE_SESSIONS is enabled, but request.session is not '
                    'set. SessionMiddleware must appear before CsrfViewMiddleware '
                    'in MIDDLEWARE%s.' % ('_CLASSES' if settings.MIDDLEWARE is None else '')
                )
        else:
            try:
                cookie_token = request.COOKIES[settings.CSRF_COOKIE_NAME]
            except KeyError:
                return None
            csrf_token = _sanitize_token(cookie_token)
            if csrf_token != cookie_token:
                # Cookie token needed to be replaced;
                # the cookie needs to be reset.
                request.csrf_cookie_needs_reset = True
            return csrf_token
    def _set_token(self, request, response):
        if settings.CSRF_USE_SESSIONS:
            request.session[CSRF_SESSION_KEY] = request.META['CSRF_COOKIE']
        else:
            response.set_cookie(
                settings.CSRF_COOKIE_NAME,
                request.META['CSRF_COOKIE'],
                max_age=settings.CSRF_COOKIE_AGE,
                domain=settings.CSRF_COOKIE_DOMAIN,
                path=settings.CSRF_COOKIE_PATH,
                secure=settings.CSRF_COOKIE_SECURE,
                httponly=settings.CSRF_COOKIE_HTTPONLY,
            )
            # Set the Vary header since content varies with the CSRF cookie.
            patch_vary_headers(response, ('Cookie',))

_get_token函数里,从request.COOKIES[settings.CSRF_COOKIE_NAME]里读取cookie_token,然后通过函数_sanitize_token处理一下

_set_token函数里,通过response.set_cookie设置cookie

生成csrftoken

在response函数里,设置token到cookie里,并设置 csrf_cookie_set = True,cookie的名字为CSRF_COOKIE

    def process_response(self, request, response):
        if not getattr(request, 'csrf_cookie_needs_reset', False):
            if getattr(response, 'csrf_cookie_set', False):
                return response

        if not request.META.get("CSRF_COOKIE_USED", False):
            return response

        # Set the CSRF cookie even if it's already set, so we renew
        # the expiry timer.
        self._set_token(request, response)
        response.csrf_cookie_set = True
        return response

生成csrfmiddlewaretoken

没找到代码

渲染csrfmiddlewaretoken

django.template.defaulttag.py

@register.tag
def csrf_token(parser, token):
    return CsrfTokenNode()
class CsrfTokenNode(Node):
    def render(self, context):
        csrf_token = context.get('csrf_token')
        if csrf_token:
            if csrf_token == 'NOTPROVIDED':
                return format_html("")
            else:
                return format_html("<input type='hidden' name='csrfmiddlewaretoken' value='{}' />", csrf_token)
        else:
            # It's very probable that the token is missing because of
            # misconfiguration, so we raise a warning
            if settings.DEBUG:
                warnings.warn(
                    "A {% csrf_token %} was used in a template, but the context "
                    "did not provide the value. This is usually caused by not "
                    "using RequestContext."
                )
            return ''

token这个值生成和传递进来的地方没有找到。

在django 1.9,这个值跟csrftoken相等;在djagno 1.11,这个值已经不一样了,应该是经过加密。

验证token

django.middleware.csrf.py

在请求处理函数里读取token,并做比较。

该函数的逻辑在前面讲django原理时已讲过,它会都去cookie里的csrftoken值和POST请求里的csrfmiddlewaretoken值,并对这两个值进行匹配比较。

    def process_view(self, request, callback, callback_args, callback_kwargs):
        if getattr(request, 'csrf_processing_done', False):
            return None

        csrf_token = self._get_token(request)
        if csrf_token is not None:
            # Use same token next time.
            request.META['CSRF_COOKIE'] = csrf_token

        # Wait until request.META["CSRF_COOKIE"] has been manipulated before
        # bailing out, so that get_token still works
        if getattr(callback, 'csrf_exempt', False):
            return None

        # Assume that anything not defined as 'safe' by RFC7231 needs protection
        if request.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'):
            if getattr(request, '_dont_enforce_csrf_checks', False):
                # Mechanism to turn off CSRF checks for test suite.
                # It comes after the creation of CSRF cookies, so that
                # everything else continues to work exactly the same
                # (e.g. cookies are sent, etc.), but before any
                # branches that call reject().
                return self._accept(request)

            if request.is_secure():
                # Suppose user visits http://example.com/
                # An active network attacker (man-in-the-middle, MITM) sends a
                # POST form that targets https://example.com/detonate-bomb/ and
                # submits it via JavaScript.
                #
                # The attacker will need to provide a CSRF cookie and token, but
                # that's no problem for a MITM and the session-independent
                # secret we're using. So the MITM can circumvent the CSRF
                # protection. This is true for any HTTP connection, but anyone
                # using HTTPS expects better! For this reason, for
                # https://example.com/ we need additional protection that treats
                # http://example.com/ as completely untrusted. Under HTTPS,
                # Barth et al. found that the Referer header is missing for
                # same-domain requests in only about 0.2% of cases or less, so
                # we can use strict Referer checking.
                referer = force_text(
                    request.META.get('HTTP_REFERER'),
                    strings_only=True,
                    errors='replace'
                )
                if referer is None:
                    return self._reject(request, REASON_NO_REFERER)

                referer = urlparse(referer)

                # Make sure we have a valid URL for Referer.
                if '' in (referer.scheme, referer.netloc):
                    return self._reject(request, REASON_MALFORMED_REFERER)

                # Ensure that our Referer is also secure.
                if referer.scheme != 'https':
                    return self._reject(request, REASON_INSECURE_REFERER)

                # If there isn't a CSRF_COOKIE_DOMAIN, require an exact match
                # match on host:port. If not, obey the cookie rules (or those
                # for the session cookie, if CSRF_USE_SESSIONS).
                good_referer = (
                    settings.SESSION_COOKIE_DOMAIN
                    if settings.CSRF_USE_SESSIONS
                    else settings.CSRF_COOKIE_DOMAIN
                )
                if good_referer is not None:
                    server_port = request.get_port()
                    if server_port not in ('443', '80'):
                        good_referer = '%s:%s' % (good_referer, server_port)
                else:
                    # request.get_host() includes the port.
                    good_referer = request.get_host()

                # Here we generate a list of all acceptable HTTP referers,
                # including the current host since that has been validated
                # upstream.
                good_hosts = list(settings.CSRF_TRUSTED_ORIGINS)
                good_hosts.append(good_referer)

                if not any(is_same_domain(referer.netloc, host) for host in good_hosts):
                    reason = REASON_BAD_REFERER % referer.geturl()
                    return self._reject(request, reason)

            if csrf_token is None:
                # No CSRF cookie. For POST requests, we insist on a CSRF cookie,
                # and in this way we can avoid all CSRF attacks, including login
                # CSRF.
                return self._reject(request, REASON_NO_CSRF_COOKIE)

            # Check non-cookie token for match.
            request_csrf_token = ""
            if request.method == "POST":
                try:
                    request_csrf_token = request.POST.get('csrfmiddlewaretoken', '')
                except IOError:
                    # Handle a broken connection before we've completed reading
                    # the POST data. process_view shouldn't raise any
                    # exceptions, so we'll ignore and serve the user a 403
                    # (assuming they're still listening, which they probably
                    # aren't because of the error).
                    pass

            if request_csrf_token == "":
                # Fall back to X-CSRFToken, to make things easier for AJAX,
                # and possible for PUT/DELETE.
                request_csrf_token = request.META.get(settings.CSRF_HEADER_NAME, '')

            request_csrf_token = _sanitize_token(request_csrf_token)
            if not _compare_salted_tokens(request_csrf_token, csrf_token):
                return self._reject(request, REASON_BAD_TOKEN)

        return self._accept(request)

通过_compare_salted_tokens函数对两个token进行比较

django 1.9和djagno1.11在处理上有一些变化,下面列了变化的部分,我没有仔细去查阅到底哪个版本开始改的,这是我用的最多的两个版本。

Django 1.9

class CsrfViewMiddleware(object):
    def process_view(self, request, callback, callback_args, callback_kwargs):
        try:
            csrf_token = _sanitize_token(
                request.COOKIES[settings.CSRF_COOKIE_NAME])
            # Use same token next time
            request.META['CSRF_COOKIE'] = csrf_token
        except KeyError:
            csrf_token = None
…… 

            if not constant_time_compare(request_csrf_token, csrf_token):
                return self._reject(request, REASON_BAD_TOKEN)

        return self._accept(request)

django 1.11

class CsrfViewMiddleware(object):
......
        csrf_token = self._get_token(request)
        if csrf_token is not None:
            # Use same token next time.
            request.META['CSRF_COOKIE'] = csrf_token
…… 

            request_csrf_token = _sanitize_token(request_csrf_token)
            if not _compare_salted_tokens(request_csrf_token, csrf_token):
                return self._reject(request, REASON_BAD_TOKEN)

比较一下他们的处理逻辑:

Django 1.9

(sanitize) csrf_token -> constant_time_compare <- request_csrf_token (csrfmiddlewaretoken)

django 1.11

(sanitize) csrf_token (_unsalt_cipher_token) -> constant_time_compare <- (sanitize) request_csrf_token (csrfmiddlewaretoken) (_unsalt_cipher_token)

可以看到,在django 1.11里有几个变化

  1. csrf_token的获取方法上,封装成了_get_token函数,对本例来说,本质上最后调用sanitize_token函数,这个是微小变化
  2. 比较之前,request_csrf_token在比较之前也用sanitize_token函数做了一下处理
  3. 比较时,不是内容直接比较,而是调用_unsalt_cipher_token函数做一下处理(我猜测是加解密相关处理),然后再进行比较。这儿特别要注意:在django 1.11 版本里,csrftoken和csrfmiddlewaretoken的值不一样了。

下面是比较函数,unsalt_cipher_token是新加的。

def _compare_salted_tokens(request_csrf_token, csrf_token):
    # Assume both arguments are sanitized -- that is, strings of
    # length CSRF_TOKEN_LENGTH, all CSRF_ALLOWED_CHARS.
    return constant_time_compare(
        _unsalt_cipher_token(request_csrf_token),
        _unsalt_cipher_token(csrf_token),
    )

参考文档