写前后端的时候遇到了这个问题,花了三天时间解决,就还是简单地写一写。写到最后才发现,这里面涉及的知识量也太大了,也请各位读者耐心的读。

前言

CORS 和 CSRF 这两个概念很容易混淆,即使我在这篇博客前也不是很清楚二者的区别,于是就再搜了一下,顺便把另外两个概念也聊一聊。

浏览器、前端和后端的关系大概是:前端 <-> 浏览器 <-> 后端。前端告诉浏览器它需要访问什么,浏览器就向后端发请求,然后把应答给前端。

而同源策略、CORS、CSRF、HttpOnly 和 SameSite 都在围绕一件事情:如何防止恶意前端误导用户和浏览器,在用户不知情的情况下以用户的身份恶意访问后端(删除数据、转账等等)。

同源策略

浏览器的同源策略- Web 安全| MDN

同源:两个 Protocol、Port 和 Host 都相同的 URL 是同源的;
跨域:两个 Protocol、Port 和 Host 不都相同的 URL 是跨域的;

MDN 上举了几个同源和跨域的例子,这里不再赘述。

同源策略是一个比较笼统的概念,它是为了限制一个 origin 的文档或者它加载的脚本如何能与另一个源的资源进行交互。就是说,它限制 baidu.com 的脚本不能访问到 taobao.com 的资源。

现代浏览器同时采用了两种同源策略:

  1. DOM 同源策略:禁止对不同源页面 DOM 进行操作。这里主要场景是 iframe 跨域的情况,不同域名的 iframe 是限制互相访问的。
  2. XMLHttpRequest 同源策略:禁止使用 XHR 对象向不同源的服务器地址发起 HTTP 请求。

可见,同源策略就是浏览器用来防止恶意前端的策略。

不过请注意,浏览器并不限制 <img> <tag> <form> 等标签等进行跨域访问。

CORS

跨源资源共享(CORS)- HTTP | MDN

但是,并不是所有跨域访问都是恶意的,比如 tmall.com 前端想要向 taobao.com 后端发起 HTTP 请求这种情况,该怎么处理呢?

所以,CORS(Cross-Origin Resource Sharing,跨域资源共享)出现了。CORS 由一系列 HTTP 头组成,当前端想要跨域访问后端时,浏览器将会利用这些 HTTP 头与后端交互,让后端告诉浏览器决定是否阻止前端获取跨域请求的响应。

MDN 可以看到,CORS 一共只规定了九个 HTTP 头,除掉 Origin 以外,其余的全部以 Access-Control- 打头。

下面是一次跨域请求的示例,前端 localhost:8080 向后端 uestcmsc-webapp.lyh543.cn 发出了 HTTP 请求。浏览器使用了两次请求,第一次被称为预请求,第二次被称为正式请求

浏览器第一次预请求
浏览器第一次预请求

浏览器首先在预请求中告诉后端:正式请求中的 HTTP 方法是 access-control-request-method: POST 以及会用到的报头为 access-control-request-headers: content-type,还有前端的源是 origin: http://localhost:8080。后端的应答中表明允许的源、报头、HTTP 方法,还有一个允许携带认证信息 access-control-allow-credentials: true(认证信息有三类,包含 Cookie、authorization 头和 TLS 客户端证书)。

浏览器第二次正式请求
浏览器第二次正式请求

收到后端的报文后,浏览器立刻向后端发出 CORS 的正式请求。在正式请求中,浏览器使用了 POSTcontent-type,以及 origin: http://localhost:8080,后端返回 access-control-allow-credentials: true access-control-allow-origin: http://localhost:8080


从上面可以看出,如果需要支持 CORS,主要是后端需要进行单独配置。前端没有什么要配置的,但是因为:

Credentials必须在前后端都被配置(即the Access-Control-Allow-Credentials header 和 XHR 或Fetch request中都要配置)才能使带credentials的CORS请求成功。– MDN

前端需要配置的只有这一个点。

前端的配置很简单,对于 axios 来说,就是加上一行 withCredentials: true

1
2
3
4
5
6
7
8
9
import axios from "axios";

const service = axios.create({
baseURL: 'https://uestcmsc-webapp.lyh543.cn/api',
timeout: 5000, // 请求的超时时间
withCredentials: true, // 允许携带 cookie sessionid 做认证
});

export default service;

