From a4993502bc4b22dbff2153492c105ea5f8194bbc Mon Sep 17 00:00:00 2001 From: chiyi Date: Wed, 9 Sep 2026 11:31:22 +0800 Subject: [PATCH] v1.3.5: add CORS whitelist support (f.CORS) covering /cell and all custom routes --- README.md | 1 + cors.go | 71 ++++++++++++++++++++++++ cors_test.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++ docs/README.zh.md | 29 +++++++--- fun.go | 2 + handle.go | 5 ++ 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 cors.go create mode 100644 cors_test.go diff --git a/README.md b/README.md index faff346..fd531f4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。 - **NDJSON 流式**:`*fun.Stream` 逐行推送,支持首条消息 + 后续流 - **自定义路由**(v1.3.0+):`BindRoute` 注册 GET/POST 回调、健康检查、通配符路径 - **请求体上限控制**(v1.3.3+):`SetBodyLimit` 支持大体积 multipart 上传 +- **CORS 跨域**(v1.3.5+):`f.CORS(origins ...string)` 白名单按需放行,预检 204 直答,覆盖 `/cell` 与全部自定义路由 - **TypeScript 客户端生成**:`BindServiceForGen` + `GenCode(fun.GenTs{})`,免基础设施即可生成,产物带 `result` 归一化错误与拦截器 ## 快速开始 diff --git a/cors.go b/cors.go new file mode 100644 index 0000000..c6fa607 --- /dev/null +++ b/cors.go @@ -0,0 +1,71 @@ +package fun + +import ( + "strings" + + "github.com/valyala/fasthttp" +) + +const ( + corsMaxAge = "86400" // 预检结果缓存 24h,减少浏览器 OPTIONS 探测 +) + +// CORS 配置跨域来源白名单,须在 Start 前调用;作用于全部端点(/cell、自定义路由与通配路由)。 +// 按需放行:仅列出的来源会被放行——请求 Origin 命中白名单时回显该来源并允许 +// 携带凭据(Cookie),未命中则不附加 CORS 头(浏览器侧自然拦截)。 +// +// f.CORS("https://a.com", "https://b.com") +// +// 预检请求(OPTIONS 且带 Access-Control-Request-Method 头)直接以 204 应答, +// 不进入路由与 /cell 处理。 +func (f *Fun) CORS(origins ...string) { + f.mustNotStarted("CORS") + whitelist := make(map[string]struct{}, len(origins)) + for _, o := range origins { + if o != "" { + whitelist[strings.ToLower(o)] = struct{}{} + } + } + f.corsOrigins = whitelist +} + +// corsEnabled CORS 是否已配置 +func (f *Fun) corsEnabled() bool { + return len(f.corsOrigins) > 0 +} + +// handleCors 处理 CORS:Origin 命中白名单的请求附加跨域响应头, +// 预检请求(OPTIONS + Access-Control-Request-Method)在此直接应答并返回 true。 +func (f *Fun) handleCors(fastCtx *fasthttp.RequestCtx) bool { + if !f.corsEnabled() { + return false + } + origin := string(fastCtx.Request.Header.Peek("Origin")) + if origin == "" { + return false // 同源请求与服务器到服务器调用,无跨域语义 + } + if _, ok := f.corsOrigins[strings.ToLower(origin)]; !ok { + return false // 未命中白名单:不加 CORS 头,交给浏览器拦截 + } + + h := &fastCtx.Response.Header + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + // 放行结果随 Origin 变化,防中间层缓存错配 + h.Add("Vary", "Origin") + + if !fastCtx.IsOptions() || fastCtx.Request.Header.Peek("Access-Control-Request-Method") == nil { + return false // 实际请求:头已附加,继续正常路由 + } + + // 预检请求:回显浏览器声明的目标方法与请求头,24h 内同请求免预检 + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if reqHeaders := fastCtx.Request.Header.Peek("Access-Control-Request-Headers"); len(reqHeaders) > 0 { + h.Set("Access-Control-Allow-Headers", string(reqHeaders)) + } else { + h.Set("Access-Control-Allow-Headers", "Content-Type") + } + h.Set("Access-Control-Max-Age", corsMaxAge) + fastCtx.SetStatusCode(fasthttp.StatusNoContent) + return true +} diff --git a/cors_test.go b/cors_test.go new file mode 100644 index 0000000..fcd1653 --- /dev/null +++ b/cors_test.go @@ -0,0 +1,134 @@ +package fun + +import ( + "bytes" + "net/http" + "strings" + "testing" + "time" +) + +func startCorsServer(t *testing.T, port uint16, withCors bool) *Fun { + t.Helper() + f := New() + if err := f.BindService(&TestSvc{}); err != nil { + t.Fatal(err) + } + if withCors { + f.CORS("https://a.com", "HTTPS://B.Com") // 大小写归一化 + } + f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error { + ctx.RequestCtx.WriteString("pong") + return nil + }) + go f.Start(port) + time.Sleep(300 * time.Millisecond) + return f +} + +func corsGet(t *testing.T, url, origin string) *http.Response { + t.Helper() + req, _ := http.NewRequest("GET", url, nil) + if origin != "" { + req.Header.Set("Origin", origin) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestCorsWhitelistEcho(t *testing.T) { + startCorsServer(t, 39201, true) + resp := corsGet(t, "http://127.0.0.1:39201/ping", "https://a.com") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" { + t.Fatalf("allow-origin = %q, want echo", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("allow-credentials = %q", got) + } + if got := resp.Header.Get("Vary"); !strings.Contains(got, "Origin") { + t.Fatalf("vary = %q, want Origin", got) + } + + // 白名单大小写不敏感 + resp2 := corsGet(t, "http://127.0.0.1:39201/ping", "https://B.COM") + defer resp2.Body.Close() + if got := resp2.Header.Get("Access-Control-Allow-Origin"); got != "https://B.COM" { + t.Fatalf("allow-origin = %q, want case-insensitive match", got) + } +} + +func TestCorsOriginNotWhitelisted(t *testing.T) { + startCorsServer(t, 39202, true) + resp := corsGet(t, "http://127.0.0.1:39202/ping", "https://evil.com") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d(未命中白名单请求本身仍应正常处理)", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("allow-origin = %q, want empty", got) + } +} + +func TestCorsPreflight(t *testing.T) { + startCorsServer(t, 39203, true) + req, _ := http.NewRequest("OPTIONS", "http://127.0.0.1:39203/cell", nil) + req.Header.Set("Origin", "https://a.com") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type, x-custom") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" { + t.Fatalf("allow-origin = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "POST") { + t.Fatalf("allow-methods = %q, want POST", got) + } + // 请求头回显 + if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "x-custom") { + t.Fatalf("allow-headers = %q, want echo x-custom", got) + } + if got := resp.Header.Get("Access-Control-Max-Age"); got != "86400" { + t.Fatalf("max-age = %q", got) + } +} + +func TestCorsHeadersOnCellRpc(t *testing.T) { + startCorsServer(t, 39204, true) + body := `{"ServiceName":"TestSvc","MethodName":"Hello","Data":{"Name":"tom","Age":1}}` + req, _ := http.NewRequest("POST", "http://127.0.0.1:39204/cell", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://a.com") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" { + t.Fatalf("/cell 响应缺少 CORS 头: %q", got) + } +} + +func TestCorsNotConfigured(t *testing.T) { + startCorsServer(t, 39205, false) + resp := corsGet(t, "http://127.0.0.1:39205/ping", "https://a.com") + defer resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("未配置 CORS 却有 allow-origin = %q", got) + } +} diff --git a/docs/README.zh.md b/docs/README.zh.md index 337a3cf..ec5529a 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -1,6 +1,6 @@ # fun 框架(github.com/cyi-cc/fun)使用文档 -> 适用版本:**v1.3.3**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架, +> 适用版本:**v1.3.5**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架, > 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。 ## 版本沿革 @@ -12,6 +12,8 @@ | v1.3.1 | TS 客户端可靠性:所有失败统一归一为 Result 并经过响应拦截器 | | v1.3.2 | 每请求上下文(request/stream options + `state`)与免基础设施的生成期注册 `BindServiceForGen` | | v1.3.3 | 新增 `SetBodyLimit`:自定义路由可放宽请求体上限,支持大体积 multipart 上传 | +| v1.3.4 | 错误通道 API、正确性修复与服务端加固 | +| v1.3.5 | 新增 `CORS`:来源白名单按需放行,预检 204 直答,覆盖 `/cell` 与全部自定义路由 | ## 1. 启动与服务注册 @@ -92,7 +94,22 @@ f.BindRoute("POST", "/upload", func(c *fun.RouteCtx) error { }) ``` -## 6. 流式响应(NDJSON) +## 6. CORS 跨域 + +```go +f.CORS("https://a.com", "https://b.com") // Start 前调用,可变参数白名单 +``` + +- **按需放行**:请求 Origin 命中白名单(大小写不敏感)才回显该来源并允许 + 携带凭据(`Access-Control-Allow-Credentials: true`);未命中不附加任何 + CORS 头,交给浏览器自然拦截。 +- **覆盖全部端点**:`/cell`、自定义路由与通配路由统一生效;预检请求 + (OPTIONS + `Access-Control-Request-Method`)由框架直接以 204 应答, + 不进入业务处理;请求头按 `Access-Control-Request-Headers` 回显, + 预检结果缓存 24h。 +- 未调用 `CORS` 则不附加任何跨域头,行为与之前完全一致。 + +## 7. 流式响应(NDJSON) ```go st := &fun.Stream{} @@ -108,7 +125,7 @@ return st, nil `Content-Type: application/x-ndjson`,每行一个 JSON;合法零消息流正常结束; 业务出错在建流前返回普通 Result;`OnClose` 注册清理回调。 -## 7. TS 客户端生成与请求上下文(v1.3.2 核心) +## 8. TS 客户端生成与请求上下文(v1.3.2 核心) ```go f := fun.GetFun() @@ -153,17 +170,17 @@ c.addResponseInterceptor((svc, m, result, context) => { 旧三参签名仍兼容。所有失败(网络、HTTP、HTML、非法 JSON、取消、拦截器异常) 统一归一为 Result 并**必经响应拦截器**,不存在绕过拦截器的错误路径。 -## 8. 依赖注入(box.go) +## 9. 依赖注入(box.go) - `fun.Wired[T]()`:按 `*T` 建单例;先注入 `fun:"auto"` 字段(缺则递归创建), 再调 `New()`(无参;连接类资源在此初始化,失败可 log.Fatalf)。 - 启动顺序:先 `Wired` 基础配置/平台单例,再 `BindService`。 -## 9. 常见坑 +## 10. 常见坑 - DTO 用普通 `int`/float/map → 注册期 panic;用 int64/字符串/指针。 - 非指针字段漏传 → 运行期 "must be a pointer or have a corresponding field"。 - 流式忘记 `Close()` → 客户端挂起;连接断开后 `Send` 返回 error,循环须检查。 - 方法首字母小写 = 不注册;客户端报 method not found。 -- vite 代理需重写前缀:`/api/cell → /cell`。 +- vite 代理需重写前缀:`/api/cell → /cell`;或服务端 `f.CORS` 加白名单放开浏览器直连。 - 生成用 `BindService`(而非 `BindServiceForGen`)会把基础设施拉起来 —— 生成命令请用后者。 diff --git a/fun.go b/fun.go index 411eb89..50f786e 100644 --- a/fun.go +++ b/fun.go @@ -25,6 +25,8 @@ type Fun struct { idleTimeout time.Duration // keep-alive 空闲超时,默认 120s maxConcurrency int // 最大并发连接数;0 = 不限制 + corsOrigins map[string]struct{} // CORS 来源白名单(小写比较);nil/空表示未开启 + server atomic.Pointer[fasthttp.Server] started atomic.Bool diff --git a/handle.go b/handle.go index 32d100e..ac3c78e 100644 --- a/handle.go +++ b/handle.go @@ -18,6 +18,11 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) { ctx := &Ctx{RequestCtx: fastCtx} defer f.handlePanic(ctx) + // CORS 置于路由之前:预检请求在此直接应答,实际请求附加跨域头后继续正常分发 + if f.handleCors(fastCtx) { + return + } + method, path := string(fastCtx.Method()), string(fastCtx.Path()) if r, ok := f.routes[method+" "+path]; ok { f.handleRoute(fastCtx, r, "")