From 800974cfff6228c2697373d9961d3c7c75611d08 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sat, 26 Sep 2026 19:53:28 +0800 Subject: [PATCH] feat: add configurable transport-independent CORS policy --- CHANGELOG.md | 8 + CONFIG.md | 4 + DEVELOPMENT.md | 2 +- MODULES.md | 2 +- PROJECT.md | 4 +- README.md | 31 +- RELEASE.md | 25 +- app/app.go | 18 +- app/cors_test.go | 187 +++++++++++ cmd/go-omni/README.md | 6 +- cmd/go-omni/apigen/corsConsumer_test.go | 59 ++++ cmd/go-omni/apigen/generate.go | 2 +- cmd/go-omni/apigen/templates.go | 5 +- .../apigen/testdata/cors/consumer_test.go | 69 ++++ .../templates/modules-config.yaml.tmpl | 1 + cmd/go-omni/scaffold/version.go | 2 +- config/config.go | 13 + config/cors_test.go | 51 +++ cors/README.md | 82 +++++ cors/config.go | 144 +++++++++ cors/cors.go | 187 +++++++++++ cors/cors_test.go | 303 ++++++++++++++++++ examples/cors/README.md | 17 + examples/cors/config.yaml | 10 + examples/cors/main.go | 76 +++++ examples/managedService/go.mod | 2 +- examples/userService/go.mod | 2 +- omni.go | 4 + reports/corsAcceptance.md | 61 ++++ reports/corsBenchmark.txt | 24 ++ reports/corsBrowser.json | 137 ++++++++ scripts/corsBrowser/main.go | 121 +++++++ scripts/verifyManagedRelease.py | 9 +- 33 files changed, 1645 insertions(+), 23 deletions(-) create mode 100644 app/cors_test.go create mode 100644 cmd/go-omni/apigen/corsConsumer_test.go create mode 100644 cmd/go-omni/apigen/testdata/cors/consumer_test.go create mode 100644 config/cors_test.go create mode 100644 cors/README.md create mode 100644 cors/config.go create mode 100644 cors/cors.go create mode 100644 cors/cors_test.go create mode 100644 examples/cors/README.md create mode 100644 examples/cors/config.yaml create mode 100644 examples/cors/main.go create mode 100644 reports/corsAcceptance.md create mode 100644 reports/corsBenchmark.txt create mode 100644 reports/corsBrowser.json create mode 100644 scripts/corsBrowser/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 50030c3..f419517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.24.0-preview(2026-09-26) + +- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。 +- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。 +- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。 +- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。 +- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。 + ## v0.23.0-preview(2026-09-26) - MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。 diff --git a/CONFIG.md b/CONFIG.md index a8e5456..695964e 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -189,3 +189,7 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量 5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。 低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。 + +## 跨域配置 + +生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0431069..d4a64ce 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.23.0-preview) +# 通用业务开发指南(v0.24.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 diff --git a/MODULES.md b/MODULES.md index 8e1ea36..dfc806d 100644 --- a/MODULES.md +++ b/MODULES.md @@ -22,7 +22,7 @@ app.GET("/metrics", measurements.Handler()) // 也可以直接读取 measurements.Snapshot()。 ``` -记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。 +记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。 ## 缓存:cache 与 cache/redis diff --git a/PROJECT.md b/PROJECT.md index 754a3c2..0161bde 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # 生成项目与文件维护边界 -v0.23.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +v0.24.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -215,7 +215,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.23.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.24.0-preview: 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 3cd2a90..79d9220 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始接入。** 当前版本 **`v0.23.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.24.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 + +**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。 **v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。 @@ -33,6 +35,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) | | 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) | | HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) | +| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) | | HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) | | 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) | | 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) | @@ -56,6 +59,26 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) | | 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) | +## 浏览器跨域 CORS + +同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入: + +```yaml +server: + cors: + enabled: true + allowedOrigins: [https://admin.example.com, https://merchant.example.com] + allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE] + allowedHeaders: [Authorization, Content-Type] + exposedHeaders: [Content-Disposition, X-Request-ID] + allowCredentials: false + maxAge: 10m +``` + +不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。 + +手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。 + ## 带认证的出站 HTTP HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport: @@ -128,7 +151,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -285,7 +308,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -611,7 +634,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.23.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.24.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 353b37a..6ac711a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,27 @@ -# v0.23.0-preview +# v0.24.0-preview + +业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。 + +- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。 +- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。 +- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。 +- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview +go-omni api generate +go-omni api generate --check +go run . -config etc/config.yaml -check-config +``` + +现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。 + +[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。 + +--- + +# v0.23.0-preview(历史版本) 减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。 diff --git a/app/app.go b/app/app.go index 86ddb36..80be03c 100644 --- a/app/app.go +++ b/app/app.go @@ -12,6 +12,7 @@ import ( "time" "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/cors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/lifecycle" @@ -24,6 +25,7 @@ import ( // httpx.Router, leaving the network implementation replaceable. type App struct { router httpx.Router + handler http.Handler config config.Config middlewares []httpx.Middleware closers []func() error @@ -67,6 +69,11 @@ func configure(options ...config.Option) config.Config { } func newApp(router httpx.Router, settings config.Config) *App { + settings.CORS = settings.CORS.Clone() + corsPolicy, corsErr := cors.New(settings.CORS) + if corsErr != nil { + panic(config.AtField("cors", corsErr)) + } settings.Response = settings.Response.Clone() settings.Errors = settings.Errors.Clone() encoder, responseErr := response.New(settings.Response) @@ -93,6 +100,7 @@ func newApp(router httpx.Router, settings config.Config) *App { } middlewares = append(middlewares, middleware.Recovery()) application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder} + application.handler = corsPolicy.Wrap(router) if fallback, ok := router.(httpx.FallbackRouter); ok { adaptError := func(status int, code, message string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -104,21 +112,23 @@ func newApp(router httpx.Router, settings config.Config) *App { return application } -// Router returns the transport contract used by this application. +// Router exposes the underlying routing contract for advanced registration. +// Serve requests through App or Engine so application CORS policy is applied. func (a *App) Router() httpx.Router { return a.router } // Engine is kept as a compatibility name for the old API. It returns a // standard http.Handler, so callers do not depend on a router type. -func (a *App) Engine() http.Handler { return a.router } +func (a *App) Engine() http.Handler { return a.handler } // ServeHTTP implements http.Handler for standard servers and HTTP tests. func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { - a.router.ServeHTTP(writer, request) + a.handler.ServeHTTP(writer, request) } // Config returns the effective application configuration. func (a *App) Config() config.Config { settings := a.config + settings.CORS = settings.CORS.Clone() settings.Response = settings.Response.Clone() settings.Errors = settings.Errors.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) @@ -177,7 +187,7 @@ func (a *App) NewServer(address string) *http.Server { } } cfg := a.config.HTTP - return &http.Server{Addr: address, Handler: a.router, + return &http.Server{Addr: address, Handler: a.handler, ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout, MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog} diff --git a/app/cors_test.go b/app/cors_test.go new file mode 100644 index 0000000..6d14a75 --- /dev/null +++ b/app/cors_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 96a2324..4678540 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -14,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目 ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -49,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.23.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.24.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -当前模板与依赖以 v0.23.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +当前模板与依赖以 v0.24.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/corsConsumer_test.go b/cmd/go-omni/apigen/corsConsumer_test.go new file mode 100644 index 0000000..b875847 --- /dev/null +++ b/cmd/go-omni/apigen/corsConsumer_test.go @@ -0,0 +1,59 @@ +package apigen + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestGeneratedCORSConsumer(t *testing.T) { + if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" { + t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1") + } + dir := filepath.Join(t.TempDir(), "app") + api := filepath.Join(t.TempDir(), "service.api") + writeFile(t, api, "service corsdemo {\n@handler ping\nget /ping\n}\n") + o := Options{API: api, Dir: dir, Module: "example.com/corsdemo", FrameworkDir: frameworkRoot(t)} + if err := generateManagedTestProject(t, o); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/cors/consumer_test.go")) + writeFile(t, filepath.Join(dir, "etc/config.yaml"), "server:\n log:\n output: discard\n cors:\n enabled: true\n allowedOrigins: [https://web.example]\n allowedMethods: [GET, POST, PUT]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n") + runConsumerGo(t, dir, "mod", "tidy") + runConsumerGo(t, dir, "test", "-race", "-count=1", "./...") + runConsumerGo(t, dir, "build", ".") + o.API = filepath.Join(dir, "api/service.api") + before := snapshot(t, dir) + for _, mode := range []string{"dry", "generate", "check"} { + o.DryRun = mode == "dry" + o.Check = mode == "check" + if err := generateManagedTestProject(t, o); err != nil { + t.Fatal(err) + } + } + if !sameFiles(before, snapshot(t, dir)) { + t.Fatal("regeneration changed project files") + } + if os.Getenv("GO_OMNI_RELEASE_CLI") != "" { + cmd := exec.Command("go", "list", "-m", "-json", frameworkModule) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatal(err, string(out)) + } + var dependency struct { + Version, Sum string + Replace any + } + if err := json.Unmarshal(out, &dependency); err != nil { + t.Fatal(err) + } + if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" { + t.Fatal("not a fixed remote consumer") + } + t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum) + } +} diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index a5f6336..d6f91aa 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -22,7 +22,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Runtime-only releases need not raise the managed template compatibility floor. const minimumVersion = "v0.22.0-preview" -const currentVersion = "v0.23.0-preview" +const currentVersion = "v0.24.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { diff --git a/cmd/go-omni/apigen/templates.go b/cmd/go-omni/apigen/templates.go index 615c9b5..8b2ad73 100644 --- a/cmd/go-omni/apigen/templates.go +++ b/cmd/go-omni/apigen/templates.go @@ -62,7 +62,10 @@ Ordinary Logic uses the generated Request/Response types directly. Where an independent business type is needed, use ordinary Go or the optional copyx package. Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation identifies fields such as server.log.level and database.port. server.log.timeZone -optionally selects a per-logger IANA zone. See the framework CONFIG.md. +optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed +automatically before routing/authentication; disabled by default. Configure allowedOrigins, +allowedMethods and allowedHeaders in the selected file; no main/handler edits. +See the framework CONFIG.md and cors/README.md. There are no mappings/mappingFiles settings or generated mapping files in v0.19. See COPY.md in the framework repository for copy policies and migration steps, diff --git a/cmd/go-omni/apigen/testdata/cors/consumer_test.go b/cmd/go-omni/apigen/testdata/cors/consumer_test.go new file mode 100644 index 0000000..030d431 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/cors/consumer_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "errors" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "example.com/corsdemo/internal/config" + "example.com/corsdemo/internal/handler" + "example.com/corsdemo/internal/svc" + omniConfig "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestGeneratedCORSConfiguration(t *testing.T) { + cfg, err := config.Load("etc/config.yaml") + if err != nil { + t.Fatal(err) + } + logger, err := logx.New(cfg.Server.Log) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + deps, err := svc.New(context.Background(), logger, cfg) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + a, err := handler.NewApp(deps) + if err != nil { + t.Fatal(err) + } + defer a.Close() + for _, tc := range []struct { + method, path string + status int + }{{"GET", "/ping", 501}, {"GET", "/missing", 404}, {"PUT", "/ping", 405}, {"GET", "/health/live", 200}, {"GET", "/health/ready", 200}, {"GET", "/openapi.json", 200}, {"OPTIONS", "/ping", 204}} { + r := httptest.NewRequest(tc.method, tc.path, nil) + r.Header.Set("Origin", "https://web.example") + if tc.method == "OPTIONS" { + r.Header.Set("Access-Control-Request-Method", "GET") + r.Header.Set("Access-Control-Request-Headers", "authorization") + } + w := httptest.NewRecorder() + a.ServeHTTP(w, r) + if w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" { + t.Fatal(tc, w.Code, w.Header(), w.Body.String()) + } + if tc.status == 204 && (w.Body.Len() != 0 || w.Header().Get("Access-Control-Max-Age") != "600") { + t.Fatal(w) + } + } + // Startup config validation uses the normal generated run/Load, before log + // files or dependencies. No changes to generated main/HTTP composition. + path := filepath.Join(t.TempDir(), "invalid.yaml") + if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins: ['*']\n allowCredentials: true\n"), 0600); err != nil { + t.Fatal(err) + } + err = run(context.Background(), path) + var d *omniConfig.Error + if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || !strings.Contains(d.Reason, "allowCredentials") { + t.Fatal(err) + } +} diff --git a/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl b/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl index 5acd801..bba2f7c 100644 --- a/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl +++ b/cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl @@ -1,5 +1,6 @@ # Only fields listed here override Default(). Environment names are explicit. server: + # Optional cross-origin access: server.cors (disabled by default). See framework cors/README.md. address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}" transport: "${GO_OMNI_TRANSPORT:-nethttp}" {{if ne .Storage "none"}}storage: diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index d6c56c0..4c8be80 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.23.0-preview" +const defaultFrameworkVersion = "v0.24.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/config/config.go b/config/config.go index 839e96d..097f3b9 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "time" "git.yuntuo.hk/k2safe/go-omni/contract" + "git.yuntuo.hk/k2safe/go-omni/cors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/lifecycle" @@ -28,6 +29,7 @@ type TLSConfig struct { // Config controls the default runtime behavior of an App. type Config struct { + CORS cors.Config `json:"cors"` Request contract.RequestPolicy `json:"request"` Address string `json:"address"` Transport string `json:"transport"` @@ -61,6 +63,9 @@ func Default() Config { // Validate checks configuration values before an application starts. func (c Config) Validate() error { + if err := c.CORS.Validate(); err != nil { + return AtField("cors", err) + } if err := c.Lifecycle.Validate(); err != nil { return AtField("lifecycle", err) } @@ -188,12 +193,14 @@ func WithTrustedProxies(proxies ...string) Option { // WithConfig applies a complete configuration loaded from a file or // environment. Later options can still override individual fields. func WithConfig(settings Config) Option { + settings.CORS = settings.CORS.Clone() settings.Response = settings.Response.Clone() settings.Errors = settings.Errors.Clone() settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...) settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...) return func(cfg *Config) { *cfg = settings + cfg.CORS = settings.CORS.Clone() cfg.Response = settings.Response.Clone() cfg.Errors = settings.Errors.Clone() cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...) @@ -201,6 +208,12 @@ func WithConfig(settings Config) Option { } } +// WithCORS installs an app-local CORS policy, before routing and authentication. +func WithCORS(settings cors.Config) Option { + settings = settings.Clone() + return func(cfg *Config) { cfg.CORS = settings.Clone() } +} + // WithResponse configures this application's default JSON envelope. func WithResponse(settings response.Config) Option { settings = settings.Clone() diff --git a/config/cors_test.go b/config/cors_test.go new file mode 100644 index 0000000..d0a868a --- /dev/null +++ b/config/cors_test.go @@ -0,0 +1,51 @@ +package config_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.yuntuo.hk/k2safe/go-omni/config" +) + +type corsProject struct { + Server config.Config `json:"server"` +} + +func (c corsProject) Validate() error { return config.AtField("server", c.Server.Validate()) } + +func TestCORSConfiguration(t *testing.T) { + t.Setenv("WEB_ORIGIN", "https://web.example") + for ext, source := range map[string]string{ + "yaml": "server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n allowedMethods: [GET, POST]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n", + "json": `{"server":{"cors":{"enabled":true,"allowedOrigins":["${WEB_ORIGIN}"],"allowedMethods":["GET","POST"],"allowedHeaders":["Authorization","Content-Type"],"maxAge":"10m"}}}`, + "xml": `true${WEB_ORIGIN}GETPOSTAuthorizationContent-Type10m`, + } { + t.Run(ext, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config."+ext) + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadProject(path, corsProject{Server: config.Default()}) + if err != nil { + t.Fatal(err) + } + if !cfg.Server.CORS.Enabled || cfg.Server.CORS.AllowedOrigins[0] != "https://web.example" || cfg.Server.CORS.MaxAge != 10*time.Minute { + t.Fatal(cfg.Server.CORS) + } + }) + } + path := filepath.Join(t.TempDir(), "bad.yaml") + if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("WEB_ORIGIN", "https://user:secret@web.example") + _, err := config.LoadProject(path, corsProject{Server: config.Default()}) + var d *config.Error + if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || d.Env != "WEB_ORIGIN" || d.Line != 5 || strings.Contains(err.Error(), "secret") { + t.Fatal(err) + } +} diff --git a/cors/README.md b/cors/README.md new file mode 100644 index 0000000..a8990c3 --- /dev/null +++ b/cors/README.md @@ -0,0 +1,82 @@ +# 可选 CORS + +CORS 控制浏览器能否跨源读取 HTTP 响应。默认关闭;同源反向代理可以保持关闭。模块无 Gin 或具体业务依赖,标准库/native/HTTP2 与自定义 Runner 使用相同 App 接入策略。 + +## 生成项目只改配置 + +在既有 `etc/config.yaml` 的 `server` 中加入,无需修改 main、svc 或每条 `.api` 路由: + +```yaml +server: + cors: + enabled: true + allowedOrigins: + - https://admin.example.com + - https://merchant.example.com + allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE] + allowedHeaders: [Authorization, Content-Type] + exposedHeaders: [X-Request-ID, Content-Disposition] + allowCredentials: false + maxAge: 10m +``` + +文件也可显式引用环境变量:`allowedOrigins: ["${WEB_ORIGIN}"]`。一个数组元素代表一个来源,不把逗号分隔环境文本隐式拆成多个来源。JSON/YAML/XML 走同一加载和校验;非法配置定位 `server.cors.allowedOrigins[0]` 等字段、文件及可用环境名/行列,不回显值。 + +```sh +go run . -config etc/config.yaml -check-config +go run ./examples/cors # 在框架源码根目录运行独立示例 +``` + +## 手写项目与独立 HTTP 服务 + +```go +app := omni.New(omni.WithCORS(omni.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://web.example.com"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, +})) +``` + +已有 net/http 服务也可单独使用,不需要创建 App: + +```go +policy, err := cors.New(cors.Config{ + Enabled: true, + AllowedOrigins: []string{"https://web.example.com"}, +}) +if err != nil { return err } +handler := policy.Wrap(existingHandler) +``` + +Config/New/WithCORS 在初始化时校验并快照配置;同一 Policy 可并发复用。`Config.Validate()` 返回 error,直接向 `omni.New` 传非法选项沿用框架初始化 panic 约定;生成项目在创建 App 前已校验。 + +## 配置规则 + +| 字段 | 规则 | +| --- | --- | +| enabled | 默认 false;关闭时直接使用原 Handler,不添加头、不拦截 OPTIONS | +| allowedOrigins | 启用时必填。完整 HTTP(S) origin(协议+主机+可选端口),无路径、尾斜杠、userinfo、查询或片段。配置中的协议/主机转小写、默认 80/443 端口省略;请求匹配浏览器序列化后的来源 | +| allowedMethods | 空列表默认 GET、HEAD、POST;填写后使用指定集合。大写方法名,拒绝浏览器禁止的 CONNECT/TRACE/TRACK,不提供方法通配符 | +| allowedHeaders | 空列表不批准额外预检请求头。Authorization、JSON Content-Type 等按需要列出;名称忽略大小写,不提供通配符 | +| exposedHeaders | 默认空;额外允许前端 JS 读取的响应头,例如下载文件名用 Content-Disposition。浏览器本来就可读取的安全列表不需重复填写 | +| allowCredentials | 默认 false;跨源 Cookie 等凭据模式需要显式 true 和具体来源。单纯手动设置 Authorization 头只需列入 allowedHeaders,不必因此开启 Cookie 凭据模式 | +| maxAge | 浏览器缓存预检结果的时长,必须非负且为整秒;0 不发送该头。浏览器可按自身上限截短 | + +公开无凭据 API 可使用唯一来源 `"*"`;与 `allowCredentials: true` 或其他来源并存会报错。此版本只支持 HTTP(S) 来源,拒绝 `null`、子域通配符、正则、file:// 和不透明来源。国际化域名填写浏览器发送的 ASCII/Punycode 形式;IP 使用完整标准形式。不会为匹配来源查询 DNS。 + +## 预检、错误响应与安全边界 + +- CORS 位于路由/业务中间件外层。只有 OPTIONS 且同时带 Origin 和 Access-Control-Request-Method 才视为预检;普通 OPTIONS 继续执行项目逻辑。 +- 合法预检返回 **204 空响应**,不进入业务中间件、鉴权、Logic 或业务响应包装。拒绝预检返回 **403 空响应**,不暴露内部原因;两者是协议响应,不受业务“失败也返回 HTTP 200”的设置影响。 +- 预检验证全局来源、方法、请求头策略,不探测路由存在性;通过不代表实际接口一定存在。实际请求仍由路由处理,可返回 404/405。 +- 实际请求继续正常鉴权;来源/方法获准时,正常响应、401/403、404/405、框架恢复的 500 都带 CORS 头,响应体、状态和错误目录不变。协议解析、TLS、网络层过载等在进入 App 前发生的拒绝不由本模块改写。 +- 未获准或没有 Origin 的实际请求仍正常进入业务,只是不添加 CORS 许可头。CORS 不等于身份认证、服务端访问控制或 CSRF 防护;服务端回调仍需项目自己的验签/鉴权。 +- 始终追加并去重 `Vary: Origin`;预检再追加请求方法/请求头的 Vary,保留原有 Vary 与 `*`。不要在下游代码覆盖这些头。代理与应用选择一处负责 CORS,避免重复头或冲突策略。 +- 不包装 ResponseWriter、不读取/缓存 body、不克隆 Request。下载、上传、Flush、Hijack 和请求上下文保留;不增加响应体大小限制或改写统一返回格式。 +- 使用 App、Engine、NewServer 或 Run/ServeContext/RunWith 接入。Router() 暴露底层路由供高级注册,单独拿 Router().ServeHTTP 服务会绕过应用的外层策略。 +- App 内业务日志/指标不统计被提前结束的预检;如需记录全部流量,在 App 外层或代理配置访问日志。不要让全局登录校验挡在 CORS 预检之前。 + +配置最多 1024 个来源、每个最多 4096 字节;方法/请求头/暴露头各最多 128 个且名称最多 256 字节;预检请求头列表最多 128 项、8192 字节。拒绝 CRLF、歧义的重复 Origin/请求方法及非法 token。WebSocket Origin 校验、Private Network Access 扩展和云桶 CORS 不包含在本模块中,按对应服务配置。 + +规则依据:[Fetch CORS 协议](https://fetch.spec.whatwg.org/#http-cors-protocol)、[凭据规则](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials)、[缓存规则](https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches)。可运行示例:[examples/cors](../examples/cors/README.md)。 diff --git a/cors/config.go b/cors/config.go new file mode 100644 index 0000000..31388db --- /dev/null +++ b/cors/config.go @@ -0,0 +1,144 @@ +// Package cors implements optional, transport-independent browser CORS policy. +// It does not authenticate requests or provide CSRF protection. +package cors + +import ( + "net" + "net/netip" + "net/url" + "strconv" + "strings" + "time" + + "git.yuntuo.hk/k2safe/go-omni/internal/configdiag" +) + +// Config is disabled by default. Origins are exact HTTP(S) origins, or a single +// "*" for public responses without credentials. Patterns and "null" are rejected. +// Empty methods default to GET, HEAD, POST; empty header lists grant no extra +// permissions. MaxAge is a nonnegative whole number of seconds; zero omits it. +type Config struct { + Enabled bool `json:"enabled"` + AllowedOrigins []string `json:"allowedOrigins"` + AllowedMethods []string `json:"allowedMethods"` + AllowedHeaders []string `json:"allowedHeaders"` + ExposedHeaders []string `json:"exposedHeaders"` + AllowCredentials bool `json:"allowCredentials"` + MaxAge time.Duration `json:"maxAge"` +} + +func (c Config) Clone() Config { + c.AllowedOrigins = append([]string(nil), c.AllowedOrigins...) + c.AllowedMethods = append([]string(nil), c.AllowedMethods...) + c.AllowedHeaders = append([]string(nil), c.AllowedHeaders...) + c.ExposedHeaders = append([]string(nil), c.ExposedHeaders...) + return c +} + +// Validate never echoes configured values in diagnostics. Invalid settings are +// rejected even when disabled; an empty origin list is legal only when disabled. +func (c Config) Validate() error { + if c.Enabled && len(c.AllowedOrigins) == 0 { + return configdiag.New("allowedOrigins", "is required when CORS is enabled") + } + if c.MaxAge < 0 || c.MaxAge%time.Second != 0 { + return configdiag.New("maxAge", "must be a nonnegative whole number of seconds") + } + if len(c.AllowedOrigins) > 1024 { + return configdiag.New("allowedOrigins", "must contain at most 1024 entries") + } + for i, origin := range c.AllowedOrigins { + field := "allowedOrigins[" + strconv.Itoa(i) + "]" + if origin == "*" { + if c.AllowCredentials || len(c.AllowedOrigins) != 1 { + return configdiag.New(field, "wildcard requires a single origin entry and allowCredentials=false") + } + } else if _, ok := normalizeOrigin(origin); !ok { + return configdiag.New(field, "must be an HTTP(S) origin without credentials, path, query, fragment or wildcard") + } + } + for _, list := range []struct { + name string + values []string + }{{"allowedMethods", c.AllowedMethods}, {"allowedHeaders", c.AllowedHeaders}, {"exposedHeaders", c.ExposedHeaders}} { + if len(list.values) > 128 { + return configdiag.New(list.name, "must contain at most 128 entries") + } + for i, value := range list.values { + field := list.name + "[" + strconv.Itoa(i) + "]" + if !token(value) || value == "*" { + return configdiag.New(field, "must be an explicit HTTP token, not a wildcard") + } + if list.name == "allowedMethods" && (value != strings.ToUpper(value) || value == "CONNECT" || value == "TRACE" || value == "TRACK") { + return configdiag.New(field, "must be an uppercase browser-allowed HTTP method") + } + } + } + return nil +} + +// Normalize configured scheme/host case and default ports once. Request values +// match serialized browser origins; arbitrary URL prefixes/suffixes never match. +func normalizeOrigin(origin string) (string, bool) { + if len(origin) == 0 || len(origin) > 4096 { + return "", false + } + for _, b := range []byte(origin) { + if b <= 32 || b >= 127 || strings.ContainsRune("\\%?#*", rune(b)) { + return "", false + } + } + u, err := url.Parse(origin) + if err != nil || u.User != nil || u.Opaque != "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Host == "" { + return "", false + } + scheme, host := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()) + if (scheme != "http" && scheme != "https") || host == "" || strings.HasSuffix(u.Host, ":") { + return "", false + } + if strings.Contains(host, ":") { + ip, err := netip.ParseAddr(host) + if err != nil || !ip.Is6() || ip.Zone() != "" { + return "", false + } + host = "[" + ip.String() + "]" + if ip.Is4In6() { + // Browsers serialize mapped IPv4 as hex IPv6 pieces; netip.String + // uses a dotted-decimal tail instead. + b := ip.As16() + host = "[::ffff:" + strconv.FormatUint(uint64(b[12])<<8|uint64(b[13]), 16) + ":" + strconv.FormatUint(uint64(b[14])<<8|uint64(b[15]), 16) + "]" + } + } else { + if strings.ContainsAny(u.Host, "[]") { + return "", false + } + for _, b := range []byte(host) { + if !(b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '-') { + return "", false + } + } + } + port := u.Port() + if port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", false + } + if !(scheme == "https" && n == 443 || scheme == "http" && n == 80) { + host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(n)) + } + } + return scheme + "://" + host, true +} + +func token(s string) bool { + if s == "" || len(s) > 256 { + return false + } + for _, b := range []byte(s) { + if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))) { + return false + } + } + return true +} diff --git a/cors/cors.go b/cors/cors.go new file mode 100644 index 0000000..21ea765 --- /dev/null +++ b/cors/cors.go @@ -0,0 +1,187 @@ +package cors + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +// Policy is immutable after New and safe to share between concurrent requests. +type Policy struct { + enabled, wildcard, credentials bool + origins, methods, headers map[string]struct{} + allowMethods, allowHeaders, exposeHeaders, maxAge string +} + +// New validates and snapshots policy. It performs no network or DNS operations. +func New(c Config) (*Policy, error) { + if err := c.Validate(); err != nil { + return nil, err + } + p := &Policy{enabled: c.Enabled, credentials: c.AllowCredentials} + if !c.Enabled { + return p, nil + } + p.origins = make(map[string]struct{}, len(c.AllowedOrigins)) + for _, origin := range c.AllowedOrigins { + if origin == "*" { + p.wildcard = true + continue + } + normalized, _ := normalizeOrigin(origin) + p.origins[normalized] = struct{}{} + } + methods := c.AllowedMethods + if len(methods) == 0 { + methods = []string{http.MethodGet, http.MethodHead, http.MethodPost} + } + p.methods, p.allowMethods = compileList(methods, false) + p.headers, p.allowHeaders = compileList(c.AllowedHeaders, true) + _, p.exposeHeaders = compileList(c.ExposedHeaders, true) + if c.MaxAge > 0 { + p.maxAge = strconv.FormatInt(int64(c.MaxAge/time.Second), 10) + } + return p, nil +} + +func compileList(values []string, header bool) (map[string]struct{}, string) { + set := make(map[string]struct{}, len(values)) + list := make([]string, 0, len(values)) + for _, value := range values { + key := value + if header { + key = strings.ToLower(value) + value = http.CanonicalHeaderKey(value) + } + if _, exists := set[key]; exists { + continue + } + set[key] = struct{}{} + list = append(list, value) + } + return set, strings.Join(list, ", ") +} + +// Wrap installs CORS outside routing/authentication. Only actual CORS preflights +// are short-circuited. Actual requests always continue through normal handlers; +// an unlisted Origin receives no permission to read the response in a browser. +// The original ResponseWriter, Request and body pass through without buffering. +func (p *Policy) Wrap(next http.Handler) http.Handler { + if next == nil { + panic("cors: nil handler") + } + if p == nil { + panic("cors: nil policy") + } + if !p.enabled { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + vary(h, "Origin") + origins := r.Header.Values("Origin") + // An OPTIONS request without Origin or Access-Control-Request-Method is + // ordinary application traffic, including user-defined OPTIONS handlers. + requestedMethods := r.Header.Values("Access-Control-Request-Method") + preflight := r.Method == http.MethodOptions && len(origins) > 0 && len(requestedMethods) > 0 + if preflight { + vary(h, "Access-Control-Request-Method") + vary(h, "Access-Control-Request-Headers") + } + if len(origins) != 1 || !p.allowsOrigin(origins[0]) { + if preflight { + w.WriteHeader(http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + return + } + if preflight { + if len(requestedMethods) != 1 || !p.allowsMethod(requestedMethods[0]) || !p.allowsHeaders(r.Header.Values("Access-Control-Request-Headers")) { + w.WriteHeader(http.StatusForbidden) + return + } + p.allowOrigin(h, origins[0]) + h.Set("Access-Control-Allow-Methods", p.allowMethods) + if p.allowHeaders != "" { + h.Set("Access-Control-Allow-Headers", p.allowHeaders) + } + if p.maxAge != "" { + h.Set("Access-Control-Max-Age", p.maxAge) + } + w.WriteHeader(http.StatusNoContent) + return + } + // Match the actual method too: this grants response access, not permission + // to execute a side effect. Authentication/CSRF checks remain in the app. + if p.allowsMethod(r.Method) { + p.allowOrigin(h, origins[0]) + if p.exposeHeaders != "" { + h.Set("Access-Control-Expose-Headers", p.exposeHeaders) + } + } + next.ServeHTTP(w, r) + }) +} + +func (p *Policy) allowsOrigin(origin string) bool { + if !p.wildcard { + _, ok := p.origins[origin] + return ok + } + normalized, ok := normalizeOrigin(origin) + return ok && normalized == origin +} +func (p *Policy) allowsMethod(method string) bool { _, ok := p.methods[method]; return ok } +func (p *Policy) allowOrigin(h http.Header, origin string) { + if p.wildcard { + origin = "*" + } + h.Set("Access-Control-Allow-Origin", origin) + if p.credentials { + h.Set("Access-Control-Allow-Credentials", "true") + } +} +func (p *Policy) allowsHeaders(lines []string) bool { + count, size := 0, 0 + for _, line := range lines { + size += len(line) + if size > 8192 { + return false + } + // Parse a header list without allocating an attacker-sized split slice. + for { + name, rest, more := strings.Cut(line, ",") + name = strings.Trim(name, " \t") + count++ + if count > 128 || !token(name) { + return false + } + if _, ok := p.headers[strings.ToLower(name)]; !ok { + return false + } + if !more { + break + } + line = rest + } + } + return true +} +func vary(h http.Header, name string) { + for _, line := range h.Values("Vary") { + for { + v, rest, more := strings.Cut(line, ",") + v = strings.TrimSpace(v) + if v == "*" || strings.EqualFold(v, name) { + return + } + if !more { + break + } + line = rest + } + } + h.Add("Vary", name) +} diff --git a/cors/cors_test.go b/cors/cors_test.go new file mode 100644 index 0000000..1a9e7f4 --- /dev/null +++ b/cors/cors_test.go @@ -0,0 +1,303 @@ +package cors + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func exampleConfig() Config { + return Config{Enabled: true, AllowedOrigins: []string{"https://admin.example", "https://merchant.example"}, AllowedMethods: []string{"GET", "POST", "DELETE"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"X-Request-ID", "Content-Disposition"}, MaxAge: 10 * time.Minute} +} + +func TestPolicyPreflightAndActual(t *testing.T) { + for _, tc := range []struct { + name, method, origin, requestedMethod, requestedHeaders string + status int + called, allowed bool + }{ + {"preflight", "OPTIONS", "https://admin.example", "POST", "authorization, CONTENT-TYPE", 204, false, true}, + {"no request headers", "OPTIONS", "https://admin.example", "DELETE", "", 204, false, true}, + {"unknown origin", "OPTIONS", "https://other.example", "POST", "authorization", 403, false, false}, + {"suffix attack", "OPTIONS", "https://admin.example.evil", "POST", "", 403, false, false}, + {"scheme mismatch", "OPTIONS", "http://admin.example", "POST", "", 403, false, false}, + {"port mismatch", "OPTIONS", "https://admin.example:444", "POST", "", 403, false, false}, + {"opaque origin", "OPTIONS", "null", "POST", "", 403, false, false}, + {"unknown method", "OPTIONS", "https://admin.example", "PATCH", "", 403, false, false}, + {"case sensitive method", "OPTIONS", "https://admin.example", "post", "", 403, false, false}, + {"unknown header", "OPTIONS", "https://admin.example", "POST", "X-Internal", 403, false, false}, + {"trailing empty header", "OPTIONS", "https://admin.example", "POST", "Authorization,", 403, false, false}, + {"control header", "OPTIONS", "https://admin.example", "POST", "Authorization\r\nX-Evil", 403, false, false}, + {"duplicate allowed header", "OPTIONS", "https://admin.example", "POST", "Authorization, authorization", 204, false, true}, + {"oversized list", "OPTIONS", "https://admin.example", "POST", strings.Repeat("Authorization,", 129) + "Authorization", 403, false, false}, + {"actual error response", "GET", "https://merchant.example", "", "", 401, true, true}, + {"actual denied origin still authenticates", "POST", "https://other.example", "", "", 401, true, false}, + {"actual denied method still authenticates", "PUT", "https://admin.example", "", "", 401, true, false}, + {"server callback", "POST", "", "", "", 401, true, false}, + {"ordinary OPTIONS", "OPTIONS", "https://admin.example", "", "", 401, true, false}, + {"OPTIONS without Origin", "OPTIONS", "", "POST", "", 401, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + policy, err := New(exampleConfig()) + if err != nil { + t.Fatal(err) + } + called := false + h := policy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(401) + io.WriteString(w, "authentication required") + })) + r := httptest.NewRequest(tc.method, "http://api.example/resource", nil) + if tc.origin != "" { + r.Header.Set("Origin", tc.origin) + } + if tc.requestedMethod != "" { + r.Header.Set("Access-Control-Request-Method", tc.requestedMethod) + } + if tc.requestedHeaders != "" { + r.Header.Set("Access-Control-Request-Headers", tc.requestedHeaders) + } + w := httptest.NewRecorder() + w.Header().Add("Vary", "Accept-Encoding") + h.ServeHTTP(w, r) + if w.Code != tc.status || called != tc.called { + t.Fatalf("status=%d called=%v", w.Code, called) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); (got != "") != tc.allowed || tc.allowed && got != tc.origin { + t.Fatal(w.Header()) + } + if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Origin") || !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Accept-Encoding") { + t.Fatal(w.Header()) + } + if !tc.called { + if w.Body.Len() != 0 { + t.Fatal("preflight wrapped in business response") + } + if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Access-Control-Request-Headers") { + t.Fatal(w.Header()) + } + if tc.allowed && (w.Header().Get("Access-Control-Max-Age") != "600" || !strings.Contains(w.Header().Get("Access-Control-Allow-Headers"), "Authorization")) { + t.Fatal(w.Header()) + } + } else if w.Body.String() != "authentication required" { + t.Fatal(w.Body.String()) + } + if tc.called && tc.allowed && w.Header().Get("Access-Control-Expose-Headers") != "X-Request-Id, Content-Disposition" { + t.Fatal(w.Header()) + } + }) + } +} + +func TestPolicyValidation(t *testing.T) { + for _, origin := range []string{"https://admin.example/", "https://user:password@admin.example", "null", "https://*.example", "https://admin.example?secret", "https://admin.example#secret", "file://host", "https://host:", "https://host:0", "https://host:65536", "https://host%2eevil", "https://host\r\nX: value", "https://例子.example", "https://[localhost]", "https://[::1%25zone]"} { + c := exampleConfig() + c.AllowedOrigins = []string{origin} + err := c.Validate() + if err == nil || strings.Contains(err.Error(), origin) { + t.Fatalf("invalid origin accepted or exposed: %q %v", origin, err) + } + } + for _, change := range []func(*Config){ + func(c *Config) { c.AllowedOrigins = nil }, + func(c *Config) { c.AllowedOrigins = []string{"*"}; c.AllowCredentials = true }, + func(c *Config) { c.AllowedOrigins = []string{"*", "https://admin.example"} }, + func(c *Config) { c.AllowedHeaders = []string{"*"} }, + func(c *Config) { c.ExposedHeaders = []string{"header\r\n"} }, + func(c *Config) { c.AllowedMethods = []string{"get"} }, + func(c *Config) { c.AllowedMethods = []string{"TRACE"} }, + func(c *Config) { c.MaxAge = -time.Second }, + func(c *Config) { c.MaxAge = time.Millisecond }, + } { + c := exampleConfig() + change(&c) + if _, err := New(c); err == nil { + t.Fatal(c) + } + } + if err := (Config{}).Validate(); err != nil { + t.Fatal(err) + } + for input, want := range map[string]string{"https://[::ffff:192.0.2.1]": "https://[::ffff:c000:201]", "https://[::ffff:c000:201]": "https://[::ffff:c000:201]", "https://ADMIN.EXAMPLE:443": "https://admin.example", "http://localhost:80": "http://localhost", "http://localhost:8080": "http://localhost:8080", "https://[::1]:443": "https://[::1]", "http://[::1]:8080": "http://[::1]:8080"} { + got, ok := normalizeOrigin(input) + if !ok || got != want { + t.Fatalf("%q => %q %v", input, got, ok) + } + } +} + +func TestPolicyCredentialsWildcardAndDuplicates(t *testing.T) { + for _, credentials := range []bool{false, true} { + c := exampleConfig() + c.AllowCredentials = credentials + if !credentials { + c.AllowedOrigins = []string{"*"} + } + p, _ := New(c) + for _, origin := range []string{"https://admin.example", "null", "https://admin.example/path", "https://bad\r\n"} { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Origin", origin) + w := httptest.NewRecorder() + p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, r) + if origin != "https://admin.example" { + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal(w.Header()) + } + continue + } + want := "*" + if credentials { + want = origin + } + if w.Header().Get("Access-Control-Allow-Origin") != want || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != credentials { + t.Fatal(w.Header()) + } + } + } + p, _ := New(exampleConfig()) + for _, header := range []string{"Origin", "Access-Control-Request-Method"} { + r := httptest.NewRequest("OPTIONS", "/", nil) + r.Header.Set("Origin", "https://admin.example") + r.Header.Set("Access-Control-Request-Method", "POST") + r.Header.Add(header, r.Header.Get(header)) + w := httptest.NewRecorder() + p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("ambiguous preflight reached handler") })).ServeHTTP(w, r) + if w.Code != 403 || w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal(w.Code, w.Header()) + } + } +} + +func TestPolicyPreservesHTTPAndSnapshot(t *testing.T) { + c := exampleConfig() + p, _ := New(c) + c.AllowedOrigins[0] = "https://evil.example" + c.AllowedHeaders[0] = "X-Evil" + r := httptest.NewRequest("GET", "/", strings.NewReader("request")) + r.Header.Set("Origin", "https://admin.example") + type key struct{} + r = r.WithContext(context.WithValue(r.Context(), key{}, "kept")) + w := httptest.NewRecorder() + w.Header().Set("Vary", "Accept-Encoding, origin") + next := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if writer != w || request != r || request.Context().Value(key{}) != "kept" { + t.Fatal("HTTP capabilities replaced") + } + io.WriteString(writer, "chunk") + writer.(http.Flusher).Flush() + }) + p.Wrap(next).ServeHTTP(w, r) + if !w.Flushed || w.Body.String() != "chunk" || w.Header().Get("Access-Control-Allow-Origin") != "https://admin.example" || len(w.Header().Values("Vary")) != 1 { + t.Fatal(w) + } + for _, prior := range []string{"*", "Accept-Encoding, Origin"} { + h := http.Header{"Vary": {prior}} + vary(h, "Origin") + if !reflect.DeepEqual(h.Values("Vary"), []string{prior}) { + t.Fatal(h) + } + } + disabled, _ := New(Config{}) + if reflect.ValueOf(disabled.Wrap(next)).Pointer() != reflect.ValueOf(next).Pointer() { + t.Fatal("disabled policy not passthrough") + } + var wg sync.WaitGroup + h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + for i := 0; i < 32; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 100; j++ { + r := httptest.NewRequest("GET", "/", nil) + origin := "https://admin.example" + if i%2 == 1 { + origin = "https://merchant.example" + } + r.Header.Set("Origin", origin) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Header().Get("Access-Control-Allow-Origin") != origin { + t.Error("cross-request origin leak") + } + } + }(i) + } + wg.Wait() +} + +func FuzzCORSHeaders(f *testing.F) { + f.Add("https://admin.example", "POST", "authorization, content-type") + f.Add("https://admin.example.evil", "POST", "") + f.Add("null", "GET", "\r\n") + p, _ := New(exampleConfig()) + h := p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("preflight reached business") })) + f.Fuzz(func(t *testing.T, origin, method, headers string) { + r := httptest.NewRequest("OPTIONS", "/", nil) + r.Header.Set("Origin", origin) + r.Header.Set("Access-Control-Request-Method", method) + if headers != "" { + r.Header.Set("Access-Control-Request-Headers", headers) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 204 && w.Code != 403 { + t.Fatal(w.Code) + } + if w.Body.Len() != 0 { + t.Fatal("unexpected body") + } + if value := w.Header().Get("Access-Control-Allow-Origin"); value != "" { + if value != "https://admin.example" && value != "https://merchant.example" { + t.Fatal("untrusted reflection") + } + } + }) +} + +type benchmarkWriter struct{ header http.Header } + +func (w benchmarkWriter) Header() http.Header { return w.header } +func (w benchmarkWriter) Write(p []byte) (int, error) { return len(p), nil } +func (w benchmarkWriter) WriteHeader(int) {} +func BenchmarkCORS(b *testing.B) { + for _, mode := range []string{"disabled", "noOrigin", "allowed", "denied", "preflight", "wildcard"} { + b.Run(mode, func(b *testing.B) { + c := exampleConfig() + if mode == "disabled" { + c.Enabled = false + } + if mode == "wildcard" { + c.AllowedOrigins = []string{"*"} + } + p, _ := New(c) + h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Origin", "https://admin.example") + if mode == "noOrigin" { + r.Header.Del("Origin") + } + if mode == "denied" { + r.Header.Set("Origin", "https://other.example") + } + if mode == "preflight" { + r.Method = "OPTIONS" + r.Header.Set("Access-Control-Request-Method", "POST") + r.Header.Set("Access-Control-Request-Headers", "authorization, content-type") + } + w := benchmarkWriter{make(http.Header)} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + clear(w.header) + h.ServeHTTP(w, r) + } + }) + } +} diff --git a/examples/cors/README.md b/examples/cors/README.md new file mode 100644 index 0000000..c037fd7 --- /dev/null +++ b/examples/cors/README.md @@ -0,0 +1,17 @@ +# CORS 与正常鉴权 + +在框架根目录运行: + +```sh +go run ./examples/cors +WEB_ORIGIN=https://admin.example go run ./examples/cors +``` + +仅监听临时 localhost 端口,不依赖数据库或外部服务。配置由统一 LoadProject 加载,示例模拟浏览器 Origin/预检请求头,依次检查: + +- OPTIONS 预检返回 204,无需登录。 +- 实际 GET 未登录仍返回 401,前端可读取这条错误响应。 +- 示例凭据通过后返回 200。 +- 404 也保留允许来源的 CORS 响应头。 + +示例 Token 仅用于展示,项目使用自身鉴权实现。CORS 不验证 Token,也不保证其他客户端不能调用 API。完整规则与配置见 [cors](../../cors/README.md)。 diff --git a/examples/cors/config.yaml b/examples/cors/config.yaml new file mode 100644 index 0000000..97511ae --- /dev/null +++ b/examples/cors/config.yaml @@ -0,0 +1,10 @@ +server: + cors: + enabled: true + allowedOrigins: + - "${WEB_ORIGIN:-https://web.example}" + allowedMethods: [GET, POST] + allowedHeaders: [Authorization, Content-Type] + exposedHeaders: [X-Request-ID] + allowCredentials: false + maxAge: 10m diff --git a/examples/cors/main.go b/examples/cors/main.go new file mode 100644 index 0000000..82c05f3 --- /dev/null +++ b/examples/cors/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/config" +) + +type Config struct { + Server config.Config `json:"server"` +} + +func (c Config) Validate() error { return config.AtField("server", c.Server.Validate()) } + +func run(path string) error { + cfg, err := config.LoadProject(path, Config{Server: config.Default()}) + if err != nil { + return err + } + a := omni.New(omni.WithConfig(cfg.Server)) + defer a.Close() + a.GET("/private", func(c *omni.Context) error { + if c.Header("Authorization") != "Bearer example-only" { + return omni.Unauthorized("LOGIN_REQUIRED", "login required") + } + return c.OK("authenticated") + }) + server := httptest.NewServer(a) + defer server.Close() + client := &http.Client{Timeout: 3 * time.Second} + defer client.CloseIdleConnections() + for _, tc := range []struct { + method, path, auth string + status int + }{{"OPTIONS", "/private", "", 204}, {"GET", "/private", "", 401}, {"GET", "/private", "Bearer example-only", 200}, {"GET", "/missing", "", 404}} { + r, _ := http.NewRequest(tc.method, server.URL+tc.path, nil) + r.Header.Set("Origin", cfg.Server.CORS.AllowedOrigins[0]) + if tc.method == "OPTIONS" { + r.Header.Set("Access-Control-Request-Method", "GET") + r.Header.Set("Access-Control-Request-Headers", "authorization") + } + if tc.auth != "" { + r.Header.Set("Authorization", tc.auth) + } + res, err := client.Do(r) + if err != nil { + return err + } + _, readErr := io.Copy(io.Discard, res.Body) + closeErr := res.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return err + } + if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") == "" { + return fmt.Errorf("unexpected CORS response: %d", res.StatusCode) + } + fmt.Printf("%s %s -> %d, CORS allowed\n", tc.method, tc.path, res.StatusCode) + } + return nil +} +func main() { + path := flag.String("config", "examples/cors/config.yaml", "selected configuration file") + flag.Parse() + if err := run(*path); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod index 12abeb4..c7a41c7 100644 --- a/examples/managedService/go.mod +++ b/examples/managedService/go.mod @@ -2,7 +2,7 @@ module example.com/managed go 1.22.0 -require git.yuntuo.hk/k2safe/go-omni v0.23.0-preview +require git.yuntuo.hk/k2safe/go-omni v0.24.0-preview require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect diff --git a/examples/userService/go.mod b/examples/userService/go.mod index 3d813d0..aed552e 100644 --- a/examples/userService/go.mod +++ b/examples/userService/go.mod @@ -3,7 +3,7 @@ module example.com/userService go 1.22.0 require ( - git.yuntuo.hk/k2safe/go-omni v0.23.0-preview + git.yuntuo.hk/k2safe/go-omni v0.24.0-preview github.com/go-sql-driver/mysql v1.9.3 ) diff --git a/omni.go b/omni.go index 263a914..e53f2b3 100644 --- a/omni.go +++ b/omni.go @@ -6,6 +6,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/app" "git.yuntuo.hk/k2safe/go-omni/auth" "git.yuntuo.hk/k2safe/go-omni/config" + "git.yuntuo.hk/k2safe/go-omni/cors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" @@ -27,6 +28,7 @@ type APIError = apierrors.APIError type Config = config.Config type TLSConfig = config.TLSConfig type HTTPConfig = config.HTTPConfig +type CORSConfig = cors.Config type EndpointsConfig = config.EndpointsConfig type EndpointConfig = config.EndpointConfig type HealthEndpointConfig = config.HealthEndpointConfig @@ -70,6 +72,8 @@ func WithTransport(name string) Option { return config.WithTransport(name) } func WithHTTPConfig(settings HTTPConfig) Option { return config.WithHTTPConfig(settings) } +func WithCORS(settings CORSConfig) Option { return config.WithCORS(settings) } + func WithTLS(certFile, keyFile string) Option { return config.WithTLS(certFile, keyFile) } func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) } diff --git a/reports/corsAcceptance.md b/reports/corsAcceptance.md new file mode 100644 index 0000000..2bd7ed3 --- /dev/null +++ b/reports/corsAcceptance.md @@ -0,0 +1,61 @@ +# v0.24 CORS 验收(2026-09-26) + +新增通用、可选的跨域策略。没有修改接入项目,没有连接业务数据库或对线上服务器进行压测。 + +## 回归 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含实际生成消费者和既有生命周期、错误目录、配置、上传下载等回归。 +- 最后补充 IPv4-mapped IPv6 来源序列化后,cors/config/app 的 race 复测通过。 +- Go 1.22.12 的 cors/config/app 测试通过;`go vet ./...`、`go build ./...` 通过。 +- managedService 重生成检查、race、根目录 build 通过;userService race 通过。没有提供外部数据库配置的测试按原规则跳过。 +- 实际生成的 CORS 消费者只修改配置,不修改 main、svc、handler;构建/race、重复生成、dry-run、check、业务文件字节保留通过。 +- `FuzzCORSHeaders` 运行 30 秒、4 worker,执行 2,877,406 次,通过。输入覆盖来源、方法和预检请求头,不等于穷尽所有协议组合。 +- `examples/cors` 使用本地临时监听端口验证 204、401、200、404,通过。 + +| 范围 | 已验证场景 | +| --- | --- | +| 默认与配置 | 默认关闭保持原 Handler;JSON/YAML/XML 一致;显式环境引用;配置错误定位 server.cors 具体字段及文件/环境名/可用位置,不输出值 | +| 来源 | 精确协议/域名/端口、默认端口规范化、IPv6、恶意后缀、null/路径/CRLF 拒绝;通配来源禁止凭据;重复 Origin 拒绝 | +| 预检 | 204 空响应,拒绝为 403;不进入业务鉴权;方法/头校验、大小与数量限制;普通 OPTIONS 仍进入项目 Handler | +| 实际响应 | 业务鉴权保留;自定义数字响应、401/500/panic、404/405 保留跨域头和原响应;不允许来源仅不授予浏览器读取权限 | +| 标准能力 | 原 Request/Context/Body/ResponseWriter 不替换,Flush 等能力保留;Vary 追加/去重,已有值和通配 Vary 保留 | +| transport | nethttp/native/HTTP2 使用真实本地 TLS;预检、原始上传、流式下载、暴露文件名、404 与关闭;HTTP2 确认协议协商 | +| 隔离 | 输入配置、Option、Config() 返回值的切片变更不改变已构建策略;32 个并发调用者各执行 100 次检查,race 通过 | +| 生成项目 | 未实现 Logic 的 501、404/405、健康检查、OpenAPI 和预检采用同一配置;非法凭据/通配组合在启动前报错 | + +## 浏览器 + +通过 Codex In-app Browser,在两个不同的临时 localhost 页面分别执行 10 项真实 fetch 检查,共 20 项通过。记录见 [corsBrowser.json](corsBrowser.json)。 + +允许来源能读取正常响应、401、404、下载文件名,Authorization 触发的预检通过;不允许的请求头/方法被浏览器拦截。未列入白名单的页面无法读取私有 API;无凭据通配 API 可读取,带 `credentials: include` 的通配请求被拦截。凭据模式测试使用临时演示请求,未设置或使用真实登录 Cookie。 + +复现:在框架根目录 `go run ./scripts/corsBrowser`,打开终端输出的两个页面地址并点击 Run checks。脚本只监听 127.0.0.1,接收退出信号或五分钟后关闭。它用于浏览器协议验证,不是部署服务。本次临时监听已停止。 + +## 性能 + +Go 1.27.1、darwin/arm64、Apple M2 Max,无 race;每场景三轮、benchtime=300ms,表中取中位数。[原始结果](corsBenchmark.txt)。 + +| 场景 | 时间/次 | 分配/次 | +| --- | ---: | ---: | +| 关闭 CORS | 3.357 ns | 0 B / 0 allocs | +| 开启,无 Origin | 108.2 ns | 16 B / 1 alloc | +| 精确来源允许,含两个暴露头 | 231.5 ns | 48 B / 3 allocs | +| 来源不允许 | 130.4 ns | 16 B / 1 alloc | +| 允许预检 | 589.7 ns | 176 B / 7 allocs | +| 通配来源,无凭据 | 510.3 ns | 216 B / 5 allocs | + +关闭时直接返回原 Handler,表中是基准循环与下游空 Handler 的耗时,不能理解为新增延迟。开启时使用预建请求、复用 Header map 的测试 Writer;不包含网络、TLS 或业务处理,不能换算成实际服务 QPS。精确白名单只查初始化时建立的表;通配模式还会验证传入来源是否合法,因此更慢。请求和响应体不进入本模块内存缓冲。 + +```sh +go test ./cors -run '^$' -bench BenchmarkCORS -benchmem -benchtime=300ms -count=3 +go test ./cors -run '^$' -fuzz '^FuzzCORSHeaders$' -fuzztime=30s -parallel=4 +GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run '^TestGeneratedCORSConsumer$' +``` + +## 边界 + +预检按应用策略判断,不探测每条路由。业务访问日志和指标不统计被 CORS 提前结束的预检,可在代理或 App 外层记录。网络解析/TLS/网络过载等进入 App 前的拒绝不由 CORS 改写。 + +默认关闭,同源反向代理可继续使用原部署方式。只支持 HTTP(S) 精确来源或单一无凭据通配来源,不提供子域通配/正则/null 来源。WebSocket Origin、Private Network Access 和对象存储自身的 CORS 属于其他接口,本轮不包含。 + +不批准来源不等于阻止服务端执行实际请求,项目的身份认证、权限和 CSRF 策略仍然必要。代理和应用只选择一处负责 CORS,下游不能覆盖许可头或 Vary。详细用法见 [cors 指南](../cors/README.md)。 diff --git a/reports/corsBenchmark.txt b/reports/corsBenchmark.txt new file mode 100644 index 0000000..9bb3b4b --- /dev/null +++ b/reports/corsBenchmark.txt @@ -0,0 +1,24 @@ +goos: darwin +goarch: arm64 +pkg: git.yuntuo.hk/k2safe/go-omni/cors +cpu: Apple M2 Max +BenchmarkCORS/disabled-12 98300086 3.347 ns/op 0 B/op 0 allocs/op +BenchmarkCORS/disabled-12 100000000 3.650 ns/op 0 B/op 0 allocs/op +BenchmarkCORS/disabled-12 105484652 3.357 ns/op 0 B/op 0 allocs/op +BenchmarkCORS/noOrigin-12 3340696 111.6 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/noOrigin-12 3420523 108.2 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/noOrigin-12 3297222 108.2 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/allowed-12 1557544 232.6 ns/op 48 B/op 3 allocs/op +BenchmarkCORS/allowed-12 1552413 230.4 ns/op 48 B/op 3 allocs/op +BenchmarkCORS/allowed-12 1561892 231.5 ns/op 48 B/op 3 allocs/op +BenchmarkCORS/denied-12 2723037 130.5 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/denied-12 2756702 129.4 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/denied-12 2777206 130.4 ns/op 16 B/op 1 allocs/op +BenchmarkCORS/preflight-12 638340 588.4 ns/op 176 B/op 7 allocs/op +BenchmarkCORS/preflight-12 623992 591.8 ns/op 176 B/op 7 allocs/op +BenchmarkCORS/preflight-12 592320 589.7 ns/op 176 B/op 7 allocs/op +BenchmarkCORS/wildcard-12 708910 510.3 ns/op 216 B/op 5 allocs/op +BenchmarkCORS/wildcard-12 739617 506.8 ns/op 216 B/op 5 allocs/op +BenchmarkCORS/wildcard-12 707361 510.6 ns/op 216 B/op 5 allocs/op +PASS +ok git.yuntuo.hk/k2safe/go-omni/cors 8.971s diff --git a/reports/corsBrowser.json b/reports/corsBrowser.json new file mode 100644 index 0000000..e6913e4 --- /dev/null +++ b/reports/corsBrowser.json @@ -0,0 +1,137 @@ +{ + "browser": "Codex In-app Browser", + "passed": true, + "allowed": { + "allowedPage": true, + "passed": true, + "checks": [ + { + "name": "simple GET", + "pass": true, + "status": 200, + "body": "hello" + }, + { + "name": "401 readable", + "pass": true, + "status": 401, + "body": "{\"code\":\"LOGIN_REQUIRED\",\"message\":\"login required\",\"requestId\":\"8e1c6e6f5b5188f2409cc4ea8ef00b31\"}\n" + }, + { + "name": "authorization preflight", + "pass": true, + "status": 200, + "body": "authenticated" + }, + { + "name": "unknown header rejected", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "unknown method rejected", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "404 readable", + "pass": true, + "status": 404, + "body": "{\"code\":\"NOT_FOUND\",\"message\":\"route not found\",\"requestId\":\"6a0acbdabaa5e41d862f921dc2f9fdf3\"}\n" + }, + { + "name": "credentials mode allowed", + "pass": true, + "status": 200, + "body": "hello" + }, + { + "name": "wildcard without credentials", + "pass": true, + "status": 200, + "body": "public" + }, + { + "name": "wildcard credentials blocked", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "exposed download header", + "pass": true, + "status": 200, + "body": "file bytes" + } + ] + }, + "denied": { + "allowedPage": false, + "passed": true, + "checks": [ + { + "name": "simple GET", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "401 readable", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "authorization preflight", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "unknown header rejected", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "unknown method rejected", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "404 readable", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "credentials mode allowed", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "wildcard without credentials", + "pass": true, + "status": 200, + "body": "public" + }, + { + "name": "wildcard credentials blocked", + "pass": true, + "blocked": true, + "error": "TypeError" + }, + { + "name": "exposed download header", + "pass": true, + "blocked": true, + "error": "TypeError" + } + ] + }, + "scope": "20 loopback checks; credentials mode exercised without setting or inspecting cookies" +} diff --git a/scripts/corsBrowser/main.go b/scripts/corsBrowser/main.go new file mode 100644 index 0000000..da8f9ad --- /dev/null +++ b/scripts/corsBrowser/main.go @@ -0,0 +1,121 @@ +// Run from the framework root; open the printed URLs and click Run checks. +// All listeners are loopback-only and stop after five minutes or SIGINT/SIGTERM. +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + omni "git.yuntuo.hk/k2safe/go-omni" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + var listeners []net.Listener + defer func() { + for _, l := range listeners { + l.Close() + } + }() + for i := 0; i < 4; i++ { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + listeners = append(listeners, l) + } + url := func(i int) string { return "http://" + listeners[i].Addr().String() } + app := omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{url(0)}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}, AllowCredentials: true})) + defer app.Close() + app.GET("/text", func(c *omni.Context) error { return c.Text(200, "hello") }) + app.GET("/private", func(c *omni.Context) error { + if c.Header("Authorization") != "Bearer example-only" { + return omni.Unauthorized("LOGIN_REQUIRED", "login required") + } + return c.Text(200, "authenticated") + }) + app.GET("/download", func(c *omni.Context) error { + c.SetResponseHeader("Content-Disposition", "attachment; filename=example.txt") + return c.Text(200, "file bytes") + }) + public := omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"*"}})) + defer public.Close() + public.GET("/text", func(c *omni.Context) error { return c.Text(200, "public") }) + page := func(allowed bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, _ := json.Marshal(map[string]any{"api": url(2), "publicApi": url(3), "allowed": allowed}) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, strings.Replace(pageSource, "CONFIG_PLACEHOLDER", string(data), 1)) + }) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + errors := make(chan error, 4) + var servers []*http.Server + for i, h := range []http.Handler{page(true), page(false), app, public} { + s := &http.Server{Handler: h, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second} + servers = append(servers, s) + go func(l net.Listener) { errors <- s.Serve(l) }(listeners[i]) + } + defer func() { + for _, s := range servers { + s.Close() + } + }() + fmt.Printf("Allowed page: %s\nDenied page: %s\n", url(0), url(1)) + select { + case <-ctx.Done(): + return nil + case err := <-errors: + if err != http.ErrServerClosed { + return err + } + return nil + } +} + +const pageSource = `GoOmni CORS browser checks +

