XSS和CSRF攻击的基础原理这里就不介绍了,之前写了一篇文章单独介绍的很详细了,传送门,这里我们直接以Django为分析对象,分析中间件csrf生成原理以及防范Token如何运作的。
Setting.py中有茫茫多的配置选项。传送门
官方文档介绍的也是表面,本文通过源码层面直接分析流程
官方文档针对CSRF的介绍以及参数配置 传送门
MIDDLEWARE
中MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
上面的第四个就是我们这里要详细分析的CSRF中间件。
Django中中间件最多可以定义五个方法:
process_request(self,request)
process_view(self, request, view_func, view_args, view_kwargs)
process_template_response(self,request,response)
process_exception(self, request, exception)
process_response(self, request, response)
Django工作流程中中间件执行顺序:
1.请求进入到Django后,Django接收到第一个请求会调用中间件的初始化方法
__init__
,处理请求之前会按中间件的注册顺序执行每个中间件中的process_request
方法。如果所有的中间件的process_request
方法return None
,则进入路由映射,进行url匹配,通过规则确认请求由哪个视图处理,如果是return HTTPResponse
,则调用process_response
返回到客户端
2.依次按顺序执行中间件中的process_view
方法
如果某个中间件的process_view
方法没有return HttpResponse
,则根据第1步中匹配到的URL执行对应的视图函数或视图类(process_view
)在views.py
执行之前
如果某个中间件的process_view
方法中返回了return HttpRespons
,则后面的视图函数或视图类不会执行,程序会执行process_response
返回客户端
3.视图函数或者视图类接收Request,通过模型Model与数据库交互,获取数据交给模板引擎进行渲染,如果视图函数或视图类中使用render()
方法来向客户端返回数据,则会触发中间件中的process_template_response
方法,response
函数都是通过注册的逆序进行调用,必须返回response
才能继续转发,否则程序排除异常
4.随后视图函数或视图类执行计算渲染,如果没有任何异常,会按照中间件的注册顺序逆序执行中间件中的process_response
方法
如果中间件中定义了return response
,程序会正常执行,把视图函数或视图类的执行结果返回给客户端,否则程序会抛出异常
5.在第三步,程序在视图函数或视图类的正常执行过程中
如果出现异常,则会执行按顺序执行中间件中的process_exception
方法,该方法也是逆序执行的,如果某个中间件的process_exception
方法中定义了return语句,则终端传递中间件中的process_exception
函数,转发给process_response
处理后返回给客户端
单个CSRF中间件详细流程图如下
根据上面的步骤,验证了一下多个中间件组合调用的顺序
# 两个中间件代码
from django.http import HttpResponse
from django.utils.deprecation import MiddlewareMixin
class Exp1(MiddlewareMixin):
def process_request(self,request):
print('Exp1 ---> precess_request %s'%id(request))
def process_response(self, request, response):
print('Exp1 ---> process_response')
return response
def process_view(self, request, view_func, view_args, view_kwargs):
print('Exp1 ---> process_view')
print('Exp1',view_func, view_func.__name__)
def process_exception(self, request, exception):
print('Exp1',exception)
print('Exp1','process_exception')
# return HttpResponse('卧槽,挂了啊')
def process_template_response(self, request, response):
print('Exp1','process_template_response')
return response
class Exp2(MiddlewareMixin):
def process_request(self, request):
print('Exp2 ---> precess_request %s'%id(request))
print()
def process_response(self, request, response):
print('Exp2 ---> process_response')
return response
def process_view(self, request, view_func, view_args, view_kwargs):
print('Exp2 ---> process_view')
print('Exp2', view_func, view_func.__name__)
print()
def process_exception(self, request, exception):
print('Exp2', exception)
print('Exp2', 'process_exception')
return HttpResponse('enenenen???')
def process_template_response(self, request, response):
print('Exp2','process_template_response')
return response
# 视图代码
def exrp(request):
# a = [1, 2, 3]
# a[100]
print('views.py-----视图中的代码被执行了')
# return render(request, 'books/cook2.html', {'x':'渲染一下'})
def render():
print("触发 Template render()方法")
return HttpResponse("反悔了啊")
rep = HttpResponse("11123232")
rep.render = render
return rep
# 最后记得在配置文件下写入对应的中间件
访问对应的路径,模拟正常流程下render()
渲染的结果
Exp1 ---> precess_request 4385889584
Exp2 ---> precess_request 4385889584
Exp1 ---> process_view
Exp1 <function exrp at 0x1052e2400> exrp
Exp2 ---> process_view
Exp2 <function exrp at 0x1052e2400> exrp
views.py-----视图中的代码被执行了
Exp2 process_template_response
Exp1 process_template_response
触发 Template render()方法
Exp2 ---> process_response
Exp1 ---> process_response
[22/May/2019 16:44:32] "GET /books/exrp/ HTTP/1.1" 200 12
再看一下如果视图渲染的时候错误打印
Exp1 ---> precess_request 4370030264
Exp2 ---> precess_request 4370030264
Exp1 ---> process_view
Exp1 <function exrp at 0x104640400> exrp
Exp2 ---> process_view
Exp2 <function exrp at 0x104640400> exrp
Exp2 list index out of range
Exp2 process_exception
Exp2 ---> process_response
Exp1 ---> process_response
[22/May/2019 16:50:29] "GET /books/exrp/ HTTP/1.1" 200 11
总结:
process_request
和process_view
这两个函数是在视图函数之前执行的process_request
和process_view
是顺序执行,其他三个是逆序执行process_exception
方法,如果不正常,执行顺序逆序,有一个返回HTTPResponse
则终端传递,直接传给process_response
返回给客户端render()
渲染的时候,会触发process_template_response
函数,很少用这个方法两张图详细描述了多个中间件执行顺序,可以配合上面的总概览图看
首先,我们假设settings中的中间件只有一个
'django.middleware.csrf.CsrfViewMiddleware'
,根据上面的流程,分析下csrf.py中间件的源码
Django的第一个请求过来,会首先分发到CsrfViewMiddleware中间件模块
class CsrfViewMiddleware(MiddlewareMixin):
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 process_request(self, request):
csrf_token = self._get_token(request)
if csrf_token is not None:
# Use same token next time.
request.META['CSRF_COOKIE'] = csrf_token
......
无论是GET还是POST,都会进入该流程,假定我们是一个未登录的时候的GET请求,会首先调用__get_token
方法(注意:这里调用的私有方法,不是外部的get_token
方法,这两个有很大的区别)。可以看到会先在settings
中拿CSRF_USE_SESSIONS
参数,这里用到了懒加载的机制,该机制会单独开一片文章介绍TODO一下。文章上面放出了官方文档settings的所有参数介绍,这里的两个分支获取token是根据以下两个值配置的。
CSRF_USE_SESSIONS
or CSRF_COOKIE_HTTPONLY
这两个值是配套出现的,默认不出现,也就是都是False
。那么HTTP报文是这样的
除了sessionid之外,还把csrftoken
作为key,也存储在cookies中一并返回给客户端。
前者代表是否把token存入session,如果存入,那么cookies就不在存放csrftoken
这个key,而是直接存储sessionid,通过sessionid查出来在从session中获取csrf_token
。那么配套的就是把CSRF_COOKIE_HTTPONLY
设置成YES,告诉浏览器Cookies只能浏览器默认行为获取,脚本是无法获取的。默认值之外,两者都是True的话,我们可以看Cookies中的报文就只剩sessiondid了。
流程继续,还是按默认值走,我们会从request.COOKIES[settings.CSRF_COOKIE_NAME]
中获取,其中CSRF_COOKIE_NAME
默认是csrftoken
。一开始肯定是None
。这里没有return _reject
,因此进入URLConf
进行路径匹配
from django.contrib import admin
from django.urls import path, include, re_path
urlpatterns = [
path('admin/', admin.site.urls),
path('polls/', include('polls.urls')),
re_path(r'^books/', include('books.urls', namespace='books'))
]
一层层匹配后会有一个对应的视图函数接收,接收之前,来到了第二步。
def process_view(self, request, callback, callback_args, callback_kwargs):
if getattr(request, 'csrf_processing_done', False):
return None
# 如果装饰器 @csrf_exempt生效,则不处理
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):
# 中间件关闭
return self._accept(request)
if request.is_secure():
# 发出的是HTTPS请求,确保我们的url在Refer中
referer = request.META.get('HTTP_REFERER')
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:
try:
# request.get_host() includes the port.
good_referer = request.get_host()
except DisallowedHost:
pass
# HTTP白名单,可信任来源
good_hosts = list(settings.CSRF_TRUSTED_ORIGINS)
if good_referer is not None:
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)
csrf_token = request.META.get('CSRF_COOKIE')
if csrf_token is None:
# POST 一定要有Cookies存储CSRFToken,避免CSRF攻击
return self._reject(request, REASON_NO_CSRF_COOKIE)
# Check non-cookie token for match.
request_csrf_token = ""
if request.method == "POST":
try:
# request.POST.get() 相当于获取request.POST['csrfmiddlewaretoken']的值,
# 若果出错就返回 ''.这里的csrfmiddlewaretoken是提交的表单中的值,在
# 模板中用{% csrf_token %} 生成
request_csrf_token = request.POST.get('csrfmiddlewaretoken', '')
except IOError:
# 在我们完成读取POST数据之前处理断开的连接。
# process_view不应该引发任何exception,因此我们将忽略并返回403
# 假设他们仍在监听,他们可能不是因为错误
pass
if request_csrf_token == "":
# ajax中适用'X-CSRFToken'
# CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
request_csrf_token = request.META.get(settings.CSRF_HEADER_NAME, '')
request_csrf_token = _sanitize_token(request_csrf_token)
# 对比两个csrf_token,一个是表单里隐藏的csrfmiddlewaretoken
# 或者ajax的hearder: X_CSRFTOKEN),另一个是自带的cookies里的csrf_token
if not _compare_salted_tokens(request_csrf_token, csrf_token):
return self._reject(request, REASON_BAD_TOKEN)
return self._accept(request)
源码已经加了注释,如果请求是GET,HEAD,OPTIONS,TRACE
中的,返回None,直接渲染模板。如果不是这几个里面的,比如POST
,首先判断是否已经禁用CSRF防御,是的话也是直接渲染模板,不是的话判断是否是HTTPS进入,进行域名白名单匹配。如果是POST
,会先从Cookies中取csrf_token
,如果取不到,直接拒绝403,如果取到了,表单提交,从csrfmiddlewaretoken
中取request_csrf_token
,如果是Ajax,从X-CSRFToken
取。然后进行这两个值的清洗,去除csrf_secret
进行对比,相等就渲染模板,反之直接403。这里的两个Token匹配规则后面会详细介绍
到这里,肯定会有人好奇,我一个网站下,一直普通Get请求是没有Cookies的,如果添加了模板的{% csrf_token%}
就会触发Cookies和csrfmiddleware这两个值,而且上面提到的数据清洗,这些Token是在什么时候赋值上去的,Token生成规则,匹配规则等,我们从render
函数切入。
以下是上面两个执行完之后,没有问题,就会渲染模板
return render(request, 'books/test1.html', {'a' : 111111,'b' : request.GET.get('b'), 'c' : request.GET.get('c')})
# render是便利函数,以下是源码
def render(request, template_name, context=None, content_type=None, status=None, using=None):
content = loader.render_to_string(template_name, context, request, using=using)
return HttpResponse(content, content_type, status)
# 继续往下render_to_string, loader是模块名,
def render_to_string(template_name, context=None, request=None, using=None):
if isinstance(template_name, (list, tuple)):
template = select_template(template_name, using=using)
else:
template = get_template(template_name, using=using)
return template.render(context, request)
最后的render
必然是模板渲染的,模板也是一个类,在/django/template/backends/jinja2.py
中
class Template:
def __init__(self, template, backend):
self.template = template
self.backend = backend
self.origin = Origin(
name=template.filename, template_name=template.name,
)
def render(self, context=None, request=None):
from .utils import csrf_input_lazy, csrf_token_lazy
if context is None:
context = {}
if request is not None:
# 以下就是我们要找的核心的代码
context['request'] = request
context['csrf_input'] = csrf_input_lazy(request)
context['csrf_token'] = csrf_token_lazy(request)
for context_processor in self.backend.template_context_processors:
context.update(context_processor(request))
return self.template.render(context)
这里的context就是我们的参数字典,最终模板渲染的时候会赋值多几个key和value,这里我们就能找到csrf_token
,这就是填充到{% csrf_token %}
的值,再往下看
from django.middleware.csrf import get_token
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.safestring import SafeText
def csrf_input(request):
return format_html(
'',
get_token(request))
csrf_input_lazy = lazy(csrf_input, SafeText, str)
csrf_token_lazy = lazy(get_token, str)
又是懒加载,这里直接用他的原理,后续再开文章介绍,记住,只有当值真正被用的时候才会执行,函数调用是不会执行的。比如csrf_token_lay(request)
。注意看这里的核心函数get_token
,引用来自django.middleware.csrf
,这下就又回到中间件的核心函数了,这就能解释为什么普通的GET请求不会生成Token,只有加载了{% csrf_token %}
的模板,在赋值的时候,去中间件获取token值,代码如下
def get_token(request):
if "CSRF_COOKIE" not in request.META:
# 如果request中不存在csrf,先生成一个新的secret,加密赋值到META["CSRF_COOKIE"] 中,
# 后面用来放到set_cookie之中
csrf_secret = _get_new_csrf_string()
request.META["CSRF_COOKIE"] = _salt_cipher_secret(csrf_secret)
else:
# 如果request的cookie中存在了csrf_token,冲洗解密,取出secret csrf_secret = _unsalt_cipher_token(request.META["CSRF_COOKIE"])
csrf_secret = _unsalt_cipher_token(request.META["CSRF_COOKIE"])
request.META["CSRF_COOKIE_USED"] = True
# 返回另外一个加密生成的secret, 由于加密是随机的,所以与上面的META["CSRF_COOKIE"]不一样
return _salt_cipher_secret(csrf_secret)
这里就不贴_get_new_csrf_string
,_salt_cipher_secret
和 _unsalt_cipher_token
了,代码太多影像阅读,首先看个图,为什么Cookies中的值csrfmiddleware
中的值不同,而且刷新的时候Cookies一直不变,csrfmiddleware
会一直变,而且还能匹配上?
这个函数会在渲染模板的时候调用,具体来说是由csrf context processor
调用。
如果request.META["CRSF_COOKIE"]
不存在,就调用_get_new_csrf_string()
函数来生成一串随机字符(32个字符,大小写字母和数字),赋给csrf_secret
,再调用_salt_cipher_secret(scrf_secret)
和随机生成的32位salt一起生成64个字符的字符串赋给request.META[“CSRF_COOKIE”],而这个request.META["CSRF_COOKIE"]
之后用来设置COOKIE 的csrf_token
。
最后的返回值_salt_cipher_secret(csrf_secret)
就渲染到POST表单的csrfmiddlewaretoken
。值得一提的是_salt_cipher_secret(csrf_secret)
每次的返回值都不一样,而csrf_secret == _unsalt_cipher_token(_salt_cipher_secret(csrf_secret))
。
总的来说,涉及到三个值,csrf_token
、csrfmiddlewaretoken
和csrf_secret
,还有两个函数,_unsalt_cipher_token(token)
和_salt_cipher_secret(token)
。
这就解释了为什么两个值不同,但是这两个值传入的csrf_secret
是一样的,只是salt不同,_unsalt_cipher_token
解析出来的值就是一样的,这就是核心比较的东西。
When validating the ‘csrfmiddlewaretoken’ field value, only the secret, not the full token, is compared with the secret in the cookie value. This allows the use of ever-changing tokens. While each request may use its own token, the secret remains common to all.
官方文档也说了,我们比较的不是token,而是计算出来的secret,由于salt的存在,每次的csrfmiddlewaretoken是不同的,但是和cookies中的比较secret是一样的。
用图来说明下这两个过程:
_unsalt_cipher_token
和_salt_cipher_secret
函数实现图解。而且两次返回的Token不同如何计算出同一个csrf_secret
模板渲染完之后会触发`process_response``
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
def _set_token(self, request, response):
if settings.CSRF_USE_SESSIONS:
if request.session.get(CSRF_SESSION_KEY) != request.META['CSRF_COOKIE']:
request.session[CSRF_SESSION_KEY] = request.META['CSRF_COOKIE']
else:
response.set_cookie(
settings.CSRF_COOKIE_NAME,
# request.META['CSRF_COOKIE']就是在上面赋值的
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,
samesite=settings.CSRF_COOKIE_SAMESITE,
)
# Set the Vary header since content varies with the CSRF cookie.
patch_vary_headers(response, ('Cookie',))
组装response,设置Cookies,返回response,客户端浏览器完成渲染,这样一次请求就结束了。
前几步已经把每一步具体的实现和原理用图和文字都介绍清楚了,现在带着问题,Django是如何验证一个请求不是CSRF的?
当我们普通的访问一个GET页面的时候,没有任何{%csrf_token%}
,渲染的时候,context['csrf_token']
不会被填充,就是不会去触发get_token
方法,所以你请求几次都不会有Cookies。当我们开发一个网站的时候,如果启用CSRF防御,用户登录之前需要提交表单,表单的这个页面会嵌入{%csrf_token%}
,会先触发当前登录页面的GET请求,页面render
的时候,根据上面第三步,会触发lazy的get_token
函数,这个函数会自动生成csrf_token,如果cookies中有的话就不生成,用csrf_token清洗出后32位,再解析出秘钥,返回给表单一个新的csrfmiddlewaretoken
,至此登录页面的Cookies也已经生成并返回了,而且表单动态生成了一个csrfmiddlewaretoken
。
当正式登录的时候,发送POST请求,同源策略浏览器会携带对应的Cookies,而且会携带表单生成的csrfmiddlewaretoken
发送给服务端,当服务器接收到的时候转发给Django,又会来到中间层1-4的步骤。这个时候第一步process_request
能获取到值,给request.META["CRSF_COOKIE"]
设置值,进入URLConfig路径匹配,匹配到进去视图函数之前进入process_view
,如果是GET,直接渲染页面,如果是POST,判断是否禁用防御,判断是否HTTPS安全,判断csrf_token
是否为空,这些判断有一个失败,就能看到熟悉的403页面。这个时候就进入最核心的CSRF防御的Token验证过程了,首先会去Cookies中拿到csrf_token
,然后如果是表单,直接拿出csrfmiddlewaretoken
如果是Ajax,就从header里面拿X-CSRF-Token
,根据上面的Token算法,这不是比较这两个token,因为肯定不相同,我们需要反向解析清洗出真正的csrf_secret
值,对比如果相同,就是正常访问,如果不同,依然是熟悉的403页面,禁止访问。通过之后渲染页面,然后继续执行precess_response
,返回给客户端。
1.django/middleware/csrf.py
文件里有个函数:rotate_token(request)
,这个函数用来改变csrf_token
这个COOKIE。 在用户登录后(是指 django.contrib.auth
这个组件的登录)调用,主要从安全考虑,避免这个COOKIE跟登录前的一样。 如果自己实现的登录逻辑,可以调用这个函数提高点安全性。
一般csrf_token
这个COOKIE是不会变的,除了第一点说的登录,和不存在时重新生成一个。有时候会出现登录后csrftoken失效的情况。
2.为什么Django要把csrf_token
和csrfmiddlewaretoken
设置成不相等,直接生成的时候让它们相等,验证的时候判断是否相等不就好了?个人觉得这样做有个好处,有时候csrftoken这个COOKIE前端不需要获取,可以设置成HTTP ONLY,提高点安全性。 纯属个人理解
3.从上面分析的算法来看,csrf_token
跟csrfmiddlewaretoken
相同也可以通过CSRF验证。所以在AJAX请求中,直接取csrf_token
值加到请求中就好了,当然和表单一样单独再动态下发也行,问题是要改太多地方了,Ajax可以统一获取。
参考文章:
官方文档
官方文档配置文件
Token验证原理简单篇
Django cookies和session
CSRF验证原理中间件核心
CSRF防御原理解析
中间件源码解析
懒加载原理
Django工作流转