feat: add configurable transport-independent CORS policy

This commit is contained in:
2026-09-26 19:53:28 +08:00
parent 9fade3bedb
commit 800974cfff
33 changed files with 1645 additions and 23 deletions

187
app/cors_test.go Normal file
View 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)
}
}
})
}
}