Files
verdande/internal/config/config.go
T
kristianwindandClaude Opus 5 b4038356f0 feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address
was read with chi's middleware.RealIP — which believes X-Forwarded-For from
anyone. So a caller could set it to a new value on every request and get a fresh
password-guess bucket each time, and could write any address they liked into the
log the panel's watchers read. The one control meant for when a password is
already gone was keyed on a number the attacker got to pick.

Now the header is believed only when the machine that *connected* is a trusted
proxy — by default anything on a private network, which is the shape of a reverse
proxy or tunnel sharing the host or the docker bridge. A connection straight off
the internet is trusted for nothing and is keyed on the address it actually came
from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no
proxy at all); VERDANDE_REAL_IP_HEADER names the header.

Verified in the current deployment before the change: behind the Cloudflare
Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared
overwrites it. This makes that safety a property of the code, not of the
deployment — expose the instance directly, or put a header-passing proxy in
front, and it would have been a live hole.

Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating
the browsing context. verdande scripts no other window, so there is nothing to
lose and a class of cross-window attacks to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:34:34 +02:00

298 lines
10 KiB
Go

// Package config loads verdande's settings from the environment.
//
// Everything is env-driven because verdande ships as a Rune: the panel hands the
// container an environment and a /data volume, and there is nowhere to put a config
// file that the operator would ever see.
package config
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
// BaseURL is the address verdande answers on from outside. Invite links,
// password resets, ICS feeds and OAuth redirects are all built from it, so it
// has to be the public address, not the listen address.
BaseURL string
Addr string
DataDir string
SessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
SMTP SMTP
// Gmail is the OAuth client the operator registered in Google Cloud. It
// belongs to the instance: one registration, any number of users.
GmailClientID string
GmailClientSecret string
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
// the same reason: a slow Google held the request open past what Cloudflare is
// willing to wait, and the browser got Cloudflare's HTML instead of an error
// this server could explain.
CalendarSyncBudget time.Duration
// Where Google is. Empty in production; a test points them at a server it
// controls, which is the only way to exercise a Google that is slow or refuses.
// The token endpoint is shared — Gmail and Calendar sign in through the same
// registration — and the two APIs are not.
GoogleTokenURL string
GmailAPIURL string
CalendarAPIURL string
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
// TrustedProxies are the peers whose forwarded-for header may be believed.
//
// The rate limiter and the audit log key on the caller's address, and that
// address is only as honest as wherever it was read. Read it from a header that
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
// every request and to write someone else's IP into the log. So the header is
// believed only when the machine that *connected* is one of these — a reverse
// proxy or tunnel the operator put there — and ignored when the connection came
// straight off the internet.
//
// The default is "the peer is on a private network", which is exactly the shape
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
// host or the docker bridge, so its address is loopback or RFC1918. A directly
// exposed instance sees a public peer, trusts nothing, and keys on the real
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
// or "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// RealIPHeader is where the client address is read from, for a trusted peer.
RealIPHeader string
Dev bool
}
type SMTP struct {
Host string
Port int
Username string
Password string
From string
// StartTLS is the common case for a self-hosted Mailcow on 587. Port 465 is
// implicit TLS instead and sets this false.
StartTLS bool
Insecure bool
}
// Configured reports whether outbound mail can be sent. Without a host verdande
// still runs — it just surfaces invite links in the UI instead of mailing them.
func (s SMTP) Configured() bool { return s.Host != "" }
func Load() (*Config, error) {
c := &Config{
BaseURL: env("VERDANDE_BASE_URL", "http://localhost:8080"),
Addr: env("VERDANDE_ADDR", ":8080"),
DataDir: env("VERDANDE_DATA_DIR", "/data"),
TrashRetention: 30 * 24 * time.Hour,
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
CalendarSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
var err error
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
return nil, err
}
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
return nil, err
}
if c.ResetTTL, err = envDuration("VERDANDE_RESET_TTL", time.Hour); err != nil {
return nil, err
}
port, err := envInt("VERDANDE_SMTP_PORT", 587)
if err != nil {
return nil, err
}
c.SMTP = SMTP{
Host: env("VERDANDE_SMTP_HOST", ""),
Port: port,
Username: env("VERDANDE_SMTP_USER", ""),
Password: env("VERDANDE_SMTP_PASS", ""),
From: env("VERDANDE_SMTP_FROM", "verdande@localhost"),
// Port 465 is implicit TLS from the first byte; everything else negotiates
// with STARTTLS. An operator can still override the guess.
StartTLS: envBool("VERDANDE_SMTP_STARTTLS", port != 465),
Insecure: envBool("VERDANDE_SMTP_INSECURE", false),
}
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
return nil, err
}
return c, nil
}
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
// families' link-local. A proxy an operator stands up in front of this server is
// on one of these; a stranger connecting directly is on none.
var defaultTrustedProxies = mustPrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
"169.254.0.0/16", "fe80::/10",
)
// parseTrustedProxies reads the override. Empty keeps the private-network default;
// the literal "none" trusts no proxy at all, for an instance that is exposed
// directly and must never believe a forwarded header.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultTrustedProxies, nil
}
if strings.EqualFold(raw, "none") {
return nil, nil
}
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
// naming one proxy does not have to know CIDR notation to do it.
if !strings.Contains(part, "/") {
addr, err := netip.ParseAddr(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
p, err := netip.ParsePrefix(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
}
out = append(out, p.Masked())
}
return out, nil
}
func mustPrefixes(cidrs ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
out = append(out, netip.MustParsePrefix(c))
}
return out
}
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
// GmailRedirectURL has to match what was registered in Google Cloud exactly,
// including the scheme — which is why it is derived from BaseURL rather than
// configured separately and left to drift out of step with it.
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
//
// One callback that decided from server-side state which of the two flows had come
// back would save the operator one line in Google Cloud, and would put the choice
// of *which token store to write* behind a lookup. When that goes wrong it goes
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
// nobody would think to look for. Two paths cannot do that.
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) (int, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a number", key, v)
}
return n, nil
}
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func envDuration(key string, def time.Duration) (time.Duration, error) {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a duration (try 720h)", key, v)
}
return d, nil
}