【Go Web开发】CORS请求

上一篇文章介绍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:9000http://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.comhttp://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);
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容