diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8b21e1c5..c0f426ea 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -22,6 +22,7 @@ import ( "io/fs" "log" "os" + "path/filepath" "strings" "time" @@ -414,15 +415,25 @@ func initConfig() { // If no configFile is passed: // 1. Check local git repo for a config file // 2. If not in a git repo. Check working directory for a config file + // Only org/database/branch are accepted from project-scoped files — never + // api-url, tokens, or other security-sensitive settings. if cfgFile == "" { + var projectDir string if rootDir, err := config.RootGitRepoDir(); err == nil { - viper.AddConfigPath(rootDir) - viper.SetConfigName(config.ProjectConfigFile()) - _ = viper.MergeInConfig() + projectDir = rootDir } else if localDir, err := config.LocalDir(); err == nil { - viper.AddConfigPath(localDir) - viper.SetConfigName(config.ProjectConfigFile()) - _ = viper.MergeInConfig() + projectDir = localDir + } + if projectDir != "" { + ignored, err := config.MergeProjectConfig(viper.GetViper(), projectDir) + if err != nil { + fmt.Println(err) + os.Exit(cmdutil.FatalErrExitCode) + } + config.WarnIgnoredProjectConfigKeys( + filepath.Join(projectDir, config.ProjectConfigFile()), + ignored, + ) } } diff --git a/internal/config/project.go b/internal/config/project.go new file mode 100644 index 00000000..bcb4cebc --- /dev/null +++ b/internal/config/project.go @@ -0,0 +1,85 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/viper" + "gopkg.in/yaml.v2" +) + +// projectConfigAllowedKeys are the only keys accepted from a repo-local or +// working-directory .pscale.yml. Credentials and API endpoints must never come +// from directory-local files — a checked-in config can otherwise redirect +// requests (and the user's keyring token) to an attacker-controlled host. +var projectConfigAllowedKeys = map[string]struct{}{ + "org": {}, + "database": {}, + "branch": {}, +} + +// FilterProjectConfig keeps only allowlisted project settings from raw YAML. +// Non-allowlisted keys are returned in ignored (sorted) for caller warnings. +func FilterProjectConfig(raw map[string]interface{}) (allowed map[string]interface{}, ignored []string) { + allowed = make(map[string]interface{}) + for k, v := range raw { + key := strings.ToLower(strings.TrimSpace(k)) + if _, ok := projectConfigAllowedKeys[key]; ok { + allowed[key] = v + continue + } + ignored = append(ignored, k) + } + sort.Strings(ignored) + return allowed, ignored +} + +// MergeProjectConfig reads dir/.pscale.yml and merges only allowlisted keys into +// v. Sensitive keys such as api-url / api-token / service-token are ignored. +// Missing files are a no-op. Returns the list of ignored keys (may be empty). +func MergeProjectConfig(v *viper.Viper, dir string) (ignored []string, err error) { + if dir == "" { + return nil, nil + } + path := filepath.Join(dir, projectConfigName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil, nil + } + + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if len(raw) == 0 { + return nil, nil + } + + allowed, ignored := FilterProjectConfig(raw) + if len(allowed) == 0 { + return ignored, nil + } + if err := v.MergeConfigMap(allowed); err != nil { + return ignored, err + } + return ignored, nil +} + +// WarnIgnoredProjectConfigKeys prints a stderr warning when a project config +// tried to set keys outside the allowlist (especially credentials / api-url). +func WarnIgnoredProjectConfigKeys(path string, ignored []string) { + if len(ignored) == 0 { + return + } + fmt.Fprintf(os.Stderr, "Warning: ignoring non-allowlisted keys in %s: %s (project config may only set: org, database, branch)\n", + path, strings.Join(ignored, ", ")) +} diff --git a/internal/config/project_test.go b/internal/config/project_test.go new file mode 100644 index 00000000..82977360 --- /dev/null +++ b/internal/config/project_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestFilterProjectConfigAllowlist(t *testing.T) { + raw := map[string]interface{}{ + "org": "acme", + "database": "shop", + "branch": "main", + "api-url": "https://evil.example.com", + "api-token": "stolen", + "service-token": "tok", + "service-token-id": "id", + "service-token-name": "legacy", + "format": "json", + "debug": true, + } + allowed, ignored := FilterProjectConfig(raw) + if len(allowed) != 3 { + t.Fatalf("allowed = %#v, want org/database/branch only", allowed) + } + if allowed["org"] != "acme" || allowed["database"] != "shop" || allowed["branch"] != "main" { + t.Fatalf("allowed values = %#v", allowed) + } + for _, key := range []string{"api-url", "api-token", "service-token", "service-token-id", "service-token-name", "format", "debug"} { + found := false + for _, ig := range ignored { + if ig == key { + found = true + break + } + } + if !found { + t.Fatalf("expected %q in ignored keys %#v", key, ignored) + } + } +} + +func TestFilterProjectConfigCaseInsensitive(t *testing.T) { + allowed, ignored := FilterProjectConfig(map[string]interface{}{ + "ORG": "acme", + "Api-Url": "https://evil.example.com", + }) + if allowed["org"] != "acme" { + t.Fatalf("ORG should map to org allowlist entry, got %#v", allowed) + } + if len(ignored) != 1 || ignored[0] != "Api-Url" { + t.Fatalf("ignored = %#v", ignored) + } +} + +func TestMergeProjectConfigIgnoresSensitiveKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, projectConfigName) + content := "" + + "org: victim-org\n" + + "database: shop\n" + + "branch: main\n" + + "api-url: https://evil.example.com\n" + + "api-token: attacker-token\n" + + "service-token: pscale_tkn_evil\n" + + "service-token-id: abc123xyz999\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + v := viper.New() + v.Set("api-url", "https://api.planetscale.com/") + ignored, err := MergeProjectConfig(v, dir) + if err != nil { + t.Fatal(err) + } + if v.GetString("org") != "victim-org" { + t.Fatalf("org = %q", v.GetString("org")) + } + if v.GetString("database") != "shop" || v.GetString("branch") != "main" { + t.Fatalf("database/branch not merged: %q %q", v.GetString("database"), v.GetString("branch")) + } + if got := v.GetString("api-url"); got != "https://api.planetscale.com/" { + t.Fatalf("api-url overridden by project config: %q", got) + } + if v.IsSet("api-token") && v.GetString("api-token") != "" { + t.Fatalf("api-token must not come from project config, got %q", v.GetString("api-token")) + } + if v.GetString("service-token") != "" || v.GetString("service-token-id") != "" { + t.Fatalf("service token fields must not come from project config") + } + joined := strings.Join(ignored, ",") + for _, key := range []string{"api-url", "api-token", "service-token", "service-token-id"} { + if !strings.Contains(joined, key) { + t.Fatalf("ignored=%v, want %q reported", ignored, key) + } + } +} + +func TestMergeProjectConfigMissingFile(t *testing.T) { + v := viper.New() + ignored, err := MergeProjectConfig(v, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if len(ignored) != 0 { + t.Fatalf("ignored = %v", ignored) + } +} + +func TestMergeProjectConfigDoesNotOverrideHomeApiURL(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, projectConfigName), []byte("org: acme\napi-url: https://evil.example.com\n"), 0o600); err != nil { + t.Fatal(err) + } + v := viper.New() + // Simulate a user home config / prior value that must win over project files. + v.Set("api-url", "https://api.planetscale.com/") + if _, err := MergeProjectConfig(v, dir); err != nil { + t.Fatal(err) + } + if got := v.GetString("api-url"); got != "https://api.planetscale.com/" { + t.Fatalf("project api-url leaked into viper: %q", got) + } +}