后端 Django 配置就要麻烦一些了,需要安装 django-cors-headers,然后在 settings.py 里添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
INSTALLED_APPS = [
# ...
'corsheaders',
#...
]

MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # CORS 中间件,需注意与其他中间件顺序,这里放在最前面即可
#...
]

# CORS headers
# 这里的 CORS 策略是允许所有源的前端跨站访问
# 也可以根据自己需要设置 CORS 源白名单,然后阻止白名单以外的
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_METHODS = (
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
'VIEW',
)
CORS_ALLOW_HEADERS = (
'XMLHttpRequest',
'X_FILENAME',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'Pragma',
)

配置好以后就可以了。

CSRF

CSRF(Cross-site request forgery)跨站请求伪造,是一种常见的攻击方式。是指 A (前端、后端)网站正常登陆后,cookie 正常保存,恶意前端网站 B 通过某种方式访问 A 网站的后端进行操作,而浏览器向 A 后端请求时会自动带上 Cookie、造成危害的攻击方式。

好,到这里我就懵了,同源策略不就是用来防止 B 前端访问到 A 后端的吗?但请注意以下三点:

  1. 同源策略不限制通过 <img> <tag> <form> 加载/上传跨域资源
  2. 同源策略不阻止接口请求而是拦截请求结果,可能请求成功时已经造成了影响(这一点的来源是博文,但是我有点疑惑)
  3. 假设一个公共后端 API 允许所有人使用(对于 Django 来说,就是设置 CORS_ORIGIN_ALLOW_ALL = True),但它应当拒绝这段开头的这种恶意误导用户的情况,同源策略 + CORS 显然不能做到这一点

所以,仅有同源策略 + CORS 是不够的,还需要有 CSRF 防御。


针对 CSRF 这种攻击行为的防御方式有很多种,其实很常见的要求输入验证码等也能算作一种。Django 采用的策略如下所述:

  1. 默认对 HTTP GET、HEAD、OPTIONS 或 TRACE 请求不要求 CSRF 检查,对其他请求要求 CSRF 检查。可以通过 @csrf_exempt@csrf_protect 自定义这个白名单;
  2. 在登录成功的应答中,会有 Set-Cookie 报头,除了给会话的 sessionid 以外,还会给 csrftoken,如下图所示;
登录成功给 csrftoken
登录成功给 csrftoken

有了 Set-Cookie 报头,浏览器会自动把 csrftoken,以及 sessionid 存为 Cookie,在 Chrome 很容易就能看到,如下图所示。

Chrome 查看 Cookie
Chrome 查看 Cookie
  1. 在需要 CSRF 检查的请求,前端应当做以下几件事情:
    1. 在请求报头添加 csrftoken 的 Cookie;
    2. 在请求报头添加 X-CSRFToken 字段,其内容等同于 csrftoken Cookie;
    3. 对于 HTTPS 请求,还需要包含 Referer 报头,一般来说都是自带了的,等一会出问题的时候(我怎么这么惨,什么怪 bug 都能遇见),我们再讨论 Referer 和 Referrer Policy
HTTP 请求通过 CSRF 检查
HTTP 请求通过 CSRF 检查

对了对了,为什么 Cookie 还不够,还需要在报文添加 X-CSRFToken 字段呢?CSRF 开头举的例子说到,浏览器向 A 后端请求时会自动带上 A 的 Cookie,所以光有 csrftoken Cookie 是没有用的,B 前端请求依然可以带上 csrftoken Cookie。但是,B 前端读不了 A 后端为 A 前端设置的 Cookie(这一点似乎过于常识,以至于我在网上没找到介绍的资料)。没办法读 csrftoken,也就没有办法设置 X-CSRFToken 为正确的值了。


前端响应地要做如下改变:

  1. 设置 Cookie,对于 axios 添加 withCredentials: true 就可以了,和上面 CORS 是一样的;
  2. 在请求报头添加 X-CSRFToken 字段, axios 也有提供配置,但由于各后端的 Cookie 名和报头名不同,所以自己手动设定一下名字即可;
  3. Referer 报头一般不需要自己配置,默认即可(实际上,在特殊的情况下会出错,这个放到 Referer 和 Referrer Policy 部分讨论)。

修改的代码如下:

1
2
3
4
5
6
7
8
9
10
11
import axios from "axios";

