feat: add configurable transport-independent CORS policy
This commit is contained in:
121
scripts/corsBrowser/main.go
Normal file
121
scripts/corsBrowser/main.go
Normal 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>`
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user