Django 中的 Web 安全手段

今天我们来简单聊一下在 Django 中针对常见的 Web 攻击手段做了哪些必要的防护措施,以及接下来我们在 Django 项目开发中需要注意哪些安全知识,避免给项目挖坑。

1. 深入 Django 中 CSRF 校验过程

上一节中提到了,针对 CSRF 攻击有效的解决方案是在网页上添加一个随机的校验 token 值,我们前面的登录的模板页面中添加的 {% csrf_token %},这里正好对应着一个随机值。我们拿之前的登录表单来进行观察,会发现这样几个现象:

  • 网页上隐藏的 csrf_token 值会在每次刷新时变化
  • 对应在请求和响应头部的 cookie 中的 csrftoken值却一直不变

图片描述
图片描述

这样子我们对应会产生几个思考问题:

  • 为什么网页上的 token 值会变,而 cookie 中的 token 则一直不变?
  • 整个 token 的校验过程是怎样的,有密码?如果有密码,密码存在哪里?

今天我们会带着这两个问题,查看下 Django 内部源码,找到这些问题的代码位置。我可能不会很完整的描述整个代码运行的逻辑,因为篇幅不够,而且细节太多,容易迷失在代码的海洋里。首先毋庸置疑的第一步是找我们在 settings.py 中设置的 CSRF 中间件:

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',
]

我们在上一讲中提到过中间件类的两个函数:process_request()process_response()。而在 CSRF 中间件文件中还有一个方法:process_view()。中间类比较完整的处理流程示意图如下所示,可以看到中间件的 process_view() 方法如果返回 None,则会执行下一个 中间件的 process_view() 方法。一旦它返回 HttpResponse 实例,则直接跳过视图函数到达最后一个中间件的 process_response() 方法中。

图片描述

我们来关注下 django.middleware 目录下的 csrf.py 文件,所有的答案都在这里可以找到。首先看最核心的中间件类:

# 源码位置:django/middleware/csrf.py

# ...

class CsrfViewMiddleware(MiddlewareMixin):
    def _accept(self, request):
        # Avoid checking the request twice by adding a custom attribute to
        # request.  This will be relevant when both decorator and middleware
        # are used.
        request.csrf_processing_done = True
        return None
    
    def _reject(self, request, reason):
        response = _get_failure_view()(request, reason=reason)
        log_response(
            'Forbidden (%s): %s', reason, request.path,
            response=response,
            request=request,
            logger=logger,
        )
        return response
    
    def _get_token(self, request):
        # ...
        
    def _set_token(self, request, response):
        # ...
    
    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
    
    def process_view(self, request, callback, callback_args, callback_kwargs):
        if getattr(request, 'csrf_processing_done', False):
            return None

        # 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)

            # 判断是不是 https 协议,不然不用执行这里
            if request.is_secure():
                # ...

            csrf_token = request.META.get('CSRF_COOKIE')
            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)
        
    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

这里比较复杂的部分就是 process_view() 方法。process_request() 方法只是从请求头中取出 csrftoken 值或者生成一个 csrftoken 值放到 request.META 属性中去;process_response() 会设置对应的 csrftoken 值到 cookie 或者 session 中去。这里获取 csrftoken 和 设置 csrftoken 调用的正是 _get_token()set_token()方法:

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 _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'],
                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',))
    # ...

如果我们没在 settings.py 中设置 CSRF_USE_SESSIONS 值时,在 django/conf/global_settings.py 默认设置为 False,那么我们就是调用前面熟悉的 response.set_cookie() 方法取设置 cookie 中的 key-value 值,也是我们在上面第二张图片所看到的 Set-Cookie 里面的值。

