This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DBURL returns the database connection string, preferring DATABASE_URL and
|
||||
// falling back to DB_URL (used by the Makefile).
|
||||
func DBURL() string {
|
||||
if v := os.Getenv("DATABASE_URL"); v != "" {
|
||||
return v
|
||||
}
|
||||
return os.Getenv("DB_URL")
|
||||
}
|
||||
|
||||
// Config holds the runtime configuration, loaded from environment variables.
|
||||
// See porting guide section 7.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
NextAuthSecret string // == AUTH_SECRET; signs sessions and derives IMAP key
|
||||
AuthSecret string
|
||||
NextAuthURL string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
AdminEmails []string
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment. Placeholders keep OIDC off.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
DatabaseURL: DBURL(),
|
||||
NextAuthSecret: os.Getenv("NEXTAUTH_SECRET"),
|
||||
AuthSecret: os.Getenv("AUTH_SECRET"),
|
||||
NextAuthURL: os.Getenv("NEXTAUTH_URL"),
|
||||
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
AdminEmails: AdminEmails(),
|
||||
}
|
||||
}
|
||||
|
||||
// Secret returns the signing secret, preferring NEXTAUTH_SECRET then AUTH_SECRET.
|
||||
// The original code falls back to a hard-coded string; a port must fail instead.
|
||||
func (c *Config) Secret() string {
|
||||
if c.NextAuthSecret != "" {
|
||||
return c.NextAuthSecret
|
||||
}
|
||||
return c.AuthSecret
|
||||
}
|
||||
|
||||
// EncryptionSecret returns the key material for at-rest encryption of
|
||||
// integration credentials (review OPS-01). A dedicated ENCRYPTION_KEY lets
|
||||
// operators rotate AUTH_SECRET (sessions) without invalidating stored IMAP
|
||||
// passwords. Without it the auth secret remains the key, matching the
|
||||
// original derivation, and stored rows keep decrypting via the legacy
|
||||
// fallback in signals.LoadMailbox.
|
||||
func (c *Config) EncryptionSecret() string {
|
||||
if v := os.Getenv("ENCRYPTION_KEY"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Secret()
|
||||
}
|
||||
|
||||
// CookieSecure reports whether session cookies must carry the Secure flag.
|
||||
// An explicit COOKIE_SECURE setting wins; otherwise the NEXTAUTH_URL scheme
|
||||
// decides, so TLS deployments are secure by default without knowing about
|
||||
// the extra variable.
|
||||
func (c *Config) CookieSecure() bool {
|
||||
if v := os.Getenv("COOKIE_SECURE"); v != "" {
|
||||
return v == "1"
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(c.NextAuthURL))
|
||||
return err == nil && u.Scheme == "https"
|
||||
}
|
||||
|
||||
// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases.
|
||||
func AdminEmails() []string {
|
||||
s := os.Getenv("ADMIN_EMAILS")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, email := range strings.Split(s, ",") {
|
||||
if email = strings.ToLower(strings.TrimSpace(email)); email != "" {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsAdminEmail reports whether email is listed in ADMIN_EMAILS.
|
||||
func IsAdminEmail(email string) bool {
|
||||
e := strings.ToLower(strings.TrimSpace(email))
|
||||
for _, a := range AdminEmails() {
|
||||
if strings.ToLower(a) == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowBootstrapSignup reports whether the first self-service signup on a
|
||||
// fresh installation may claim administrator (review SEC-5). Default is on
|
||||
// so the documented "sign up, then sign in" onboarding keeps working;
|
||||
// exposed deployments set ALLOW_BOOTSTRAP_SIGNUP=0 and provision operators
|
||||
// via ADMIN_EMAILS/seed instead, so an attacker scanning for new
|
||||
// installations cannot claim the instance.
|
||||
func AllowBootstrapSignup() bool {
|
||||
return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCookieSecureDerivation(t *testing.T) {
|
||||
t.Setenv("COOKIE_SECURE", "")
|
||||
https := &Config{NextAuthURL: "https://app.example.com"}
|
||||
if !https.CookieSecure() {
|
||||
t.Fatal("https NEXTAUTH_URL must imply Secure cookies")
|
||||
}
|
||||
local := &Config{NextAuthURL: "http://localhost:3000"}
|
||||
if local.CookieSecure() {
|
||||
t.Fatal("http NEXTAUTH_URL must not set Secure")
|
||||
}
|
||||
if (&Config{}).CookieSecure() {
|
||||
t.Fatal("unset NEXTAUTH_URL must not set Secure")
|
||||
}
|
||||
t.Setenv("COOKIE_SECURE", "1")
|
||||
if !local.CookieSecure() {
|
||||
t.Fatal("explicit COOKIE_SECURE=1 must win over http URL")
|
||||
}
|
||||
t.Setenv("COOKIE_SECURE", "0")
|
||||
if https.CookieSecure() {
|
||||
t.Fatal("explicit COOKIE_SECURE=0 must win over https URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretPrecedence(t *testing.T) {
|
||||
c := &Config{NextAuthSecret: "next", AuthSecret: "auth"}
|
||||
if c.Secret() != "next" {
|
||||
t.Fatal("NEXTAUTH_SECRET must win")
|
||||
}
|
||||
if (&Config{AuthSecret: "auth"}).Secret() != "auth" {
|
||||
t.Fatal("AUTH_SECRET fallback broken")
|
||||
}
|
||||
}
|
||||
|
||||
// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential
|
||||
// encryption from the session signing secret.
|
||||
func TestEncryptionSecretPrecedence(t *testing.T) {
|
||||
c := &Config{AuthSecret: "auth"}
|
||||
t.Setenv("ENCRYPTION_KEY", "")
|
||||
if c.EncryptionSecret() != "auth" {
|
||||
t.Fatal("without ENCRYPTION_KEY the auth secret must remain the key")
|
||||
}
|
||||
t.Setenv("ENCRYPTION_KEY", "dedicated-key")
|
||||
if c.EncryptionSecret() != "dedicated-key" {
|
||||
t.Fatal("ENCRYPTION_KEY must win over the auth secret")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user