上一篇文章介绍CORS原理,现在让我们对API服务做一些修改,放开同域策略,这样JavaScript就可以从API接口读取响应了。首先,最简单的实现方法是在所有API响应中设置以下header:
Access-Control-Allow-Origin: *
Access-Control-Allow-Origin响应头用于指示浏览器可以与不同的域共享返回数据。在本例中,header值是通配符*,这意味着可以与任何其他域共享响应。
我们在API服务中新增加一个enableCORS()中间件函数,在响应头中添加允许跨域通配符:
package main
...
func (app *application)enableCORS(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
next.ServeHTTP(w, r)
})
}
然后更新cmd/api/routes.go文件,将跨域中间件应用在所有的API接口中,如下所示:
package main
...
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler))
router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler))
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler))
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler))
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler))
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
//添加enableCORS()中间件
return app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router))))
}
这里需要指出的是,enableCORS()中间件故意放在中间件链的前面。如果我们放在限流后面,例如任何跨域请求达到限流条件时将无法设置Access-Control-Allow-Origin响应头。这种情况的请求的返回内容就会被浏览器阻止,而无法收到429 Too Many Request的请求响应。
好了,我们来测试下跨域请求。重启API服务然后在浏览器中访问http://localhost:9000。这次跨域请求正常处理,你可以看到API返回的JSON响应显示在浏览器中,如下所示:
我建议你看下JavaScript在调用fetch()时候的请求和响应头。你应该会看到前面设置的Access-Control-Allow-Origin: * 响应头信息,如下所示:
限制跨域
使用通配符来允许跨域请求,就像我们在上面的代码中所做的那样,在某些情况下(比如当你有一个完全公共的、没有访问控制检查的API服务时)可能很有用。但更常见的情况是,您可能希望将CORS限制在一个小范围可信域集合内。
为了实现这个跨域限制,需要明确地将可信域添加到Access-Control-Allow-Origin头信息中,而不是直接使用通配符。例如,你想允许域为https://www.example.com跨域请求,可以发送以下响应头:
Access-Control-Allow-Origin: https://www.example.com
如果你只有一个固定域需要设置允许跨域,这么做很简单,只需要更新enableCORS()中间件,然后硬编码写入必要的域值。但如果要支持多个可信域,想要在服务运行时对跨域可配置的话,处理起来会有一点复杂。
第一个问题就是,在实现时,只能指定一个可信域到Access-Control-Allow-Origin头信息中。不能和你想象的那样添加多个可信域值并用空格或逗号隔开。
要解决这个限制,你需要更新enableCORS()中间件检查Origin的值是否和可信域集合中的一个匹配。如果匹配,可以将对应值写回到Access-Control-Allow-Origin响应头。
提示:web origin规范确实允许在Access-Control-Allow-Origin报头中使用多个空格分隔,但不幸的是,没有web浏览器真正支持这一点。
支持多个动态域
下面更新我们的API服务,让跨域只支持一个可信域列表,在运行时可配置。
首先为应用程序添加-cors-trusted-origins命令行参数,可以在运行时指定一个可信域列表。我们将这样设置,以便url值必须由空格字符分隔-像这样:
$ go run ./cmd/api -cors-trusted-origins="https://www.example.com https://staging.example.com"
为了解析这个命令行参数,我们使用Go 1.16 flag.Func()和strings.Fields()函数来将传入的字符串分割成字符串列表[]string。
如果你跟随本系列文章操作,打开cmd/api/main.go文件添加以下代码:
File:cmd/api/main.go
package main
...
type config struct {
port int
env string
db struct {
dsn string
maxOpenConns int
maxIdleConns int
maxIdleTime string
}
limiter struct {
rps float64
burst int
enable bool
}
smtp struct{
host string
port int
username string
password string
sender string
}
//添加cors结构体和trustedOrigins字段类型为[]string
cors struct{
trustedOrigins []string
}
}
// 更新application,添加WaitGroup类型字段。WaitGroup无需初始化,因为其零值就包含一个计算器为0有效值。
type application struct {
config config
logger *jsonlog.Logger
models data.Models
mailer mailer.Mailer
wg sync.WaitGroup
}
func main() {
// 声明一个配置结构体实例
var cfg config
// 从命令行参数中将port和env读取到配制结构体实例当中。
//默认端口使用4000以及环境信息使用开发环境development
flag.IntVar(&cfg.port, "port", 4000, "API server port")
flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
flag.StringVar(&cfg.db.dsn, "db-dsn", "postgres://greenlight:pa55word@localhost/greenlight?sslmode=disable", "PostgreSQL DSN")
flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time")
//限流参数
flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum request per secod")
flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")
flag.BoolVar(&cfg.limiter.enable, "limiter-enable", true, "enable")
//读取SMTP服务器配置参数
flag.StringVar(&cfg.smtp.host, "smtp-host", "smtp.mailtrap.io", "SMTP host")
flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
flag.StringVar(&cfg.smtp.username, "smtp-username", "a2441ed093524a", "SMTP username")
flag.StringVar(&cfg.smtp.password, "smtp-password", "02b2620c11a7f4", "SMTP password")
flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.net>", "SMTP sender")
//使用flag.Func()函数处理-cors-trusted-origin命令行参数。这里使用strings.Fields()函数
//将出入的值根据空格分割为切片,并赋值config结构体。注意如果参数没有传,strings.Fields()返回空切片
flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(s string) error {
cfg.cors.trustedOrigins = strings.Fields(s)
return nil
})
flag.Parse()
...
提示:如果你想了解更多关于flag.Func()函数功能,以及使用方式,可以阅读这篇文章
完成命令行参数解析之后,下一步更新enableCORS()中间件。具体来说,我们希望中间件检查请求Origin头信息中的值,与命令行参数传入的可信域值列表是否有匹配值,注意大小写是敏感的。如果有匹配的,我们应该设置Access-Control-Allow-Origin响应头的值为请求的Origin对应值。
否则,我们对请求按原本方式处理不对Access-Control-Allow-Origin响应头做任何处理。反过来,这意味着任何跨域的响应将被浏览器阻止,就像他们最初一样。
这样做的一个副作用是响应将根据请求的来源不同而不同。具体来说,响应中的Access-Control-Allow-Origin头信息可能不同,甚至不存在。因此,我们要确保设置响应头信息Vary: Origin提醒客户端响应缓存可能不同。这么做很重要,如果没有设置的话可能会引起小的类似这种bugs。根据经验:
如果代码根据请求头内容来决定返回的话,你需要添加名为Vary响应头,即使请求没有对应的头信息
好了,下面按照前面的逻辑来更新enableCORS()中间件:
File: cmd/api/middleware.go
package main
...
func (app *application)enableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//添加"Vary: Origin"头信息
w.Header().Set("Vary", "Origin")
//获取请求中的Origin头信息
origin := r.Header.Get("Origin")
//只有请求头信息Origin不为空以及可信域配置有效值才执行以下逻辑
if origin != "" && len(app.config.cors.trustedOrigins) !=0 {
//遍历所有可信域值,检查是否请求域可匹配
for i := range app.config.cors.trustedOrigins {
if origin == app.config.cors.trustedOrigins[i] {
//如果匹配,就设置"Access-Control-Allow-Origin"响应信息
w.Header().Set("Access-Control-Allow-Origin", origin)
}
}
}
next.ServeHTTP(w, r)
})
}
完成上面的代码之后,可以来测下功能。先重启API,传入http://localhost:9000和http://localhost:9001可信域值如下所示:
$ go run ./cmd/api -cors-trusted-origins="http://localhost:9000 http://localhost:9001"
{"level":"INFO","time":"2022-01-09T05:52:42Z","message":"database connection pool established"}
{"level":"INFO","time":"2022-01-09T05:52:42Z","message":"starting server","properties":{"addr":":4000","env":"development"}}
当你在浏览器中访问http://localhost:9000,发现跨域请求还是正常处理。
如果你现在将第二个应用程序(cmd/example/cors/simple)运行地址改为:9001,你会发现跨域请求还是正常工作的。但是,如果将地址改为:9002的话就会失败。如下所示:
$ go run ./cmd/example/cors/simple --addr=":9002"
2022/01/09 13:57:09 starting server on :9002
现在应用程序的域为:http://localhost:9002,并不是我们API程序中传入的可信域之一,因此在浏览器中访问http://localhost:9002会发现跨域请求失败。
附加内容
部分域匹配
如果你想支持很多可信域,你可能会想要检查域的部分匹配,看看它是否以特定值开始或结束,或者匹配正则表达式。如果您这样做,必须非常小心,以避免任何无意的匹配。举一个简单例子,如果http://example.com和http://www.example.com都是可信域,你的第一想法可能是检查Origin的值是否以example.com结尾。这不是一个好主意,攻击者可能注册域名为attackerexample.com,从这个域发出的请求也能通过匹配。
这只是一个例子,下面的博客中讨论了使用部分匹配或正则表达式检查时可能出现的一些其他漏洞:
一般来说,最好是根据一个显式的完整的可信域安全列表来检查Origin请求头,就像我们在本章中所做的那样。
null域
安全可信域列表不要包含值为“null“,因为攻击者可以通过sandboxed ifram发送伪造请求头Origin: null。
认证和CORS
如果API接口需要凭证(cookies或HTTP 基础认证),你需要设置在响应中设置Access-Control-Allow-Credentials: true头信息。如果不设置这个响应头,浏览器将阻止任何需要认证跨域响应被JavaScript读取。
重要的是,你绝对不能使用通配符Access-Control-Allow-Origin: *响应头和Access-Control-Allow- Credentials: true,这将允许任何网站向你的API发有证书的跨域请求。
同样重要的是,如果你要跨域发送带证书的请求,你需要在JavaScript中指定。例如,使用fetch()函数需要设置请求的credentials值为'include',如下所示:
fetch("https://api.example.com", {credentials: 'include'}).then(...);
如果使用XMLHTTPRequest,应该设置withCredentials属性为ture。例如:
var xhr = new XMLHttpRequest()
xhr.open('GET', 'http://api.example');
xhr.withCredentials = true
xhr.send(null);