我们来看最核心的处理方法:process_view()。它的执行流程如下所列,略有删减,请仔细研读和对照代码:

  • 判断视图方法是否有 csrf_exempt 属性。相当于该视图方法添加了 @csrf_exempt 装饰器,这样不用检验 csrf_token 值,直接返回 None,进入下面的中间件执行,直到视图函数去处理 HTTP 请求;

  • 对于 GET、HEAD、 OPTIONS、 TRACE 这四种请求不用检查 csrf_token,会直接跳到最后执行 self._accept(request) 方法。但是我们常用的如 POST、PUT 以及 DELETE 等请求会进行特别的处理;

来看针对 POST、PUT 以及 DELETE 的特殊处理,要注意两处代码:

  • request_csrf_token 值的获取:对于 POST 请求,我们要从请求参数中获取,这个值正是表单中隐藏的随机 csrf_token,也是我们在第一张图中看到的值,每次请求都会刷新该值;而且对于其它的请求,该值则是从 request.META 中获取;

  • 校验 csrf_token 值是否正确。如果是不正确的 csrf_token 值,则会直接返回 403 错误;

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

    可以看到,这里校验的是两个值:一个是我们从 cookie 中获取的,另一个是前端表单中隐藏的那个随机数。

现在我们大致心里有个数了,Django 的校验方法竟然是用 cookie 中的值和页面上的随机值进行校验,这两个值都是64位的,你必须同时拿到这两个正确 token 值才能通过 Django 的 csrf 中间件校验。

比较原理,2个 token,一个放到 cookie 中,另一个放到表单中,会一直变得那种。接下来就是对这两个 token 进行对比。我们继续追踪 _compare_salted_tokens() 方法,可以在 csrf.py 中找到如下两个方法,它们分别对应着 csrf_token 值的生成和解码:

# 源码位置:django/middleware/csrf.py
# ...

def _salt_cipher_secret(secret):
    """
    Given a secret (assumed to be a string of CSRF_ALLOWED_CHARS), generate a
    token by adding a salt and using it to encrypt the secret.
    """
    salt = _get_new_csrf_string()
    chars = CSRF_ALLOWED_CHARS
    pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in salt))
    cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs)
    return salt + cipher


def _unsalt_cipher_token(token):
    """
    Given a token (assumed to be a string of CSRF_ALLOWED_CHARS, of length
    CSRF_TOKEN_LENGTH, and that its first half is a salt), use it to decrypt
    the second half to produce the original secret.
    """
    salt = token[:CSRF_SECRET_LENGTH]
    token = token[CSRF_SECRET_LENGTH:]
    chars = CSRF_ALLOWED_CHARS
    pairs = zip((chars.index(x) for x in token), (chars.index(x) for x in salt))
    secret = ''.join(chars[x - y] for x, y in pairs)  # Note negative values are ok
    return secret

# ...

来看这两个函数,首先是 _salt_cipher_secret() 方法,需要传入一个长度为 32 的 secret,就可以得到一个64位的随机字符。这个 secret 值在使用时也是随机生成的32个字符:

# 源码位置:django/middleware/csrf.py
def _get_new_csrf_string():
    return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)

# 源码位置:django/utils/crypto.py
def get_random_string(length=12,
                      allowed_chars='abcdefghijklmnopqrstuvwxyz'
                                    'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'):
    """
    Return a securely generated random string.

    The default length of 12 with the a-z, A-Z, 0-9 character set returns
    a 71-bit value. log_2((26+26+10)^12) =~ 71 bits
    """
    if not using_sysrandom:
        # This is ugly, and a hack, but it makes things better than
        # the alternative of predictability. This re-seeds the PRNG
        # using a value that is hard for an attacker to predict, every
        # time a random string is required. This may change the
        # properties of the chosen random sequence slightly, but this
        # is better than absolute predictability.
        random.seed(
            hashlib.sha256(
                ('%s%s%s' % (random.getstate(), time.time(), settings.SECRET_KEY)).encode()
            ).digest()
        )
    return ''.join(random.choice(allowed_chars) for i in range(length))

