写前后端的时候遇到了这个问题,花了三天时间解决,就还是简单地写一写。写到最后才发现,这里面涉及的知识量也太大了,也请各位读者耐心阅读。
# 前言
CORS 和 CSRF 这两个概念很容易混淆,即使我在这篇博客前也不是很清楚二者的区别,于是就再搜了一下,顺便把另外两个概念也聊一聊。
浏览器、前端和后端的关系大概是:前端 <->
浏览器 <->
后端。前端告诉浏览器它需要访问什么,浏览器就向后端发请求,然后把应答给前端。
而同源策略、CORS、CSRF、HttpOnly 和 SameSite 都在围绕一件事情:如何防止恶意前端误导用户和浏览器,在用户不知情的情况下以用户的身份恶意访问后端(删除数据、转账等等)。
# 同源策略
浏览器的同源策略 - Web 安全| MDN (opens new window)
同源:两个 Protocol、Port 和 Host 都相同的 URL 是同源的; 跨域:两个 Protocol、Port 和 Host 不都相同的 URL 是跨域的;
MDN 上举了几个同源和跨域的例子,这里不再赘述。
同源策略是一个比较笼统的概念,它是为了限制一个 origin 的文档或者它加载的脚本如何能与另一个源的资源进行交互。就是说,它限制 baidu.com
的脚本不能访问到 taobao.com
的资源。
现代浏览器同时采用了两种同源策略:
- DOM 同源策略:禁止对不同源页面 DOM 进行操作。这里主要场景是 iframe 跨域的情况,不同域名的 iframe 是限制互相访问的。
- XMLHttpRequest 同源策略:禁止使用 XHR 对象向不同源的服务器地址发起 HTTP 请求。
可见,同源策略就是浏览器用来防止恶意前端的策略。
不过请注意,浏览器并不限制 <img>
<tag>
<form>
等标签等进行跨域访问。
# Cookie 的源
Cookie 也有源的概念,但它的“源”和同源策略的“源”念有所区别。
Cookie 的 set:一个页面可以为本域和其父域设置 cookie,只要是父域不是公共后缀(public suffix)即可。
Cookie 的 get:浏览器都允许给定的域以及其任何子域名 (sub-domains) 访问 cookie。当你设置 cookie 时,你可以使用Domain
、Path
、Secure
、和 HttpOnly 标记来限定其可访问性。
理解起来有点绕,可以用一个例子来解释。假设前端域名为 app.example.com
,后端域名为 api.example.com
:
- 如果后端对发来的域名不加以设置,后端的 cookie 的 domain 就是默认的
api.example.com
。此时,前端并不能访问该 cookie,因为前端域名不是后端域名(或其子域名); - 正确的解决办法是,后端把 cookie 的 domain 设置为
example.com
(一个页面可以为父域设置 cookie)然后发给前端,前端也可以访问到这个 cookie(浏览器都允许example.com
的子域名 (sub-domains) 访问 这个 cookie)
上面的访问都进行了加粗,注意到这个访问的含义是:前端使用 document.cookie
可以看到这个 cookie。在实际操作中,可能你虽然看不到这个 cookie,但是发 XMLHttpRequest 时浏览器会提交这个 cookie,这不叫访问。
# CORS 跨域资源共享
跨源资源共享(CORS)- HTTP | MDN (opens new window)
同源策略屏蔽了所有的跨域访问。但是,并不是所有跨域访问都是恶意的,比如 tmall.com
前端想要向 taobao.com
后端发起 HTTP 请求这种情况,该怎么处理呢?
所以,CORS(Cross-Origin Resource Sharing,跨域资源共享)出现了。CORS 由一系列 HTTP 头组成,当前端想要跨域访问后端时,浏览器将会利用这些 HTTP 头与后端交互,让后端告诉浏览器决定是否阻止前端获取跨域请求的响应。
在 MDN (opens new window) 可以看到,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 的正式请求。在正式请求中,浏览器使用了 POST
和 content-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 (opens new window)
前端需要配置的只有这一个点。
前端的配置很简单,对于 axios 来说,就是加上一行 withCredentials: true
。
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
里添加:
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 后端的吗?但请注意以下两点:
- 同源策略不限制通过
<img>
<tag>
<form>
加载/上传跨域资源 - 一个公共的后端 API 应当允许所有人使用,但它应当恶意前端误导用户的情况(如 Google Mail 就曾因为没有配置 CSRF 导致恶意前端诱导用户发送邮件),同源策略 + CORS 显然不能做到这一点
所以,仅有同源策略 + CORS 是不够的,还需要有 CSRF 防御。
针对 CSRF 这种攻击行为的防御方式有很多种,其实很常见的要求输入验证码等也能算作一种。Django 采用的策略 (opens new window)如下所述:
- 默认对 HTTP GET、HEAD、OPTIONS 或 TRACE 这类安全请求不要求 CSRF 检查,对其他请求要求 CSRF 检查。可以通过
@csrf_exempt
和@csrf_protect
自定义这个白名单; - 在登录成功的应答中,会有
Set-Cookie
报头,除了给会话的sessionid
以外,还会给csrftoken
,如下图所示;
有了 Set-Cookie
报头,浏览器会自动把 csrftoken
,以及 sessionid
存为 Cookie,在 Chrome 很容易就能看到,如下图所示。
- 在需要 CSRF 检查的请求,前端应当做以下几件事情:
- 在请求报头添加
csrftoken
的 Cookie; - 在请求报头添加
X-CSRFToken
字段,其内容等同于csrftoken
Cookie(注:在实际操作中不一定要相等,只需要保证 cookie 和报头都是有效的 CSRF Token 即可); - 对于 HTTPS 请求,还需要包含
Referer
报头,一般来说都是自带了的,不会出问题。等实际出问题的时候(我怎么这么惨,什么 bug 都能遇见),我们再讨论 Referer 和 Referrer Policy。
- 在请求报头添加
对了对了,为什么 Cookie 还不够,还需要在报文添加 X-CSRFToken
字段呢?CSRF 开头举的例子说到,浏览器向 A 后端请求时会自动带上 A 的 Cookie,所以光有 csrftoken
Cookie 是没有用的,恶意的 B 前端来请求,也会带上 csrftoken
Cookie。但是,B 前端读不了 A 后端为 A 前端设置的 Cookie(见 Cookie 的源)。没办法读 csrftoken,也就没有办法设置 X-CSRFToken
为有效值了。
前端响应地要做如下改变:
- 设置 Cookie,对于 axios 添加
withCredentials: true
就可以了,和上面 CORS 是一样的; - 在请求报头添加
X-CSRFToken
字段, axios 也有提供配置,但由于各后端的 Cookie 名和报头名不同,所以自己手动设定一下名字即可; Referer
报头一般不需要自己配置,默认即可(实际上,在特殊的情况下会出错,这个放到 Referer 和 Referrer Policy 部分讨论)。
修改的代码如下:
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
时已经默认生成了:
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
]
对于有跨域需求的情况,我们还需要将前端域名加入 CSRF_TRUSTED_ORIGINS
:
# settings.py
CSRF_TRUSTED_ORIGINS = ['.uestc-msc.com']
如果前后端的跨域拥有相同的根域名(设为 .uestc-msc.com
),那么还可以设置 CSRF_COOKIE_DOMAIN
(还可以顺便把 SESSION_COOKIE_DOMAIN
也设置了):
# settings.py
CSRF_TRUSTED_ORIGINS = ['.uestc-msc.com']
if not DEBUG:
CSRF_COOKIE_DOMAIN = FRONTEND_TRUSTED_ORIGINS[0]
SESSION_COOKIE_DOMAIN = FRONTEND_TRUSTED_ORIGINS[0]
前后端不拥有相同根域名(如 a.com
和 b.com
)的情况,不建议这种情况直接进行跨域访问,原因见文末。
另外,后端还要把 X-CSRFToken
放入 CORS_ALLOW_HEADERS
。不过,这一点我们在配置 CORS 的时候就顺便做了。
# SameSite 的源
上面的一切看上去都是那么完美,对于同源的前后端就只有以上这么一点,但对于非同源的前后端,麻烦事才刚刚开始。
在本地开发环境下非常完美,但是一部署到生产环境就开始出锅,表现在登录成功后,任何 POST/PATCH 操作(修改信息、登出)均显示未登录。
检查一下 Cookie,发现什么都没有!
是 Set-Cookie
没有成功吗?查一下登录的应答,发现 Chrome 提示:
This Set-Cookie was blocked because it had the "SameSite=Lax" ...
是这个 SameSite 的问题!
有关 SameSite 的知识可以参考 即将到来的Chrome新的Cookie策略 - 知乎 (opens new window) 和 SameSite cookies - MDN - Mozilla (opens new window)。
所以,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 有三种取值:
None
,即不限制,所有跨站请求(前端和后端跨站的请求)都会携带这个 Cookie;Strict
,这种情况下,所有跨站请求都不会携带这个 Cookie,只有同站请求可以携带;Lax
,某些跨站请求(导航到目标网址的 GET 请求,包括链接,预加载请求和 GET 表单)可以携带,其他跨站请求不能携带。
需要注意一个细节是,如果要设置 SameSite=None
,需要同时给 Cookie 加上 Secure 属性,否则 SameSite=None 失效。而 Secure 意味着后端必须启用了 https。
另外,SameSite 也是一个 CSRF 防御的方案。依旧是针对 CSRF 开头举的例子,如果 A 的认证 Cookie 设置了 SameSite 为 Strict
或 Lax
,B 前端再向 A 后端发送请求时就不会携带这个 Cookie 了。
具体到代码上,在 Django settings.py
加上以下代码即可:
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
# CSRF 和跨站 Cookie
如果你以为上面的配置就可以了,那你就大错特错了。
在进行了上面的配置以后,发现 Django 提示 CSRF cookie not set.
。读取
# CSRF 和 session
不会给 CSRFtoken
# Referer 和 Referrer Policy
<!DOCTYPE html>
<html lang="zh">
<head>
<!-- ... -->
<meta name="referrer" content="strict-origin-when-cross-origin">
<!-- ... -->
</head>
</html>
# 后记:三方 Cookie 的去路
上面似乎解决了所有问题,但是,还有一个问题,是越来越多的浏览器开始默认禁用第三方 Cookie 了,现在有 Safari、Firefox,以后估计还会有更多。
禁用三方 Cookie,意味着你即使完成了上面所有配置,也不能将跨域后端的 Cookie 存到浏览器中,这套认证方法直接失效。毕竟,各种广告商也是通过三方 Cookie 定位用户然后精准投放广告的。
从长久考虑,可以采用其他方法替代:
- 使用同一个根域名(如使用
app.test.com
和api.test.com
)。这样虽然是同源策略下的跨域,但不是 Cookie 概念下的跨域。把后端的 Cookie 域设置为test.com
后,前端就可以直接读取、修改了; - 如果前端服务器支持反代,可以把后端 api 反向代理到
app.test.com/api/
下,这样就是完全同源了。后端服务器支持反代的话也是同理; - 不使用 Session 认证,因为 Session 是基于 Cookie 的;可以改为 Token(如 JSON Web Token)等认证方案,这样的认证方式就完全不需要 Cookie,可以回避跨域 Cookie 引发的一系列问题。