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

# 前言

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 的资源。

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

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

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

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

Cookie 也有源的概念,但它的“源”和同源策略的“源”念有所区别。

MDN 文档 (opens new window)

Cookie 的 set:一个页面可以为本域和其父域设置 cookie,只要是父域不是公共后缀(public suffix)即可。
Cookie 的 get:浏览器都允许给定的域以及其任何子域名 (sub-domains) 访问 cookie。当你设置 cookie 时,你可以使用 DomainPathSecure、和 HttpOnly 标记来限定其可访问性。

理解起来有点绕,可以用一个例子来解释。假设前端域名为 app.example.com,后端域名为 api.example.com

  1. 如果后端对发来的域名不加以设置,后端的 cookie 的 domain 就是默认的 api.example.com。此时,前端并不能访问该 cookie,因为前端域名不是后端域名(或其子域名);
  2. 正确的解决办法是,后端把 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 的正式请求。在正式请求中,浏览器使用了 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 (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 后端的吗?但请注意以下两点:

  1. 同源策略不限制通过 <img> <tag> <form> 加载/上传跨域资源
  2. 一个公共的后端 API 应当允许所有人使用,但它应当恶意前端误导用户的情况(如 Google Mail 就曾因为没有配置 CSRF 导致恶意前端诱导用户发送邮件),同源策略 + CORS 显然不能做到这一点

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


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

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

登录成功给 csrftoken

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

Chrome 查看 Cookie

  1. 在需要 CSRF 检查的请求,前端应当做以下几件事情:
    1. 在请求报头添加 csrftoken 的 Cookie;
    2. 在请求报头添加 X-CSRFToken 字段,其内容等同于 csrftoken Cookie(注:在实际操作中不一定要相等,只需要保证 cookie 和报头都是有效的 CSRF Token 即可);
    3. 对于 HTTPS 请求,还需要包含 Referer 报头,一般来说都是自带了的,不会出问题。等实际出问题的时候(我怎么这么惨,什么 bug 都能遇见),我们再讨论 Referer 和 Referrer Policy

HTTP 请求通过 CSRF 检查

对了对了,为什么 Cookie 还不够,还需要在报文添加 X-CSRFToken 字段呢?CSRF 开头举的例子说到,浏览器向 A 后端请求时会自动带上 A 的 Cookie,所以光有 csrftoken Cookie 是没有用的,恶意的 B 前端来请求,也会带上 csrftoken Cookie。但是,B 前端读不了 A 后端为 A 前端设置的 Cookie(见 Cookie 的源)。没办法读 csrftoken,也就没有办法设置 X-CSRFToken 为有效值了。


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

  1. 设置 Cookie,对于 axios 添加 withCredentials: true 就可以了,和上面 CORS 是一样的;
  2. 在请求报头添加 X-CSRFToken 字段, axios 也有提供配置,但由于各后端的 Cookie 名和报头名不同,所以自己手动设定一下名字即可;
  3. 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.comb.com)的情况,不建议这种情况直接进行跨域访问,原因见文末


另外,后端还要把 X-CSRFToken 放入 CORS_ALLOW_HEADERS。不过,这一点我们在配置 CORS 的时候就顺便做了。

# SameSite 的源

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


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

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

没有 Cookie

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

This Set-Cookie was blocked because it had the "SameSite=Lax" ...

Set-Cookie 失败

是这个 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 有三种取值:

  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 了。

具体到代码上,在 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

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

在进行了上面的配置以后,发现 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 了,现在有 Safari、Firefox,以后估计还会有更多。

禁用三方 Cookie,意味着你即使完成了上面所有配置,也不能将跨域后端的 Cookie 存到浏览器中,这套认证方法直接失效。毕竟,各种广告商也是通过三方 Cookie 定位用户然后精准投放广告的。

从长久考虑,可以采用其他方法替代:

  1. 使用同一个根域名(如使用 app.test.comapi.test.com)。这样虽然是同源策略下的跨域,但不是 Cookie 概念下的跨域。把后端的 Cookie 域设置为 test.com 后,前端就可以直接读取、修改了;
  2. 如果前端服务器支持反代,可以把后端 api 反向代理到 app.test.com/api/ 下,这样就是完全同源了。后端服务器支持反代的话也是同理;
  3. 不使用 Session 认证,因为 Session 是基于 Cookie 的;可以改为 Token(如 JSON Web Token)等认证方案,这样的认证方式就完全不需要 Cookie,可以回避跨域 Cookie 引发的一系列问题。