const service = axios.create({
baseURL: 'https://uestcmsc-webapp.lyh543.cn/api',
timeout: 5000, // 请求的超时时间
withCredentials: true, // 允许携带 cookie: sessionid & csrftoken 做认证
xsrfCookieName: 'csrftoken', // 添加 CSRF token
xsrfHeaderName: 'X-CSRFToken'
});

export default service;

然后是后端的配置。如果没有跨域需求,配置比较简单,只需要把 csrf 加入 settings.py 就行了,这个其实在 django-admin startproject 时已经默认生成了:

1
2
3
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
]

对于有跨域需求的情况,我们还需要将前端域名加入 CSRF_TRUSTED_ORIGINS

1
2
# settings.py
CSRF_TRUSTED_ORIGINS = ['.uestc-msc.com']

还要把 X-CSRFToken 放入 CORS_ALLOW_HEADERS。这一点我们在配置 CORS 的时候就顺便做了。

SameSite

上面的一切看上去都是那么完美,对于同源的前后端就只有以上这么一点,但对于非同源的前后端,麻烦事才刚刚开始。


在本地开发环境下非常完美,但是一部署到生产环境就开始出锅,表现在登录成功后,任何 POST/PATCH 操作(修改信息、登出)均显示未登录。

检查一下 Cookie,发现什么都没有!

没有 Cookie
没有 Cookie

Set-Cookie 没有成功吗?查一下登录的应答,发现 Chrome 提示:

This Set-Cookie was blocked because it had the “SameSite=Lax” …

Set-Cookie 失败
Set-Cookie 失败

是这个 SameSite 的问题!


有关 SameSite 的知识可以参考 即将到来的Chrome新的Cookie策略 - 知乎SameSite cookies - MDN - Mozilla

所以,SameSite(同站)是和 Same Origin(同源)不同的概念。

同源:两个 Protocol、Port 和 Host 都相同的 URL 是同源的;
跨域:两个 Protocol、Port 和 Host 不都相同的 URL 是跨域的;
同站:两个 eTLD+1 相同的网站是同站的。同站设置的 Cookie 称为一方 Cookie;
跨站:两个 eTLD+1 不同的网站是跨站的。跨站设置的 Cookie 称为三方 Cookie。

eTLD(effective Top Level Domain) 指的是 .com .cn .xyz .com.cn 这类域名。而 eTLD+1 指的就是 baidu.com pconline.com.cn 这类域名。

缕清 SameSite 的概念以后,我们再来说 Set-Cookie 中 SameSite 的作用。

一个 Cookie 的 SameSite 属性决定了是否限制跨站请求携带这个 Cookie。SameSite 有三种取值:

  1. None,即不限制,所有跨站请求(前端和后端跨站的请求)都会携带这个 Cookie;
  2. Strict,这种情况下,所有跨站请求都不会携带这个 Cookie,只有同站请求可以携带;
  3. Lax,某些跨站请求(导航到目标网址的 GET 请求,包括链接,预加载请求和 GET 表单)可以携带,其他跨站请求不能携带。

需要注意一个细节是,如果要设置 SameSite=None,需要同时给 Cookie 加上 Secure 属性,否则 SameSite=None 失效。而 Secure 意味着后端必须启用了 https。

另外,SameSite 也是一个 CSRF 防御的方案。依旧是针对 CSRF 开头举的例子,如果 A 的认证 Cookie 设置了 SameSite 为 StrictLax,B 前端再向 A 后端发送请求时就不会携带这个 Cookie 了。

具体到代码上,在 settings.py 加上以下代码即可:

1
2
3
4
5
6
7
if not DEBUG:
CSRF_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SECURE = True
LANGUAGE_COOKIE_SAMESITE = 'None'
LANGUAGE_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True

如果你以为上面的配置就可以了,那你就大错特错了。

在进行了上面的配置以后,发现 Django 提示 CSRF cookie not set.。读取

CSRF 和 session

不会给 CSRFtoken

Referer 和 Referrer Policy

1
2
3
4
5
6
7
8
<!DOCTYPE html>
<html lang="zh">
<head>
<!-- ... -->
<meta name="referrer" content="strict-origin-when-cross-origin">
<!-- ... -->
</head>
</html>

上面似乎解决了所有问题,但是,还有一个问题,是越来越多的浏览器开始默认禁用第三方 Cookie 了,现在有 Safari、Firefox,以后估计还会有更多。从长久考虑,可能需要使用别的方式,或者,尽量使用同站吧。