Files
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

189 lines
6.8 KiB
Go

package instancecheck
import (
"context"
"fmt"
"os"
"sort"
"strings"
"github.com/warmbly/warmbly/internal/app/instanceconfig"
"github.com/warmbly/warmbly/internal/config"
)
const (
docsSecrets = "/development/configuration/#secrets"
docsEncryption = "/development/configuration/#encryption"
docsWorkers = "/development/configuration/#workers"
docsProxy = "/development/configuration/#network-and-proxy"
docsCaptcha = "/development/configuration/#captcha"
docsDeployment = "/development/configuration/#deployment"
docsWebhooks = "/guides/webhooks/"
docsMail = "/development/configuration/#platform-mail"
)
func securityChecks() []check {
return []check{
{id: "secret_published_default", run: checkSecretPublishedDefault},
{id: "allow_insecure_defaults", run: checkAllowInsecureDefaults},
{id: "credentials_key_unset", run: checkCredentialsKeyUnset},
{id: "internal_token_unset", run: checkInternalTokenUnset},
{id: "trusted_proxies_unset", run: checkTrustedProxiesUnset},
{id: "captcha_misconfigured", run: checkCaptchaMisconfigured},
{id: "turnstile_bypass_set", run: checkTurnstileBypassSet},
{id: "dev_mode_public", run: checkDevModePublic},
{id: "unsafe_webhook_urls", run: checkUnsafeWebhookURLs},
{id: "tls_verification_off", run: checkTLSVerificationOff},
}
}
func checkSecretPublishedDefault(ctx context.Context, d Deps, in Input) *Finding {
keys := make([]string, 0, len(instanceconfig.PublishedDefaults))
for key := range instanceconfig.PublishedDefaults {
keys = append(keys, key)
}
sort.Strings(keys)
var offenders []string
for _, key := range keys {
if os.Getenv(key) == instanceconfig.PublishedDefaults[key] {
offenders = append(offenders, key)
}
}
if len(offenders) == 0 {
return nil
}
severity := SeverityError
if devEnv() || truthy("ALLOW_INSECURE_DEFAULTS") {
severity = SeverityWarning
}
verb := "hold"
if len(offenders) == 1 {
verb = "holds"
}
return result(CategorySecurity, severity, "Published default secrets in use",
fmt.Sprintf("%s still %s the published default value from docker-compose.yml. "+
"Those values are public in the Warmbly repository, so anyone can forge a session token "+
"or unwrap every organization key. Generate real values with `make gen-key` and restart.",
strings.Join(offenders, ", "), verb),
docsSecrets)
}
func checkAllowInsecureDefaults(ctx context.Context, d Deps, in Input) *Finding {
if !truthy("ALLOW_INSECURE_DEFAULTS") {
return nil
}
return result(CategorySecurity, SeverityWarning, "Insecure defaults are allowed",
"ALLOW_INSECURE_DEFAULTS=true is set, so this instance starts even when it is using published default secrets. "+
"Remove it once you have generated real values.",
docsSecrets)
}
func checkCredentialsKeyUnset(ctx context.Context, d Deps, in Input) *Finding {
if env("CREDENTIALS_ENCRYPTION_KEY") != "" {
return nil
}
return result(CategorySecurity, SeverityError, "Mailbox credentials are not sealed",
"CREDENTIALS_ENCRYPTION_KEY is not set, so mailbox SMTP and IMAP passwords are stored without being sealed. "+
"Set a 64 hex character key (`openssl rand -hex 32`) before connecting any mailbox. "+
"Back it up: losing it makes connected mailboxes unrecoverable.",
docsEncryption)
}
func checkInternalTokenUnset(ctx context.Context, d Deps, in Input) *Finding {
if env("INTERNAL_API_TOKEN") != "" {
return nil
}
return result(CategorySecurity, SeverityError, "Internal API token is not set",
"INTERNAL_API_TOKEN is not set, so every request to /api/v1/internal/ is rejected. "+
"Workers cannot fetch organization keys and the tracking service cannot resolve click links. "+
"Nothing fails at boot, only at runtime.",
docsWorkers)
}
func checkTrustedProxiesUnset(ctx context.Context, d Deps, in Input) *Finding {
// Only a real forwarded request proves a proxy is in front; guessing would
// warn every directly exposed backend, where empty is correct.
if !in.Forwarded || env("TRUSTED_PROXIES") != "" {
return nil
}
return result(CategorySecurity, SeverityWarning, "Proxy headers are not trusted",
"This request arrived with an X-Forwarded-For header but TRUSTED_PROXIES is empty, "+
"so Warmbly is recording your proxy's address as the client address. "+
"The per-IP login limiter, session records, audit rows and API key IP allowlists are all reading the wrong address. "+
"Set TRUSTED_PROXIES to your proxy's CIDR.",
docsProxy)
}
func checkCaptchaMisconfigured(ctx context.Context, d Deps, in Input) *Finding {
if config.CaptchaProvider() != "turnstile" || env("TURNSTILE_SECRET") != "" {
return nil
}
return result(CategorySecurity, SeverityError, "Captcha cannot verify",
"CAPTCHA_PROVIDER is turnstile but TURNSTILE_SECRET is empty, so every captcha verification fails "+
"and nobody can sign in. Set the secret or set CAPTCHA_PROVIDER=none.",
docsCaptcha)
}
func checkTurnstileBypassSet(ctx context.Context, d Deps, in Input) *Finding {
if env("TURNSTILE_BYPASS_TOKEN") == "" {
return nil
}
severity := SeverityWarning
if devEnv() {
severity = SeverityInfo
}
return result(CategorySecurity, severity, "Turnstile bypass token is set",
"TURNSTILE_BYPASS_TOKEN is set. It is only honoured when APP_ENV=dev, so on this deployment it does nothing. "+
"Remove it to avoid confusion.",
docsCaptcha)
}
func checkDevModePublic(ctx context.Context, d Deps, in Input) *Finding {
if !devEnv() || isLoopbackURL(appURL()) {
return nil
}
value := env("APP_ENV")
if value == "" {
value = "unset"
}
return result(CategorySecurity, SeverityWarning, "Dev mode on a public address",
fmt.Sprintf("This instance is running with APP_ENV=%s on a public address. "+
"Dev mode allows the published default secrets and enables Gin debug logging. Set APP_ENV=prod.", value),
docsDeployment)
}
func checkUnsafeWebhookURLs(ctx context.Context, d Deps, in Input) *Finding {
if !truthy("WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS") {
return nil
}
return result(CategorySecurity, SeverityWarning, "Unsafe webhook URLs are allowed",
"Customer webhooks may point at http:// and private addresses. This is intended for development only, "+
"because it lets any workspace member make the backend reach into your internal network.",
docsWebhooks)
}
func checkTLSVerificationOff(ctx context.Context, d Deps, in Input) *Finding {
platform := truthy("SMTP_TLS_INSECURE_SKIP_VERIFY")
mailbox := truthy("MAIL_TLS_INSECURE")
if !platform && !mailbox {
return nil
}
subject := "mailbox connections"
switch {
case platform && mailbox:
subject = "platform mail and mailbox connections"
case platform:
subject = "platform mail"
}
return result(CategorySecurity, SeverityWarning, "TLS verification is disabled",
fmt.Sprintf("TLS certificate verification is disabled for %s. "+
"Only do this for a relay using a private certificate authority.", subject),
docsMail)
}