【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() 函數功能,以及使用方式,可以閱讀這篇文章 [https://www.jianshu.com/p/8e0e427578f9]

完成命令行參數解析之後,下一步更新 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);
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-xDzeqs5Lc0Ip1cTmoAD1g