GoOmni CORS browser checks

Loopback-only, synthetic data; no accounts or credentials required.

+
Not started
+` diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index eb4c18d..cf42100 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -124,7 +124,7 @@ def main(): checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"] env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) - tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer"] + tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer"] print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env) events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] @@ -161,13 +161,16 @@ def main(): for package, expected in { "copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"}, "logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"}, + "cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"}, + "app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports"}, }.items(): output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env) events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] passed = {event.get("Test") for event in events if event.get("Action") == "pass"} assert expected <= passed checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup", - "remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation"] + "remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation", + "remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"] checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"] @@ -188,6 +191,8 @@ def main(): sources += [str(p.relative_to(ROOT)) for p in (ROOT / "logx").glob("*.go")] sources += ["internal/configdiag/error.go", "database/mysql/mysql.go", "database/connector.go", "database/database.go", "contract/policy.go"] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/developerTools").iterdir() if p.is_file()] + sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cors").glob("*.go")] + sources += ["app/cors_test.go", "cmd/go-omni/apigen/corsConsumer_test.go", "cmd/go-omni/apigen/testdata/cors/consumer_test.go", "examples/cors/main.go", "examples/cors/config.yaml", "scripts/corsBrowser/main.go"] report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], "freshModuleCache": True, "noLocalReplace": True, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks,