fetch 引发 blocked by CORS policy

HTTP 2020-02-04 6327 字 1200 浏览 点赞

起步

当使用 fetch 函数做跨域请求时,大概率会在浏览器 Console 中看到这样一个错误信息:Access to fetch at 'xxx' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

会出现上述错误是因为你在做跨域请求,这是一项浏览器认为不安全的操作。那么如何鉴定是不是在跨域呢?要看 url 是否同源,同源的标准是:协议,域名,端口均相同。详情可参看 浏览器的同源策略

除大公司外,我斗胆猜测一下你之所以需要 CORS (跨域资源共享),可能是以下原因之一:1. 你在写 demo; 2. 你处于前后端分离开发。

接下来我会后端采用 flask 讲述如何解决 CORS 被禁问题。选择 flask 是因为该框架在处理 Content-Type 为 text/plainapplication/json 时存在明显区别,有助实验观察。如果你使用的是其他语言,或者其他框架,都没关系,原理是相通的。

从一个 demo 入手

假设当前我知道了 fetch 函数如何发起 post 请求,但我想测试一下,验证我知道的对不对。于是我写了下面这段 js 代码:

fetch("http://127.0.0.1:8080/login", {
  method: "POST",
  body: JSON.stringify({
    username: "zhong",
    password: "zzZhong",
  }),
})
.then(resp => resp.json())
.then(data => console.log(data))

这段 js 的含义是,向 http://127.0.0.1:8080/login 发起 post 请求,并等待后端响应,最后将后端响应的数据打印到 Console 中。

毕竟我只是想简单测试一下,偷懒起见,把 js 代码嵌入 html 文件中,并且在浏览器里以绝对路径的方式直接访问 html 文件。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
</body>
<script>

// js 代码

</script>
</html>

Alt text

后端暂时啥也别做,只要一接收到前端的请求,立马返回:{"hello": "world"}。后端代码如下所示:

from flask import Flask

app = Flask(__name__)

@app.route("/login", methods=["POST"])
def index():
    return {"hello": "world"}

def main():
    app.debug = True
    app.run(host="0.0.0.0", port=8080)

if __name__ == "__main__":
    main()

照理说,只要我浏览器一回车,Console 中就会打印出 {"hello": "world"}。但事实并非如此。这一回车,Console 中就出现篇头提到的那个错误,说明你在跨域访问了。可是为甚么会这样呢?打开浏览器的 Network 探个明白。

Alt text

当我在浏览器中输入文件的绝对路径后回车,浏览器使用的 file 协议访问 html 文件。而 html 文件中的 js 代码 (fetch) 发起的是 http 请求,即使用的是 http 协议。二者协议不同,所以非同源,浏览器出于安全考虑把请求禁了。

其实解决方法很简单,只要在后端的响应头中加上 Access-Control-Allow-Origin: * 即可。现在用 Python 把这个需求翻译一下:

@app.route("/login", methods=["POST"])
def index():
    resp = Response()
    # 添加响应头: Access-Control-Allow-Origin: *
    resp.headers["Access-Control-Allow-Origin"] = "*"
    # 字段对象转 json 格式的字符串
    resp.data = json.dumps({"hello": "world"})
    return resp

此时在浏览器中刷新页面,可以看到 Console 不会报错,并且打印了后端返回的数据。查看后端返回的响应头,可以看到我们确实把 Access-Control-Allow-Origin 加进去了。
Alt text

后端如何解析参数

我们要做登陆功能,就需要解析前端提交的数据:用户名密码。对 flask 框架来说,request 对象有一个 json 属性,而前端传递的数据是 json 字符串……感觉可以哟!

from flask import Flask, request, Response

...

@app.route("/login", methods=["POST"])
def index():
    resp = Response()
    resp.headers["Access-Control-Allow-Origin"] = "*"
    print(request.json)  # 注意打印内容
    return resp
...

很遗憾,程序跑起来终端打印 None。当 flask 框架不能解析到 json 数据时,request.json 就会为 None。

再回到 Network,观察 fetch 发起请求的请求头。其中 Content-Type 为 text/plain,也就是说,数据是以文本格式 (text/plain) 提交的,不是 json 格式,所以 flask 框架没有解析到。post 提交数据格式可见 四种常见的 POST 提交数据方式
Alt text

对 flask 来说,当提交数据格式为 text/plain 时,后端正确解析数据方式:

@app.route("/login", methods=["POST"])
def index():    
    resp = Response()
    resp.headers["Access-Control-Allow-Origin"] = "*"
    # 读取 http body
    content = request.input_stream.read(request.content_length)
    # byte -> utf-8
    resp.data = content.decode("utf-8")
    return resp

此时刷新浏览器,Console 里就能看到 {username: "zhong", password: "zzZhong"},说明我们成功拿到了前端提交的数据。

流行的 json 风格

当前互联网流行传递 json 风格数据,fetch 也允许你这样做,只要你在请求头中加上 Content-Type: application/json 就好。js 代码修改如下:

fetch("http://127.0.0.1:8080/login", {
  method: "POST",
  body: JSON.stringify({
    username: "zhong",
    password: "zzZhong",
  }),
  // 添加请求头:application/json
  headers: {
    "Content-Type": "application/json"
  }
})
.then(resp => resp.json())
.then(data => console.log(data))

感觉上 request.json 不会是 None 了,那我们改后端代码试一试。

@app.route("/login", methods=["POST"])
def index():
    resp = Response()
    resp.headers["Access-Control-Allow-Origin"] = "*"
    # dict -> json string
    resp.data = json.dumps(request.json)
    return resp

浏览器页面刷起来,结果你会发现禁止 CORS 又出现了。其实这是浏览器的 preflight request 机制引起的。浏览器会自主发起一个预请求 (Content-Type 为 text/plain 则没有),请求方式为 OPTIONS。浏览器倒没别的意思,它就是想问问服务器:你允许我跨域请求吗?服务器要是允许了,浏览器才会发起 js 代码中的 post 请求。

遇到这种情况禁止 CORS 是令人头疼的,因为浏览器发起的这个 options 请求没有出现在 Network 中,而后端代码又不允许该路由接收 OPTIONS 请求。“好事“都赶上了,所以一头雾水。

正确后端代码如下所示,刷新页面也不会看到报错信息了。

# 允许本路由被 POST, OPTIONS 请求
@app.route("/login", methods=["POST", "OPTIONS"])
def index():
    resp = Response()

    # 处理 post 请求
    if request.method == "POST":
        resp.headers["Access-Control-Allow-Origin"] = "*"
        resp.data = json.dumps(request.json)
    # 处理 options 请求
    elif request.method == "OPTIONS":
        # 设置响应头
        resp.headers["Access-Control-Allow-Origin"] = "*"
        resp.headers["Access-Control-Allow-Headers"] = "*"
    
    return resp

服务器回复浏览器要用“暗号”,暗号就摆在响应头里。

Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: *
  • 第一行表示允许任何域跨于请求。
  • 第二行表示允许跨于请求的请求头带上任何字段。

其实这样的允许尺度比较宽松,可能会为生产环境带来安全风险。如何最恰当配置响应头可先阅读 HTTP 响应首部字段 准确理解每个字段的含义,再着手设计。

参考



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论