diff --git a/README.md b/README.md index 7d316d3..0ccb3d8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Global flags: `--config`/`-c` (config file), `--debug`/`-d` and | `job-search config get ` | Print one value, e.g. `logging.level` | | `job-search config set ` | Validate and write one value to the config file | | `job-search config set --unset ` | Remove a value from the config file | +| `job-search config generate-csrf-key` | Store a new random `server.csrf.tokens.key` in the config file, so CSRF tokens survive restarts and work across instances (`--print` only prints one; `--force` replaces an existing key) | | `job-search migrate` | Apply every pending database migration (same as `migrate up`) | | `job-search migrate up` | Apply pending migrations (`--steps N` for only N; `--create` to create a missing database file without asking) | | `job-search migrate down --steps N` | Roll back the last N migrations (`--steps 0` rolls back all of them, deleting all data, and needs `--yes`) | diff --git a/internal/cli/config/command.go b/internal/cli/config/command.go index 4b7944c..01a239a 100644 --- a/internal/cli/config/command.go +++ b/internal/cli/config/command.go @@ -25,6 +25,7 @@ func NewCommand(opts *cli.Options) *cobra.Command { cmd.AddCommand(commandGet(opts)) cmd.AddCommand(commandSet(opts)) cmd.AddCommand(commandInit()) + cmd.AddCommand(commandGenerateCSRFKey(opts)) return cmd } diff --git a/internal/cli/config/csrf_key.go b/internal/cli/config/csrf_key.go new file mode 100644 index 0000000..9a05851 --- /dev/null +++ b/internal/cli/config/csrf_key.go @@ -0,0 +1,129 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/config" +) + +// csrfKeySetting is the setting generate-csrf-key stores the key in. +const csrfKeySetting = "server.csrf.tokens.key" + +// csrfKeyBytes is the length of a generated key: 32 bytes, as the server +// generates for itself when no key is set. +const csrfKeyBytes = 32 + +// commandGenerateCSRFKey returns the "config generate-csrf-key" command, +// which stores a new random CSRF key in the configuration file, or prints it. +func commandGenerateCSRFKey(opts *cli.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-csrf-key", + Args: cobra.NoArgs, + Short: "Generate a CSRF key and store it in the configuration file", + Long: `Generate a random key for signing CSRF tokens and store it in +server.csrf.tokens.key in the configuration file. Without a key, each server +process signs with a random key of its own, so tokens don't survive a restart +and aren't valid across instances. Give every instance the same key, and +restart them to use it. + +The key isn't printed. With --print, it is only printed, and the +configuration file isn't changed: use that to set +JOBSEARCH_SERVER_CSRF_TOKENS_KEY instead, or another instance's file. + +An existing key is only replaced with --force, as a new key invalidates +every token already issued. A key set by JOBSEARCH_SERVER_CSRF_TOKENS_KEY +overrides the file, so the command refuses to store one while it is set.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + slog.Debug( + "command persistent pre-run", "command", "config.generate-csrf-key", + ) + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + slog.Debug( + "command persistent post-run", "command", "config.generate-csrf-key", + ) + }, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("command run", "command", "config.generate-csrf-key") + + printOnly, printErr := cmd.Flags().GetBool("print") + force, forceErr := cmd.Flags().GetBool("force") + err := errors.Join(printErr, forceErr) + if err != nil { + return err + } + + key := generateCSRFKey() + if printOnly { + fmt.Fprintln(cmd.OutOrStdout(), key) //nolint:errcheck // printing only + return nil + } + + envVar := config.EnvVarName(csrfKeySetting) + if os.Getenv(envVar) != "" { + return fmt.Errorf( + "%s is set and overrides the configuration file: "+ + "unset it first, or use --print to make a key for it", + envVar, + ) + } + + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + if viper.ConfigFileUsed() == "" { + return errors.New("no configuration file exists") + } + if conf.Server.CSRF.Tokens.Key != "" && !force { + return fmt.Errorf( + "%s is already set: --force replaces it, which invalidates "+ + "every token already issued", + csrfKeySetting, + ) + } + + _, _, err = config.StoreValue(conf, csrfKeySetting, key) + if err != nil { + return err + } + + fmt.Fprintf( //nolint:errcheck // printing only + cmd.OutOrStdout(), + "%s set in %s; restart the server to use it, and give every "+ + "instance the same key\n", + csrfKeySetting, + viper.ConfigFileUsed(), + ) + + return nil + }, + } + cmd.Flags().Bool( + "print", false, "only print the key; don't change the configuration file", + ) + cmd.Flags().Bool( + "force", false, "replace an existing key, invalidating issued tokens", + ) + cmd.MarkFlagsMutuallyExclusive("print", "force") + + return cmd +} + +// generateCSRFKey returns a new random key, hex encoded as +// server.csrf.tokens.key expects. +func generateCSRFKey() string { + key := make([]byte, csrfKeyBytes) + rand.Read(key) // crypto/rand.Read never returns an error + + return hex.EncodeToString(key) +} diff --git a/internal/cli/config/csrf_key_test.go b/internal/cli/config/csrf_key_test.go new file mode 100644 index 0000000..02926cb --- /dev/null +++ b/internal/cli/config/csrf_key_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "encoding/hex" + "strings" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" +) + +// fileCSRFKey returns the CSRF key in config.yaml, or "" if it has none. +func fileCSRFKey(t *testing.T) string { + t.Helper() + key, _ := fileSettings(t, "config.yaml")[csrfKeySetting].(string) + return key +} + +// checkCSRFKey fails the test unless key is csrfKeyBytes of hex. +func checkCSRFKey(t *testing.T, key string) { + t.Helper() + decoded, err := hex.DecodeString(key) + if err != nil || len(decoded) != csrfKeyBytes { + t.Fatalf("key %q is not %d bytes of hex", key, csrfKeyBytes) + } +} + +// The key is stored in the configuration file, without being printed, and +// the file's other settings are kept. +func TestGenerateCSRFKey(t *testing.T) { + cmdtest.Setup(t, appConfig) + + out := mustRun(t, "config", "generate-csrf-key") + cmdtest.Contains(t, out, csrfKeySetting+" set in ") + + key := fileCSRFKey(t) + checkCSRFKey(t, key) + if strings.Contains(out, key) { + t.Errorf("the key was printed: %s", out) + } + if fileSettings(t, "config.yaml")["sources.sleeper.interval"] != "2m" { + t.Errorf("other settings were changed: %v", fileSettings(t, "config.yaml")) + } +} + +// An existing key is only replaced with --force. +func TestGenerateCSRFKeyExisting(t *testing.T) { + cmdtest.Setup(t, appConfig) + mustRun(t, "config", "generate-csrf-key") + first := fileCSRFKey(t) + + _, err := run(t, "config", "generate-csrf-key") + cmdtest.ErrContains(t, err, "already set: --force replaces it") + if fileCSRFKey(t) != first { + t.Fatal("the key was replaced without --force") + } + + mustRun(t, "config", "generate-csrf-key", "--force") + second := fileCSRFKey(t) + checkCSRFKey(t, second) + if second == first { + t.Error("--force kept the same key") + } +} + +// --print only prints a key, and needs no configuration file. +func TestGenerateCSRFKeyPrint(t *testing.T) { + cmdtest.Setup(t, "") + + key := strings.TrimSpace(mustRun(t, "config", "generate-csrf-key", "--print")) + checkCSRFKey(t, key) + + other := strings.TrimSpace(mustRun(t, "config", "generate-csrf-key", "--print")) + if other == key { + t.Error("two keys were the same") + } +} + +// A key in the environment variable would override the file, so none is +// stored while it is set. +func TestGenerateCSRFKeyEnvironment(t *testing.T) { + cmdtest.Setup(t, appConfig) + t.Setenv("JOBSEARCH_SERVER_CSRF_TOKENS_KEY", generateCSRFKey()) + + _, err := run(t, "config", "generate-csrf-key") + cmdtest.ErrContains(t, err, "JOBSEARCH_SERVER_CSRF_TOKENS_KEY is set") + if fileCSRFKey(t) != "" { + t.Error("a key was stored") + } +} + +func TestGenerateCSRFKeyWithoutConfigFile(t *testing.T) { + cmdtest.Setup(t, "") + _, err := run(t, "config", "generate-csrf-key") + cmdtest.ErrContains(t, err, "no configuration file exists") +} + +func TestGenerateCSRFKeyFlags(t *testing.T) { + cmdtest.Setup(t, appConfig) + _, err := run(t, "config", "generate-csrf-key", "--print", "--force") + cmdtest.ErrContains(t, err, "none of the others") + if _, err := run(t, "config", "generate-csrf-key", "extra"); err == nil { + t.Error("an argument was accepted") + } +} diff --git a/internal/config/README.md b/internal/config/README.md index 11fd64f..0841e0c 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -141,7 +141,7 @@ The `server` section is split into a number of sub-sections: | `server.csrf.cross_origin_protection.enabled` | bool | `true` | Reject cross-origin unsafe requests (`Sec-Fetch-Site`/`Origin`) | | `server.csrf.cross_origin_protection.trusted_origins` | list | | Extra origins (`https://admin.example.com`) allowed to make them, and to open WebSockets; empty: none | | `server.csrf.tokens.enabled` | bool | `true` | Require a signed double-submit token on unsafe requests | -| `server.csrf.tokens.key` | string | | Hex HMAC key; empty: a random per-process key, so tokens don't survive a restart | +| `server.csrf.tokens.key` | string | | Hex HMAC key; empty: a random per-process key, so tokens don't survive a restart. `job-search config generate-csrf-key` stores one | | `server.csrf.tokens.cookie_name` | string | `js_csrf` | Empty: `js_csrf` | | `server.csrf.tokens.header_name` | string | `X-CSRF-Token` | Empty: `X-CSRF-Token` | | `server.csrf.tokens.token_ttl` | duration | `12h` | `0s`: 12h | diff --git a/internal/config/load.go b/internal/config/load.go index 782e4d9..fa47f70 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -15,9 +15,9 @@ import ( // (logging.level to LOGGING_LEVEL, once upper-cased). var envKeyReplacer = strings.NewReplacer(".", "_") -// envVarName returns the environment variable that sets key, e.g. +// EnvVarName returns the environment variable that sets key, e.g. // JOBSEARCH_LOGGING_LEVEL for logging.level. -func envVarName(key string) string { +func EnvVarName(key string) string { return EnvPrefix + "_" + strings.ToUpper(envKeyReplacer.Replace(key)) } @@ -29,7 +29,7 @@ func isExplicitlySet(key string) bool { if viper.InConfig(key) { return true } - value, ok := os.LookupEnv(envVarName(key)) + value, ok := os.LookupEnv(EnvVarName(key)) return ok && value != "" } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index cf938ca..87a51e8 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -200,8 +200,8 @@ func TestEnvVarName(t *testing.T) { "server.websocket.connection_limit.enabled": "JOBSEARCH_SERVER_WEBSOCKET_CONNECTION_LIMIT_ENABLED", } for key, want := range tests { - if got := envVarName(key); got != want { - t.Errorf("envVarName(%q) = %q; want %q", key, got, want) + if got := EnvVarName(key); got != want { + t.Errorf("EnvVarName(%q) = %q; want %q", key, got, want) } } }