fix(config): harden default JWT secret
Replace known development JWT secret placeholders with an ephemeral runtime secret during config loading. Return LoadInfo so startup can warn when token persistence depends on a stable configured secret. Update config docs and tests for default, legacy placeholder, custom, env, and empty secret behavior.
This commit is contained in:
+6
-1
@@ -23,7 +23,7 @@ var serveCmd = &cobra.Command{
|
|||||||
Short: "Start the MyGO HTTP server",
|
Short: "Start the MyGO HTTP server",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
v := config.New()
|
v := config.New()
|
||||||
cfg, err := config.Load(v, serveConfigFile)
|
cfg, loadInfo, err := config.Load(v, serveConfigFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load config: %w", err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,11 @@ var serveCmd = &cobra.Command{
|
|||||||
appLogger := mygolog.NewLogger(cfg.Log)
|
appLogger := mygolog.NewLogger(cfg.Log)
|
||||||
slog.SetDefault(appLogger)
|
slog.SetDefault(appLogger)
|
||||||
slog.Info("mygo server starting")
|
slog.Info("mygo server starting")
|
||||||
|
if loadInfo.EphemeralJWTSecret {
|
||||||
|
slog.Warn("jwt.secret is a development placeholder; using an ephemeral runtime secret. " +
|
||||||
|
"Set a stable jwt.secret or MYGO_JWT_SECRET for production, multi-instance deployments, " +
|
||||||
|
"and token persistence across restarts")
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
+3
-1
@@ -28,6 +28,8 @@ log:
|
|||||||
file_level: debug # file: debug, info, warn, error
|
file_level: debug # file: debug, info, warn, error
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: change-me-in-production
|
# Development placeholder. MyGO replaces this with an ephemeral runtime
|
||||||
|
# secret at startup; set a stable value for production.
|
||||||
|
secret: dev-secret-do-not-use-in-production
|
||||||
access_ttl: 15m
|
access_ttl: 15m
|
||||||
refresh_ttl: 168h
|
refresh_ttl: 168h
|
||||||
|
|||||||
@@ -59,9 +59,11 @@
|
|||||||
|----------|----------|
|
|----------|----------|
|
||||||
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. |
|
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. |
|
||||||
| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. |
|
| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. |
|
||||||
|
| Default JWT secret hardening | The development placeholder `jwt.secret` is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret. |
|
||||||
| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. |
|
| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. |
|
||||||
|
|
||||||
**Consequences**:
|
**Consequences**:
|
||||||
- Handlers are independently extensible (caching, rate limiting scoped per handler).
|
- Handlers are independently extensible (caching, rate limiting scoped per handler).
|
||||||
- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
|
- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
|
||||||
|
- The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart.
|
||||||
- New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
|
- New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
|
||||||
|
|||||||
+6
-1
@@ -52,9 +52,14 @@ storage:
|
|||||||
path: data/files
|
path: data/files
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: changeme-in-production
|
secret: dev-secret-do-not-use-in-production
|
||||||
access_ttl: 15m
|
access_ttl: 15m
|
||||||
refresh_ttl: 168h
|
refresh_ttl: 168h
|
||||||
```
|
```
|
||||||
|
|
||||||
Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...`
|
Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...`
|
||||||
|
|
||||||
|
The default JWT secret is a development placeholder. At startup, MyGO replaces
|
||||||
|
it with an ephemeral runtime secret; set a stable `jwt.secret` or
|
||||||
|
`MYGO_JWT_SECRET` when tokens must survive restarts or when running multiple
|
||||||
|
instances.
|
||||||
|
|||||||
+55
-6
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,6 +10,17 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultJWTSecret is a development-only placeholder. Load replaces it with
|
||||||
|
// an ephemeral secret before returning a usable Config.
|
||||||
|
const DefaultJWTSecret = "dev-secret-do-not-use-in-production"
|
||||||
|
|
||||||
|
// LoadInfo describes runtime decisions made while loading configuration.
|
||||||
|
type LoadInfo struct {
|
||||||
|
// EphemeralJWTSecret is true when a placeholder jwt.secret was replaced
|
||||||
|
// with a random in-memory value for the current process.
|
||||||
|
EphemeralJWTSecret bool
|
||||||
|
}
|
||||||
|
|
||||||
func defaults(v *viper.Viper) {
|
func defaults(v *viper.Viper) {
|
||||||
v.SetDefault("server.host", "0.0.0.0")
|
v.SetDefault("server.host", "0.0.0.0")
|
||||||
v.SetDefault("server.port", 10086)
|
v.SetDefault("server.port", 10086)
|
||||||
@@ -24,7 +37,7 @@ func defaults(v *viper.Viper) {
|
|||||||
v.SetDefault("storage.driver", "local")
|
v.SetDefault("storage.driver", "local")
|
||||||
v.SetDefault("storage.local.path", "data/files")
|
v.SetDefault("storage.local.path", "data/files")
|
||||||
|
|
||||||
v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production")
|
v.SetDefault("jwt.secret", DefaultJWTSecret)
|
||||||
v.SetDefault("jwt.access_ttl", "15m")
|
v.SetDefault("jwt.access_ttl", "15m")
|
||||||
v.SetDefault("jwt.refresh_ttl", "168h")
|
v.SetDefault("jwt.refresh_ttl", "168h")
|
||||||
|
|
||||||
@@ -42,7 +55,7 @@ func New() *viper.Viper {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(v *viper.Viper, cfgFile string) (*Config, error) {
|
func Load(v *viper.Viper, cfgFile string) (*Config, LoadInfo, error) {
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
v.SetConfigFile(cfgFile)
|
v.SetConfigFile(cfgFile)
|
||||||
} else {
|
} else {
|
||||||
@@ -54,18 +67,54 @@ func Load(v *viper.Viper, cfgFile string) (*Config, error) {
|
|||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
var notFound viper.ConfigFileNotFoundError
|
var notFound viper.ConfigFileNotFoundError
|
||||||
if !errors.As(err, ¬Found) {
|
if !errors.As(err, ¬Found) {
|
||||||
return nil, fmt.Errorf("read config: %w", err)
|
return nil, LoadInfo{}, fmt.Errorf("read config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
return nil, LoadInfo{}, fmt.Errorf("unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := hardenRuntimeSecrets(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, LoadInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("validate config: %w", err)
|
return nil, LoadInfo{}, fmt.Errorf("validate config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hardenRuntimeSecrets(cfg *Config) (LoadInfo, error) {
|
||||||
|
if !isWeakJWTSecret(cfg.JWT.Secret) {
|
||||||
|
return LoadInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := generateEphemeralJWTSecret()
|
||||||
|
if err != nil {
|
||||||
|
return LoadInfo{}, fmt.Errorf("generate ephemeral jwt secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.JWT.Secret = secret
|
||||||
|
return LoadInfo{EphemeralJWTSecret: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWeakJWTSecret(secret string) bool {
|
||||||
|
switch secret {
|
||||||
|
case DefaultJWTSecret, "change-me-in-production", "changeme-in-production":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEphemeralJWTSecret() (string, error) {
|
||||||
|
var secret [32]byte
|
||||||
|
if _, err := rand.Read(secret[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(secret[:]), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
func TestDefaults(t *testing.T) {
|
func TestDefaults(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
cfg, err := Load(v, "")
|
cfg, info, err := Load(v, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,16 @@ func TestDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !info.EphemeralJWTSecret {
|
||||||
|
t.Fatal("expected default jwt.secret to be replaced with an ephemeral secret")
|
||||||
|
}
|
||||||
|
if cfg.JWT.Secret == DefaultJWTSecret {
|
||||||
|
t.Fatal("default jwt.secret was not replaced")
|
||||||
|
}
|
||||||
|
if cfg.JWT.Secret == "" {
|
||||||
|
t.Fatal("ephemeral jwt.secret is empty")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromYAML(t *testing.T) {
|
func TestFromYAML(t *testing.T) {
|
||||||
@@ -67,10 +77,13 @@ jwt:
|
|||||||
}
|
}
|
||||||
|
|
||||||
v := New()
|
v := New()
|
||||||
cfg, err := Load(v, path)
|
cfg, info, err := Load(v, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if info.EphemeralJWTSecret {
|
||||||
|
t.Fatal("custom jwt.secret should not be replaced")
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Server.Host != "127.0.0.1" {
|
if cfg.Server.Host != "127.0.0.1" {
|
||||||
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
|
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
|
||||||
@@ -102,10 +115,13 @@ func TestEnvOverride(t *testing.T) {
|
|||||||
t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite")
|
t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite")
|
||||||
|
|
||||||
v := New()
|
v := New()
|
||||||
cfg, err := Load(v, "")
|
cfg, info, err := Load(v, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if info.EphemeralJWTSecret {
|
||||||
|
t.Fatal("env jwt.secret should not be replaced")
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Server.Port != 8080 {
|
if cfg.Server.Port != 8080 {
|
||||||
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
|
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
|
||||||
@@ -137,7 +153,7 @@ server:
|
|||||||
t.Setenv("MYGO_SERVER_PORT", "9999")
|
t.Setenv("MYGO_SERVER_PORT", "9999")
|
||||||
|
|
||||||
v := New()
|
v := New()
|
||||||
cfg, err := Load(v, path)
|
cfg, _, err := Load(v, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +180,7 @@ server:
|
|||||||
}
|
}
|
||||||
|
|
||||||
v := New()
|
v := New()
|
||||||
_, err := Load(v, path)
|
_, _, err := Load(v, path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for port 0, got nil")
|
t.Fatal("expected error for port 0, got nil")
|
||||||
}
|
}
|
||||||
@@ -183,7 +199,7 @@ jwt:
|
|||||||
}
|
}
|
||||||
|
|
||||||
v := New()
|
v := New()
|
||||||
_, err := Load(v, path)
|
_, _, err := Load(v, path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty jwt.secret, got nil")
|
t.Fatal("expected error for empty jwt.secret, got nil")
|
||||||
}
|
}
|
||||||
@@ -191,12 +207,48 @@ jwt:
|
|||||||
|
|
||||||
func TestExplicitConfigFileNotFound(t *testing.T) {
|
func TestExplicitConfigFileNotFound(t *testing.T) {
|
||||||
v := New()
|
v := New()
|
||||||
_, err := Load(v, "/nonexistent/path/config.yaml")
|
_, _, err := Load(v, "/nonexistent/path/config.yaml")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when explicitly specifying a nonexistent config file")
|
t.Fatal("expected error when explicitly specifying a nonexistent config file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeakJWTSecretsAreReplaced(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
DefaultJWTSecret,
|
||||||
|
"change-me-in-production",
|
||||||
|
"changeme-in-production",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secret := range tests {
|
||||||
|
t.Run(secret, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := "jwt:\n secret: " + secret + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
cfg, info, err := Load(v, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.EphemeralJWTSecret {
|
||||||
|
t.Fatal("expected weak jwt.secret to be replaced")
|
||||||
|
}
|
||||||
|
if cfg.JWT.Secret == secret {
|
||||||
|
t.Fatal("weak jwt.secret was not replaced")
|
||||||
|
}
|
||||||
|
if cfg.JWT.Secret == "" {
|
||||||
|
t.Fatal("ephemeral jwt.secret is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJWTConfigAccessDuration(t *testing.T) {
|
func TestJWTConfigAccessDuration(t *testing.T) {
|
||||||
j := JWTConfig{AccessTTL: 15 * time.Minute}
|
j := JWTConfig{AccessTTL: 15 * time.Minute}
|
||||||
if j.AccessTTL != 15*time.Minute {
|
if j.AccessTTL != 15*time.Minute {
|
||||||
|
|||||||
Reference in New Issue
Block a user