feat: add configurable transport-independent CORS policy
This commit is contained in:
187
app/cors_test.go
Normal file
187
app/cors_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func corsApp() *omni.App {
|
||||
return omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST", "PUT"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 999, MessageField: "msg", DataMode: "always"}))
|
||||
}
|
||||
func TestAppCORSRoutingAndErrors(t *testing.T) {
|
||||
a := corsApp()
|
||||
defer a.Close()
|
||||
var middlewareCalls int
|
||||
a.Use(func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { middlewareCalls++; return next(c) }
|
||||
})
|
||||
a.GET("/secure", func(c *omni.Context) error { return c.OK("private") }, func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { return omni.Unauthorized("LOGIN_REQUIRED", "login required") }
|
||||
})
|
||||
a.GET("/failure", func(c *omni.Context) error { return omni.Internal(errors.New("private detail")) })
|
||||
a.GET("/panic", func(c *omni.Context) error { panic("private panic") })
|
||||
a.Handle("OPTIONS", "/options", func(c *omni.Context) error { return c.Text(200, "ordinary options") })
|
||||
for name, entry := range map[string]http.Handler{"App": a, "Engine": a.Engine(), "NewServer": a.NewServer("").Handler} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{{"GET", "/secure", 401}, {"GET", "/failure", 500}, {"GET", "/panic", 500}, {"GET", "/absent", 404}, {"PUT", "/secure", 405}} {
|
||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
var body map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if err != nil || w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" || body["code"] != float64(999) || body["data"] != nil || body["msg"] == nil || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal(w.Code, w.Header(), w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/secure", "/absent"} {
|
||||
before := middlewareCalls
|
||||
r := httptest.NewRequest("OPTIONS", path, nil)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
if w.Code != 204 || w.Body.Len() != 0 || middlewareCalls != before {
|
||||
t.Fatal(w.Code, w.Body.String(), middlewareCalls, before)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("OPTIONS", "/options", nil)
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
if w.Code != 200 || w.Body.String() != "ordinary options" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCORSConfigIsolation(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.CORS = omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://one.example"}, AllowedHeaders: []string{"Authorization"}}
|
||||
option := omni.WithConfig(cfg)
|
||||
cfg.CORS.AllowedOrigins[0] = "https://evil.example"
|
||||
a := omni.New(option)
|
||||
defer a.Close()
|
||||
b := omni.New(option)
|
||||
defer b.Close()
|
||||
a.Config().CORS.AllowedOrigins[0] = "https://evil.example"
|
||||
for _, app := range []*omni.App{a, b} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/absent", nil)
|
||||
r.Header.Set("Origin", "https://one.example")
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "https://one.example" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("invalid CORS passed direct New")
|
||||
}
|
||||
}()
|
||||
omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true}))
|
||||
}
|
||||
|
||||
func TestCORSTransports(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
cfg := omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}
|
||||
a := omni.New(omni.WithCORS(cfg), omni.WithTransport(transport), omni.WithTLS(cert, key))
|
||||
var called atomic.Int32
|
||||
a.Use(func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { called.Add(1); return next(c) }
|
||||
})
|
||||
a.POST("/upload", func(c *omni.Context) error {
|
||||
b, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text(200, string(b))
|
||||
})
|
||||
a.GET("/download", func(c *omni.Context) error {
|
||||
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: io.NopCloser(strings.NewReader("file bytes")), Name: "example.txt"})
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, listener) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not close")
|
||||
listener.Close()
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
url := "https://" + listener.Addr().String()
|
||||
for _, tc := range []struct {
|
||||
method, path, body string
|
||||
status int
|
||||
}{{"OPTIONS", "/upload", "", 204}, {"POST", "/upload", "raw upload", 200}, {"GET", "/download", "file bytes", 200}, {"GET", "/missing", "", 404}} {
|
||||
var input io.Reader
|
||||
if tc.method == "POST" {
|
||||
input = strings.NewReader(tc.body)
|
||||
}
|
||||
r, _ := http.NewRequest(tc.method, url+tc.path, input)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
if tc.method == "OPTIONS" {
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||
}
|
||||
res, err := client.Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") != "https://web.example" {
|
||||
t.Fatal(res.StatusCode, res.Header)
|
||||
}
|
||||
if tc.status == 200 && string(body) != tc.body {
|
||||
t.Fatal(string(body))
|
||||
}
|
||||
if tc.status == 204 && (len(body) != 0 || called.Load() != 0) {
|
||||
t.Fatal("preflight reached middleware")
|
||||
}
|
||||
if tc.path == "/download" && (!strings.Contains(res.Header.Get("Content-Disposition"), "example.txt") || res.Header.Get("Access-Control-Expose-Headers") != "Content-Disposition") {
|
||||
t.Fatal(res.Header)
|
||||
}
|
||||
if transport == omni.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||
t.Fatal(res.Proto)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user