_salt_cipher_secret() 方法中我们可以看到,传入32位的密钥 secret,最后的 csrf_token 的生成是 salt + cipher,前32位是 salt,后32位是加密字符串。解密的过程差不多就是 _salt_cipher_secret() 的逆过程了,最后得到 secret。我们可以在 Django 的 shell 模式下使用下这两个函数:

(django-manual) [root@server first_django_app]# python manage.py shell
Python 3.8.1 (default, Dec 24 2019, 17:04:00) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from django.middleware.csrf import _get_new_csrf_token, _unsalt_cipher_token
>>> x1 = _get_new_csrf_token()
>>> x2 = _get_new_csrf_token()
>>> x3 = _get_new_csrf_token()
>>> print('x1={}\nx2={}\nx3={}'.format(x1, x2, x3))
x1=dvK3CRLiyHJ6Xgt0B6eZ7kUjxXgZ5CKkhl8HbHq8CKR0ZXMOxYnigzDTIZIdk3xZ
x2=TMazqRDst3BSiyxIAI1XDiFKdbmxu8nKRVvMogERiZi6IG6KNhDSxcgEOPTqU0qF
x3=gy998wPOCZJiXHo7HYQtY3dfwaevPHKAs2YXPAeJmWUaA5vV2xdXqvlidLR4XM1T
>>> _unsalt_cipher_token(x1)
'e0yOJ0P0edi4cRtY62jtjpTKlcCopBXP'
>>> _unsalt_cipher_token(x2)
'8jvn8zbzZ6RoAiJcnJM544L4LOH3A2d5'
>>> _unsalt_cipher_token(x3)
'mEZYRez5U7l2NyhYvJxECCidRLNJifrt'
>>> 

了解了上述这些方法后,现在来思考前面提出的问题:为什么每次刷新表单中的 csrf_token 值会一直变化,而 cookie 中的 csrf_token 值却一直不变呢?首先我们看在页面上生成随机 token 值的代码,也就是将标签 {{ csrf_token }} 转成 64位随机码的地方:

# 源码位置: django/template/defaulttags.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 ''

可以看到 csrf_token 值是从 context 中取出来的,而在 context 中的 csrf_token 值又是由如下代码生成的:

# 源码位置:django/template/context_processors.py

from django.middleware.csrf import get_token

# ...

def csrf(request):
    """
    Context processor that provides a CSRF token, or the string 'NOTPROVIDED' if
    it has not been provided by either a view decorator or the middleware
    """
    def _get_val():
        token = get_token(request)
        if token is None:
            # In order to be able to provide debugging info in the
            # case of misconfiguration, we use a sentinel value
            # instead of returning an empty dict.
            return 'NOTPROVIDED'
        else:
            return token

    return {'csrf_token': SimpleLazyObject(_get_val)}

可以看到,最后 csrf_token 值还是由 csrf.py 文件中的 get_token() 方法生成的。来继续看这个 get_token() 方法的代码:

# 源码位置:django/middleware/csrf.py

def get_token(request):
    """
    Return the CSRF token required for a POST form. The token is an
    alphanumeric value. A new token is created if one is not already set.

    A side effect of calling this function is to make the csrf_protect
    decorator and the CsrfViewMiddleware add a CSRF cookie and a 'Vary: Cookie'
    header to the outgoing response.  For this reason, you may need to use this
    function lazily, as is done by the csrf context processor.
    """
    if "CSRF_COOKIE" not in request.META:
        csrf_secret = _get_new_csrf_string()
        request.META["CSRF_COOKIE"] = _salt_cipher_secret(csrf_secret)
    else:
        csrf_secret = _unsalt_cipher_token(request.META["CSRF_COOKIE"])
    request.META["CSRF_COOKIE_USED"] = True
    return _salt_cipher_secret(csrf_secret)

