From 33a8d5a635aa172162bc60c93f31d2c3d950a63e Mon Sep 17 00:00:00 2001 From: dawn Date: Sat, 3 Oct 2026 21:14:18 +0300 Subject: [PATCH] spindle: opt-in cors on xrpc via SPINDLE_SERVER_CORS_ORIGINS Signed-off-by: dawn --- docker-compose.yml | 1 + docs/DOCS.md | 1 + go.mod | 3 +- go.sum | 2 + nix/gomod2nix.toml | 3 ++ nix/modules/spindle.nix | 8 ++++ spindle/config/config.go | 1 + spindle/xrpc/cors_test.go | 86 +++++++++++++++++++++++++++++++++++++++ spindle/xrpc/xrpc.go | 12 ++++++ 9 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 spindle/xrpc/cors_test.go diff --git a/docker-compose.yml b/docker-compose.yml index b6106fb7..3ea02cad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -285,6 +285,7 @@ services: SPINDLE_SERVER_JETSTREAM_ENDPOINT: wss://hydrant.tngl.boltless.dev/subscribe SPINDLE_SERVER_DEV: "true" SPINDLE_SERVER_DEV_EXTRA_HOSTS: knot2.tngl.boltless.dev,mirror.tngl.boltless.dev + SPINDLE_SERVER_CORS_ORIGINS: "*" SPINDLE_SERVER_TAP_DB_PATH: /var/lib/spindle/tap.db SPINDLE_SERVER_TAP_RELAY_URL: https://pds.tngl.boltless.dev SPINDLE_MICROVM_PIPELINES_IMAGE_DIR: /var/lib/spindle/images diff --git a/docs/DOCS.md b/docs/DOCS.md index 9d7e88c5..bc8e7753 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -1946,6 +1946,7 @@ Spindle is configured using environment variables. The following environment var - `SPINDLE_SERVER_OWNER`: The DID of the owner (required). - `SPINDLE_SERVER_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). - `SPINDLE_SERVER_DOCKER_SOCKET`: Path to Docker socket to expose to invoked Spindle containers (default: `""`). +- `SPINDLE_SERVER_CORS_ORIGINS`: Comma-separated origins allowed to call the XRPC API from a browser, or `*` for any. Needed for web frontends that talk to Spindle directly (default: `""`, CORS disabled). - `SPINDLE_NIXERY_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). - `SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). diff --git a/go.mod b/go.mod index 9a53c732..92c14aa4 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.2.0 + github.com/go-chi/cors v1.2.2 github.com/go-enry/go-enry/v2 v2.9.6 github.com/go-git/go-git/v5 v5.19.0 github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 @@ -80,6 +81,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.6 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b go.abhg.dev/goldmark/mermaid v0.6.0 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 @@ -305,7 +307,6 @@ require ( github.com/wasilibs/go-re2 v1.10.0 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.etcd.io/bbolt v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index b0b095f4..9d1318cb 100644 --- a/go.sum +++ b/go.sum @@ -303,6 +303,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs= github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml index f8a96134..8f57aeb4 100644 --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -364,6 +364,9 @@ schema = 3 [mod."github.com/go-chi/chi/v5"] version = "v5.2.0" hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A=" + [mod."github.com/go-chi/cors"] + version = "v1.2.2" + hash = "sha256-uLbgPWK2WvrfDD+2LMbh5himuoidBPwDdnDE6agfuBo=" [mod."github.com/go-enry/go-enry/v2"] version = "v2.9.6" hash = "sha256-iQWIFp+ddOsKdLn7ObdMmshEMJFcGw9hEzwQH2RNyF4=" diff --git a/nix/modules/spindle.nix b/nix/modules/spindle.nix index 434e21e9..ddc11397 100644 --- a/nix/modules/spindle.nix +++ b/nix/modules/spindle.nix @@ -83,6 +83,13 @@ in description = "Maximum number of jobs queue up"; }; + corsOrigins = mkOption { + type = types.listOf types.str; + default = []; + example = ["https://tangled.org"]; + description = "Origins browsers may call the xrpc api from, \"*\" allows any. Empty disables CORS."; + }; + secrets = { provider = mkOption { type = types.str; @@ -724,6 +731,7 @@ in "SPINDLE_SERVER_OWNER=${instance.server.owner}" "SPINDLE_SERVER_MAX_JOB_COUNT=${toString instance.server.maxJobCount}" "SPINDLE_SERVER_QUEUE_SIZE=${toString instance.server.queueSize}" + "SPINDLE_SERVER_CORS_ORIGINS=${concatStringsSep "," instance.server.corsOrigins}" "SPINDLE_SERVER_SECRETS_PROVIDER=${instance.server.secrets.provider}" "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${instance.server.secrets.openbao.proxyAddr}" "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${instance.server.secrets.openbao.mount}" diff --git a/spindle/config/config.go b/spindle/config/config.go index 7c9d0e84..61d26302 100644 --- a/spindle/config/config.go +++ b/spindle/config/config.go @@ -29,6 +29,7 @@ type Server struct { QueueSize int `env:"QUEUE_SIZE, default=100"` MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of pipelines that run at a time DockerSocket string `env:"DOCKER_SOCKET"` // path to a docker socket to expose to workflow containers + CorsOrigins []string `env:"CORS_ORIGINS"` // origins browsers may call xrpc from, "*" for any. empty leaves cors off } type Tap struct { diff --git a/spindle/xrpc/cors_test.go b/spindle/xrpc/cors_test.go new file mode 100644 index 00000000..75a88875 --- /dev/null +++ b/spindle/xrpc/cors_test.go @@ -0,0 +1,86 @@ +package xrpc + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "tangled.org/core/api/tangled" + "tangled.org/core/spindle/config" + "tangled.org/core/xrpc/serviceauth" +) + +func corsRouter(origins ...string) http.Handler { + cfg := &config.Config{} + cfg.Server.CorsOrigins = origins + x := &Xrpc{ + Config: cfg, + ServiceAuth: serviceauth.NewServiceAuth(slog.Default(), nil, "did:web:spindle.test"), + } + return x.Router() +} + +func preflight(router http.Handler, origin, nsid, method string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodOptions, "/"+nsid, nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", method) + req.Header.Set("Access-Control-Request-Headers", "authorization") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestCorsOffByDefault(t *testing.T) { + rec := preflight(corsRouter(), "https://tangled.org", tangled.RepoListSecretsNSID, http.MethodGet) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("allow-origin %q with no origins configured", got) + } +} + +func TestCorsAnyOrigin(t *testing.T) { + router := corsRouter("*") + + for _, tc := range []struct{ nsid, method string }{ + {tangled.RepoListSecretsNSID, http.MethodGet}, + {tangled.RepoAddSecretNSID, http.MethodPost}, + } { + rec := preflight(router, "https://tangled.org", tc.nsid, tc.method) + if rec.Code >= 300 { + t.Errorf("%s preflight: status %d", tc.nsid, rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("%s preflight: allow-origin %q", tc.nsid, got) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); got == "" { + t.Errorf("%s preflight: no allow-headers", tc.nsid) + } + } +} + +func TestCorsListedOrigins(t *testing.T) { + router := corsRouter("https://tangled.org") + + rec := preflight(router, "https://tangled.org", tangled.RepoListSecretsNSID, http.MethodGet) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://tangled.org" { + t.Errorf("listed origin: allow-origin %q", got) + } + + rec = preflight(router, "https://evil.example", tangled.RepoListSecretsNSID, http.MethodGet) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("unlisted origin: allow-origin %q", got) + } +} + +func TestCorsHeadersOnRejectedRequests(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/"+tangled.RepoListSecretsNSID, nil) + req.Header.Set("Origin", "https://tangled.org") + req.Header.Set("Authorization", "Bearer not.a.jwt") + rec := httptest.NewRecorder() + corsRouter("*").ServeHTTP(rec, req) + + // without the header the browser hides the xrpc error body and the web app only sees a network error + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("allow-origin %q on a %d", got, rec.Code) + } +} diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index b66f6454..959cbf0b 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -11,6 +11,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" "tangled.org/core/api/org_tangled" "tangled.org/core/api/tangled" @@ -79,6 +80,17 @@ type Xrpc struct { func (x *Xrpc) Router() http.Handler { r := chi.NewRouter() + // browsers preflight anything carrying a service auth header, and without an + // answer they drop the real request before it leaves + if x.Config != nil && len(x.Config.Server.CorsOrigins) > 0 { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: x.Config.Server.CorsOrigins, + AllowedMethods: []string{http.MethodGet, http.MethodPost}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + MaxAge: 86400, + })) + } + r.Get("/"+org_tangled.CiGetWorkflowDefinitionNSID, x.handleOrgTangledCiGetWorkflowDefinition) r.Get("/"+org_tangled.CiListWorkflowDefinitionsNSID, x.handleOrgTangledCiListWorkflowDefinitions) r.Get("/"+org_tangled.CiSubscribePipelineLogsNSID, x.handleOrgTangledCiSubscribePipelineLogs) -- 2.51.2