feat: add configurable transport-independent CORS policy

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

121
scripts/corsBrowser/main.go Normal file
View File

@@ -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 = `<!doctype html><meta charset="utf-8"><title>GoOmni CORS browser checks</title>
<h1>GoOmni CORS browser checks</h1><p>Loopback-only, synthetic data; no accounts or credentials required.</p>
<button id="run">Run checks</button><pre id="results">Not started</pre>
<script>
const cfg=CONFIG_PLACEHOLDER;
document.querySelector('#run').onclick=async()=>{
const results=[];const out=document.querySelector('#results');out.textContent='Running';
async function check(name,path,options,expected,blocked=false,header=false){
try{const r=await fetch(path,options);const body=await r.text();
results.push({name,pass:!blocked&&r.status===expected&&(!header||r.headers.get('Content-Disposition')==='attachment; filename=example.txt'),status:r.status,body});
}catch(e){results.push({name,pass:blocked&&e.name==='TypeError',blocked:true,error:e.name});}
}
await check('simple GET',cfg.api+'/text',{},200,!cfg.allowed);
await check('401 readable',cfg.api+'/private',{},401,!cfg.allowed);
await check('authorization preflight',cfg.api+'/private',{headers:{Authorization:'Bearer example-only'}},200,!cfg.allowed);
await check('unknown header rejected',cfg.api+'/private',{headers:{'X-Not-Allowed':'example'}},0,true);
await check('unknown method rejected',cfg.api+'/text',{method:'DELETE'},0,true);
await check('404 readable',cfg.api+'/missing',{},404,!cfg.allowed);
await check('credentials mode allowed',cfg.api+'/text',{credentials:'include'},200,!cfg.allowed);
await check('wildcard without credentials',cfg.publicApi+'/text',{},200);
await check('wildcard credentials blocked',cfg.publicApi+'/text',{credentials:'include'},0,true);
await check('exposed download header',cfg.api+'/download',{},200,!cfg.allowed,cfg.allowed);
out.textContent=JSON.stringify({allowedPage:cfg.allowed,passed:results.every(r=>r.pass),checks:results},null,2);
};
</script>`

View File

@@ -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,