注意注意!最关键的地方来了,这个加密的 secret 的值是从哪里来的?正是从请求头中的 cookie 信息中来的,如果没有将生成一个新的密钥,接着把该密钥生成的 token 放到 cookie 中。最后使用 _salt_cipher_secret() 方法生成的 csrf_token 和 cookie 中的 csrf_token 具有相同的密钥。同时拿到了这两个值,就可以进行校验和判断,下面我们在 ``_salt_cipher_secret()方法中加上一个print()` 语句,然后执行下看看是否如我们所说。

可以看到每次生成 token 时加密的秘钥都是一样的。我们从上面生成的 csrf_token 中选一个进行解密,得到的结果和 cookie 中的正是一样的密钥:

>>> from django.middleware.csrf import _unsalt_cipher_token
# 两个 token 解密是相同的,这才是正确的
>>> _unsalt_cipher_token('2Tt8StiU4rZcvCrTb2KqJwTTOTCP0WvJhp7GyTj58RGv97IvJInxyrAN4DKCdt1M')
'pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd'
>>> _unsalt_cipher_token('VI68m6xT1JczSsnuJvxqtcr0L0EvCN1DaeKG2wy459TSwXE6hbaxi78U1KMiPkxG')
'pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd'

现在大部分代码我们也算清楚了,csrf_token 的校验原理我们也知道了。那么如果想自己生成 csrf_token 并通过 Django 的校验也非常简单,只需要通过那个密钥生成一个 csrf_token 或者直接输入使用密钥都可以通过校验。我们首先使用密钥在 shell 模式下随机生成一个 csrf_token 值:

>>> from django.middleware.csrf import _salt_cipher_secret
>>> _salt_cipher_secret('pGOIQAbleARtOFrMIQNhZ5R4qUiXnHGd')
'ObaC9DEZfn4seXbOhgBCph2Y5PMjm0Eo3HOaP3FajNLLSssqPWeJecJSlzU6zxar

接下来在看我的演示,第一此我随机改动 csrf_token 的字符,这样校验肯定通不过;接下来我是用自己生成的 token 值以及直接填写密钥再去提交都能通过校验。为了方便演示结果,我们在 csrf.pyprocess_view() 函数中添加几个 print() 方法,方便我们理解执行的过程。

图片描述

2. Django 中 XSS 漏洞防护

在 Django 中也提供了部分代码来帮助我们防止 XSS 漏洞,我们需要熟悉 Django 的相关代码才能使用好它。在模板文件中,Django 使用 escape 过滤器对单一变量进行转义过滤,无需转义时使用 safe 过滤器;此外 Django 默认对 HTML 自动转义,使用的标签为:{% autoescape on %},而如想停止自动转义,可以使用 off 参数关闭该标签:{% autoescape off %}。对于 Django 做的这些网页元素安全、防止 XSS 漏洞的工作的代码主要在 django/utils/html_safe.py 文件中,如果有兴趣可以深入学习下这里的代码。但是有这些代码真的就万无一失了吗?这种想法是错误的,比如我们人为的用 safe 不对变量进行转义,有时候控制不好就会造成漏洞,更多的时候,Django 给我们写好了很多安全代码,但我们需要用好这些代码,同时也要加强安全相关的知识背景,尽量减少常见的漏洞出现。

3. Django 中对 SQL 注入漏洞做的工作

Django 内置的 ORM 模型某种程度上帮我们处理好了 SQL 注入问题,我们尽量使用 Django 内置 ORM 模型的 api 去对数据库中的表进行增删改查操作,它会根据我们所使用的数据库服务器的转换规则,自动转义特殊的SQL参数,从而避免出现 SQL 注入的问题。这个操作被运用到了整个 Django 的 ORM 模型的 api 中,但也有一些例外,如给 extra() 方法的 where 参数, 这个参数故意设计成可以接受原始的 SQL,并使用底层数据库API的查询。我们来看存在 SQL 注入漏洞和正确操作者两种写法:

# 存在SQL注入漏洞代码
name = 'Joe'  # 如果first_name中有SQL特定字符就会出现漏洞
User.objects.all().extra(where=["name='%s' and password='%s'" % (name, password)])
# 正确方式
User.objects.all().extra(where=["name='%s' and password='%s'"], params=[name, password])

我们前面在 ORM 操作中建立了一个 user 表,对应的 model 类如下:

# 代码位置: hello_app/models.py
class User(models.Model):
    name = models.CharField('用户名', max_length=20)
    password = models.CharField('密码', max_length=50)
    email = models.EmailField('邮箱')

    def __str__(self):
        return "<%s>" % (self.name)

    class Meta:
        # 通过db_table自定义数据表名
        db_table = 'user'

这个表中有我们之前第16节中测试的11条数据,我们来拿这个表来完成相关 SQL 注入的实验。

图片描述

我们现在用两种方式来实现 SQL 注入:

在 Django 中使用原生 SQL 操作 MySQL 数据库。下面是两种写法,分别对应着存在 SQL 注入漏洞和安全的操作:

>>> from django.db import connection
>>> cur = connection.cursor()
# 存在注入漏洞,绕过了判断语句
>>> cur.execute("select * from user where name='%s' and password='%s'" % ("' or 1=1 #", 'xxx'))
11
# 使用这种方式会避免上述问题
>>> cur.execute("select * from user where name=%s and password=%s", ["' or 1=1#", 'xxx'])
0
>>> 

在 Django 的 ORM 模型中使用 extra() 方法来构建 SQL 注入漏洞:

>>> from hello_app.models import User
# 实现SQL注入
>>> User.objects.all().extra(where=["name='%s' and password='%s'" % ("') or 1=1 limit 1#", 'xx')])
query=b"SELECT `user`.`id`, `user`.`name`, `user`.`password`, `user`.`email` FROM `user` WHERE (name='') or 1=1 limit 1#' and password='xx')  LIMIT 21"
<QuerySet [<User: <test>>]>
# 安全操作
>>> User.objects.all().extra(where=["name=%s and password=%s"], params=["') or 1=1 limit 1#", 'xx'])
query=b"SELECT `user`.`id`, `user`.`name`, `user`.`password`, `user`.`email` FROM `user` WHERE (name='\\') or 1=1 limit 1#' and password='xx')  LIMIT 21"
<QuerySet []>
# 正常取数据操作
>>> User.objects.all().extra(where=["name=%s and password=%s"], params=["test", 'xxxxxx'])
# 这个query是我为了方便在执行sql的地方加了个print语句,打印执行的sql
query=b"SELECT `user`.`id`, `user`.`name`, `user`.`password`, `user`.`email` FROM `user` WHERE (name='test' and password='xxxxxx')  LIMIT 21"
<QuerySet [<User: <test>>]>

注意:为什么这次注入的语句变成了"') or 1=1 limit 1#",这是因为我发现使用 extra() 方法是生成的 SQL 语句是这样的 (下面的 query 是我在源代码添加的一行 print 语句打印的):

>>> User.objects.all().extra(where=["name=%s and password=%s"], params=["test", 'xxxxxx'])
  query=b"SELECT `user`.`id`, `user`.`name`, `user`.`password`, `user`.`email` FROM `user` WHERE (name='test' and password='xxxxxx')  LIMIT 21"
  <QuerySet [<User: <test>>]>

可以看到 extra 将 where 参数放到括号中,为了能注入正确的 SQL语句,就必须要添加 ) 去抵消 # 注释掉的原右括号,这样才能正常执行。

到目前位置,我们在 Django 中对 SQL 注入漏洞进行了再现。为了避免 SQL 注入漏洞的方式也比较简单,主要遵循如下两个规则即可:

  • 尽量使用 Django 的 ORM 模型提供的方法去操作数据库
  • 不要使用动态拼接 SQL 的方式,而是将 SQL 语句和参数分开放

4. 小结

本小节中我们主要介绍了 Django 框架在几种常见的 Web 安全问题上做的一些工作,以及我们在后续开发 Web 项目的过程中要注意的安全规范,避免产生不必要的安全问题。