在接下来的文章中,我们将讨论一个全新的主题,并更新我们的应用程序,使其支持来自JavaScript的跨域请求(CORS: Cross-origin resource sharing))。
你将学习到:
- 什么是跨域请求,为什么浏览器默认阻止跨域请求。
- 普通请求和跨域请求之间的区别。
- 如何使用Access-Control请求头来允许或拒绝特定的跨域请求。
- 考虑到安全因素,你需要注意什么时候配置你的应用程序可以跨域请求。
CORS概述
在深入代码前,或者开始具体讨论跨域请求之前,让我们先来定义一下术语origin的含义。
本质上,如果两个url具有相同的schema、主机和端口(如果指定了),则称它们具备相同的域。为了说明这一点,让我们比较以下url:
URL A | URL B | 同域? | 原因 |
---|---|---|---|
https://foo.com/a | http://foo.com/a | NO | schema不同(http vs https) |
https://foo.com/a | http://www.foo.com/a | NO | host不同(foo.com vs www.foo.com) |
https://foo.com/a | http://foo.com:443/a | NO | 端口不同(默认端口 vs 443) |
https://foo.com/a | http://foo.com/b | NO | url路径不同 |
https://foo.com/a | http://foo.com/a?b=c | NO | 查询字符串不同 |
https://foo.com/a#b | http://foo.com/a#c | NO | 路径不通 |
https://foo.com/a#b | http://foo.com/a | NO | schema不同(http vs https) |
理解什么是origin是很重要的,因为所有的web浏览器都实现了一种被称为同域策略的安全机制。在浏览器实现这个策略的方式上有一些非常小的区别,但广义上来说:
- 一个域的网页可以在其HTML中嵌入来自另一个域的特定类型的资源——包括图像、CSS和JavaScript文件。例如,在你的网页中这样做是可以的:
<img src="http://anotherorigin.com/example.png" alt="example image">
- 一个域上的网页可以向另一个域发送数据。例如,网页中的HTML表单可以向不同的域提交数据。
- 但是一个域上的网页不允许接收来自不同域的数据。
这里的关键是最后一个要点:同域策略不允许(潜在恶意)其他域网站从本域读取数据。
必须强调的是,同域策略不会阻止数据的跨域发送,尽管这很危险。事实上,这就是为什么CSRF攻击可能发生,以及为什么我们需要采取额外的步骤来防止它们——比如使用samsite cookie和CSRF令牌。
作为一名开发人员,您最可能遇到同域策略是在浏览器中运行JavaScript的跨域请求时。例如:假设你有一个http://foo.com网页包含一些前端JavaScript代码。如果这个JavaScript试图发起https://bar.com/data.json请求(不同的域),然后请求被发送到bar.com服务端处理,但用户的浏览器将阻止接收响应,以至于https://foo.com这边的JavaScript代码接收不到返回数据。
一般来说,同域策略是一种非常有用的安全保护措施。虽然它在一般情况下是好的,但在某些情况下,你可能想要将这种约束放开。例如,你有一个API服务域名为api.example.com和一个JavaScript前端应用运行在www.example.com,那么你可能想要允许www.example.com前端应用跨域访问API服务。
或者你有一个完全开放的公共API服务,你想允许来自任何地方的跨域请求,这样其他开发人员就可以很容易地与他们自己的网站集成。
幸运的是,大多数现代web浏览器允许你通过设置API响应的访问控制头来允许或禁止特定的跨域请求。在接下来的几节中,我们将详细解释如何做到这一点以及这些响应头是如何工作的。
演示同源(Same-Origin)策略
为了演示同源策略是如何工作的,以及如何在对API的请求中放开同源策略,我们需要模拟一个来自不同源的对API的请求。
可以快速地创建一个简单的Go应用程序来模拟这个跨域请求。从本质上说,我们希望第二个应用程序包含一些JavaScript的网页,然后向我们的 GET / v1 / healthcheck接口发起请求。
如果你跟随本系列文章的操作,创建文件cmd/example/cors/simple/main.go来写我们的第二个应用程序。
$ mkdir -p cmd/example/cors/simple
$ touch cmd/example/cors/simple/main.go
添加以下代码:
File:cmd/example/cors/simple/main.go
package main
import (
"flag"
"log"
"net/http"
)
//定义HTML网页字符串常量。
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Simple CORS</h1>
<div id="output"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch("http://localhost:4000/v1/healthcheck").then(
function (response) {
response.text().then(function (text) {
document.getElementById("output").innerHTML = text;
});
},
function(err) {
document.getElementById("output").innerHTML = err;
}
);
});
</script>
</body>
</html>`
func main() {
//允许服务地址在运行时可根据命令行参数配置
addr := flag.String("addr", ":9000", "Server address")
flag.Parse()
log.Printf("starting server on %s", *addr)
//启动HTTP服务,并监听给定的地址。并对上面的HTML中所有请求进行应答。
err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html))
}))
log.Fatal(err)
}
这里Go代码我们应该很熟悉了,下面一起来看看<script>标签中的JavaScript代码:
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch("http://localhost:4000/v1/healthcheck").then(
function (response) {
response.text().then(function (text) {
document.getElementById("output").innerHTML = text;
});
},
function(err) {
document.getElementById("output").innerHTML = err;
}
);
});
</script>
在这个代码中:
- 我们使用fetch()函数向API服务的健康检查接口发起请求。默认发送的是GET请求,但是也可以配置不同的HTTP方法,并添加自定义请求头。稍后我们再介绍如何实现。
- fetch()方法是异步工作的,并返回响应。然后使用then()方法在响应中设置回调函数:如果接口调用成功就执行第一个回调函数,否则就执行第二个回调。
- 在“成功”回调中使用response。text()读取响应body,然后使用document.getElementById("output").innerHTML将响应body替换<div id="output"><div>元素。
- 在“失败”回调中将返回的错误消息替换<div id="output"><div>元素。
- 整个逻辑放在document.addEventListener(''DOMContentLoaded', function(){...}),表示fetch()函数只有在用户浏览器把HTML内容加载完之后才会执行。
提示:这里不是关于JavaScript的,不用担心其中的细节。你需要知道的就是JavaScript向API服务的健康检查接口发起请求,然后将响应内容填入到<div id="output"><div>元素中。
演示
下面开始测试下,请启动第二个应用程序:
$ go run ./cmd/example/cors/simple
2022/01/09 09:57:20 starting server on :9000
然后打开一个新的终端,把我们的API应用服务启动:
$ go run ./cmd/api
{"level":"INFO","time":"2022-01-09T01:58:42Z","message":"database connection pool established"}
{"level":"INFO","time":"2022-01-09T01:58:42Z","message":"starting server","properties":{"addr":":4000","env":"development"}}
此时你的API服务启动的域为http://localhost:4000,JavaScript所在网页运行在域为:http://localhost:9000。因为端口不一样,它们处在不同域。因此,浏览器访问http://localhost:9000时,fetch()向http://localhost:4000/v1/healthcheck发起请求时会被同域策略阻止。具体来说,API服务会接收和处理请求的,但是浏览器会阻塞以至于JavaScript无法读取到响应。
让我们来测试下。打开浏览器然后访问http://localhost:9000,你会看到CORS报头后面跟着类似这样的错误消息:
提示:这里的错误消息是浏览器定义的,我用的是chrome浏览器,因此如果你使用其他浏览器可能错误不太一样。
这里你可以打开浏览器开发者工具,并刷新页面,然后看看控制台日志。你应该能看到一条消息表示GET /v1/healthcheck接口的响应因同域策略被阻止。如下所示:
Access to fetch at 'http://localhost:4000/v1/healthcheck' from origin 'http://localhost:9000' has been blocked by CORS policy
您可能还希望打开开发者工具中的网络活动选项,并检查与被阻止的请求相关的HTTP头。
这里有些重要的信息需要指出。首先请求的url说明是把请求发送到我们的API服务的,并且API服务处理完请求后将200 OK返回给浏览器。请求本身并没有被同域策略阻止,而是浏览器没有将响应内容传给JavaScript。
第二个需要指出的是:web浏览器自动设置请求的Origin头,以显示请求的来源,如下所示:
Origin: http://localhost:9000
我们将在下一节中使用这个头信息来帮助我们有选择地放开同域策略,这取决于我们是否信任请求的来源。最后需要强调的是同域策略只在浏览器中使用。在浏览器之外,任何应用都可以向API服务发起请求,使用curl、wget等工具都可以读取到返回内容。这完全不受同源策略的影响。