Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -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,
)
}
}

Expand Down
85 changes: 85 additions & 0 deletions internal/config/project.go
Original file line number Diff line number Diff line change
@@ -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, ", "))
}
128 changes: 128 additions & 0 deletions internal/config/project_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}