fix
This commit is contained in:
@@ -109,6 +109,12 @@ type Config struct {
|
||||
// Non-production always allows scrapes. Production without this flag: loopback only.
|
||||
MetricsPublic bool
|
||||
|
||||
// AllowInsecureLocalProduction permits loopback WEB_ORIGIN (http or https) when
|
||||
// APP_ENV=production|prod. Env: ALLOW_INSECURE_LOCAL_PRODUCTION=1.
|
||||
// Public (non-loopback) origins still require https. Use only for local systemd /
|
||||
// npm run dev mislabeled as production — never for a public deploy.
|
||||
AllowInsecureLocalProduction bool
|
||||
|
||||
// Postgres pgx pool (api + worker). Defaults preserve historical NewPool hardcodes
|
||||
// and add idle recycle + statement_timeout for multi-tenant churn.
|
||||
// See docs/ops-runtime.md § Postgres pgx pool and db.PoolOptions comments.
|
||||
@@ -183,8 +189,9 @@ func Load() (Config, error) {
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripeMock: getenvBool("STRIPE_MOCK", false),
|
||||
StripePriceIDs: loadStripePriceIDs(),
|
||||
MetricsPublic: getenvBool("METRICS_PUBLIC", false),
|
||||
DBMaxConns: getenvInt("DB_MAX_CONNS", 20),
|
||||
MetricsPublic: getenvBool("METRICS_PUBLIC", false),
|
||||
AllowInsecureLocalProduction: getenvBool("ALLOW_INSECURE_LOCAL_PRODUCTION", false),
|
||||
DBMaxConns: getenvInt("DB_MAX_CONNS", 20),
|
||||
DBMinConns: getenvInt("DB_MIN_CONNS", 2),
|
||||
DBMaxConnLifetime: getenvDuration("DB_MAX_CONN_LIFETIME", time.Hour),
|
||||
DBMaxConnLifetimeJitter: getenvDurationAllowZero("DB_MAX_CONN_LIFETIME_JITTER", 6*time.Minute),
|
||||
@@ -224,10 +231,21 @@ func (c Config) IsProduction() bool {
|
||||
|
||||
// CookieSecure is true when session/CSRF cookies must carry the Secure flag.
|
||||
// Prefer SessionSecure; also force Secure when APP_ENV is production (defense in depth).
|
||||
// Exception: insecure local production escape with http loopback WEB_ORIGIN follows SessionSecure
|
||||
// so cookies work on http://localhost during mislabeled local deploys.
|
||||
func (c Config) CookieSecure() bool {
|
||||
if c.InsecureLocalProductionActive() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "http://") {
|
||||
return c.SessionSecure
|
||||
}
|
||||
return c.SessionSecure || c.IsProduction()
|
||||
}
|
||||
|
||||
// InsecureLocalProductionActive reports APP_ENV=production|prod with the explicit
|
||||
// loopback escape (ALLOW_INSECURE_LOCAL_PRODUCTION) and a loopback WEB_ORIGIN.
|
||||
func (c Config) InsecureLocalProductionActive() bool {
|
||||
return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin)
|
||||
}
|
||||
|
||||
// ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit
|
||||
// awareness or requested an unsupported shared backend.
|
||||
func (c Config) ShouldWarnRateLimits() bool {
|
||||
@@ -273,14 +291,8 @@ func (c Config) validate() error {
|
||||
if !c.IsProduction() {
|
||||
return nil
|
||||
}
|
||||
if !c.SessionSecure {
|
||||
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "https://") {
|
||||
return fmt.Errorf("WEB_ORIGIN must be https in production")
|
||||
}
|
||||
if isLoopbackWebOriginHost(c.WebOrigin) {
|
||||
return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production")
|
||||
if err := c.validateProductionWebOrigin(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(c.AppEncryptionKey) == "" {
|
||||
return fmt.Errorf("APP_ENCRYPTION_KEY is required in production")
|
||||
@@ -299,6 +311,40 @@ func (c Config) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductionWebOrigin fails closed for public production: https + non-loopback
|
||||
// WEB_ORIGIN and SESSION_SECURE. Loopback http(s) is allowed only with the explicit
|
||||
// ALLOW_INSECURE_LOCAL_PRODUCTION escape (local systemd / npm run dev mislabeled as prod).
|
||||
func (c Config) validateProductionWebOrigin() error {
|
||||
origin := strings.TrimSpace(c.WebOrigin)
|
||||
https := strings.HasPrefix(strings.ToLower(origin), "https://")
|
||||
loopback := isLoopbackWebOriginHost(origin)
|
||||
localEscape := c.AllowInsecureLocalProduction && loopback
|
||||
|
||||
if localEscape {
|
||||
// SESSION_SECURE is optional for http://localhost escape so cookies work locally.
|
||||
if https && !c.SessionSecure {
|
||||
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production and WEB_ORIGIN is https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prefer WEB_ORIGIN diagnostics for the common local-mislabeled-production crash loop
|
||||
// (http://localhost + APP_ENV=production) before SESSION_SECURE.
|
||||
if !https {
|
||||
if loopback {
|
||||
return fmt.Errorf("WEB_ORIGIN must be https in production (got %q). Public deploy: set WEB_ORIGIN=https://your.domain. Local systemd/npm run dev: set APP_ENV=development, or ALLOW_INSECURE_LOCAL_PRODUCTION=1 with loopback WEB_ORIGIN only — otherwise api/worker exit and leave processing_jobs status=running forever", origin)
|
||||
}
|
||||
return fmt.Errorf("WEB_ORIGIN must be https in production (got %q); set WEB_ORIGIN=https://your.public.domain", origin)
|
||||
}
|
||||
if loopback {
|
||||
return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production (got %q). Public deploy: use your https domain. Local only: APP_ENV=development or ALLOW_INSECURE_LOCAL_PRODUCTION=1", origin)
|
||||
}
|
||||
if !c.SessionSecure {
|
||||
return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSMTPConfig no longer fails closed at boot: SMTP credentials live in
|
||||
// admin platform settings (with optional env fallback). Incomplete SMTP_ENABLED
|
||||
// env is ignored until admin configures delivery.
|
||||
|
||||
@@ -217,6 +217,61 @@ func TestProductionValidateFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionLoopbackWebOriginEscape(t *testing.T) {
|
||||
base := Config{
|
||||
AppEnv: "production",
|
||||
WebOrigin: "http://localhost:28472",
|
||||
SessionSecure: false,
|
||||
AppEncryptionKey: "enc",
|
||||
TokenSigningSecret: "tok",
|
||||
ProcessingPollInterval: 250 * time.Millisecond,
|
||||
DBMaxConns: 20,
|
||||
DBMinConns: 2,
|
||||
DBMaxConnLifetime: time.Hour,
|
||||
DBMaxConnIdleTime: 5 * time.Minute,
|
||||
DBHealthCheckPeriod: time.Minute,
|
||||
DBStatementTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
denied := base
|
||||
if err := denied.validate(); err == nil {
|
||||
t.Fatal("expected http loopback WEB_ORIGIN rejected without escape")
|
||||
} else if !strings.Contains(err.Error(), "ALLOW_INSECURE_LOCAL_PRODUCTION") {
|
||||
t.Fatalf("error should mention escape hatch: %v", err)
|
||||
}
|
||||
|
||||
allowed := base
|
||||
allowed.AllowInsecureLocalProduction = true
|
||||
if err := allowed.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !allowed.InsecureLocalProductionActive() {
|
||||
t.Fatal("expected insecure local production active")
|
||||
}
|
||||
if allowed.CookieSecure() {
|
||||
t.Fatal("http loopback escape should not force CookieSecure")
|
||||
}
|
||||
|
||||
httpsLocal := allowed
|
||||
httpsLocal.WebOrigin = "https://localhost:28472"
|
||||
if err := httpsLocal.validate(); err == nil {
|
||||
t.Fatal("expected SESSION_SECURE required for https loopback escape")
|
||||
}
|
||||
httpsLocal.SessionSecure = true
|
||||
if err := httpsLocal.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Escape must not weaken public (non-loopback) http.
|
||||
publicHTTP := base
|
||||
publicHTTP.AllowInsecureLocalProduction = true
|
||||
publicHTTP.WebOrigin = "http://app.example.com"
|
||||
publicHTTP.SessionSecure = true
|
||||
if err := publicHTTP.validate(); err == nil {
|
||||
t.Fatal("expected public http WEB_ORIGIN rejected even with escape flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMTPEnabledDoesNotRequireEnvHost(t *testing.T) {
|
||||
// SMTP credentials live in admin platform settings; boot must succeed with SMTP_ENABLED=true and empty host.
|
||||
cfg := Config{
|
||||
@@ -298,6 +353,15 @@ func TestCookieSecure(t *testing.T) {
|
||||
if (Config{AppEnv: "development", SessionSecure: false}).CookieSecure() {
|
||||
t.Fatal("development without SessionSecure should not enable CookieSecure")
|
||||
}
|
||||
insecureLocal := Config{
|
||||
AppEnv: "production",
|
||||
WebOrigin: "http://localhost:28472",
|
||||
AllowInsecureLocalProduction: true,
|
||||
SessionSecure: false,
|
||||
}
|
||||
if insecureLocal.CookieSecure() {
|
||||
t.Fatal("insecure local production http escape should not force CookieSecure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionSecureDefaultsWithAppEnv(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user