diff --git a/CHANGELOG.md b/CHANGELOG.md
index f45a54ce..c45b7dde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,24 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
## [Unreleased]
+### Security
+- **`CALDAV_STRICT_SSRF` — an opt-in strict dial guard for CalDAV.** The CalDAV client
+ blocks only cloud-metadata addresses, because `server_url` is a bring-your-own-server
+ field and a Nextcloud, Radicale or Baïkal on the operator's own LAN is the intended
+ configuration of a self-hostable product. That reasoning inverts on an instance whose
+ users are not the operator: the URL is supplied by somebody else, the private network it
+ reaches is the operator's, and connect-success versus connect-failure — times a hostname
+ the caller chooses — is a port scan of it.
+
+ Set `CALDAV_STRICT_SSRF=true` and every CalDAV dial, including each manually followed
+ redirect hop, refuses private, loopback, link-local, CGNAT and ULA addresses as well.
+ A refused dial then reports the same "could not reach the CalDAV server" sentence an
+ unreachable host produces, with the resolved address written to the server log and never
+ into the error, because the connect endpoint returns that text to the caller.
+
+ **Default `false`: an instance that does not set it behaves exactly as before** — same
+ transport, same errors. See ARCHITECTURE §16.
+
## [0.9.0] - 2026-09-10
### Added
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e91d5db6..dca31330 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -54,6 +54,8 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary
- `MICROSOFT_CLIENT_ID/SECRET` and `MICROSOFT_TENANT` (default `common`; use the
multi-tenant `common` so any work/personal Microsoft account can connect/sign in)
- `COOKIE_SECURE` (defaults true when BASE_URL is https)
+ - `CALDAV_STRICT_SSRF` (default `false`) — widens the CalDAV dial guard from
+ cloud-metadata-only to private/loopback/CGNAT/ULA as well (see §16)
- Startup (`internal/server/server.go: New`): open DB → run goose migrations →
open keyvault (unwrap DEK) → configure mailer (DB settings override env) → start
webhook/reminder **worker** → load Google creds (DB > env) → build one
@@ -762,6 +764,24 @@ as the desired state:
otherwise be the only thing the walk saw. Headers from an **untrusted** peer are never read, which is what keeps the
default un-weakenable by a header. Resolution happens once, in the outermost
middleware, and is carried in the request context.
+- **The CalDAV dial guard has two tiers, and the default is the narrow one.** `server_url`
+ is a bring-your-own-server field, so the CalDAV client blocks only the cloud-metadata
+ range (§13's guard is the strict tier, used for webhook delivery where the target is a
+ third party's endpoint). A self-hoster pointing Calnode at a Nextcloud, Radicale or
+ Baïkal on their own LAN — or on localhost — is the intended configuration of a
+ self-hostable product, and blocking private ranges there would break the feature for the
+ people it was written for. **`CALDAV_STRICT_SSRF=true`** switches that field to the
+ strict tier: private, loopback, link-local, CGNAT and ULA addresses are all refused, on
+ the first dial and on every redirect hop, which are followed by hand and re-enter the
+ same guarded client. Turn it on when the CalDAV servers your hosts connect are on the
+ public internet, or when the people using the instance are not the operator — there the
+ URL is supplied by somebody else and the private network it can reach is *yours*, so
+ connect-success versus connect-failure, times a hostname the caller chooses, is a port
+ scan. Under the strict tier a refused dial reports the same "could not reach the CalDAV
+ server" sentence an unreachable host produces, with the resolved address in the server
+ log only, because `POST /v1/calendar/caldav/connect` returns that text to the caller and
+ a specific one would be the oracle the guard exists to close. Default `false`, so an
+ instance that never sets it behaves exactly as it always has.
---
diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go
index cf143e75..8a904198 100644
--- a/internal/caldav/caldav.go
+++ b/internal/caldav/caldav.go
@@ -41,36 +41,89 @@ type Client struct {
key [32]byte
logger *slog.Logger
hc *http.Client
+
+ // strictSSRF picks which of netutil's two tiers every dial goes through. See
+ // WithStrictSSRFGuard: it is a property of the INSTANCE (CALDAV_STRICT_SSRF), not
+ // of the build.
+ strictSSRF bool
+ // resolve overrides the strict tier's lookup. Test-only (withResolver): an IP
+ // literal needs no stub, but a NAME that resolves private cannot be exercised
+ // against real DNS without depending on somebody else's zone.
+ resolve netutil.Resolver
+}
+
+// Option configures a Client at construction.
+type Option func(*Client)
+
+// WithStrictSSRFGuard makes every CalDAV dial — the initial one and each redirect hop —
+// refuse a private, loopback, link-local, CGNAT or ULA address, not merely the cloud
+// metadata range.
+//
+// ⛔ It is OFF by default, and the default is the right one for a self-hoster.
+// `server_url` is a "bring your own server" field, and a self-hoster pointing it at a
+// Nextcloud, Radicale or Baïkal on their own LAN — or on localhost — is the intended
+// configuration of a self-hostable product. Blocking private ranges there would break the
+// feature for the people it was written for, which is why netutil's narrow tier exists at
+// all.
+//
+// An instance whose users are NOT the operator inverts every term of that. The string is
+// supplied by someone else; the private network it can reach is the OPERATOR's — the pod
+// network, the node's exporters, the database; and the oracle is cheap, because
+// connect-success versus "could not reach", plus timing, is a port scan any account
+// holder can run. Such an operator sets CALDAV_STRICT_SSRF=true and the same field
+// becomes strict.
+func WithStrictSSRFGuard(strict bool) Option {
+ return func(c *Client) { c.strictSSRF = strict }
+}
+
+// withResolver replaces the strict tier's address lookup. Unexported: it is a test seam,
+// and an exported one would be a way to configure the guard away.
+func withResolver(f netutil.Resolver) Option {
+ return func(c *Client) { c.resolve = f }
}
// New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key (the same
// instance key used to encrypt the other providers' tokens).
-func New(db *sql.DB, encKeyHex string) (*Client, error) {
+func New(db *sql.DB, encKeyHex string, opts ...Option) (*Client, error) {
b, err := hex.DecodeString(encKeyHex)
if err != nil || len(b) != 32 {
return nil, fmt.Errorf("caldav: invalid encryption key")
}
var key [32]byte
copy(key[:], b)
- return &Client{
- db: db,
- key: key,
- logger: slog.Default(),
- hc: &http.Client{
- Timeout: 20 * time.Second,
- // CalDAV discovery follows redirects manually (preserving the PROPFIND method),
- // so disable Go's auto-follow which would downgrade 301/302 to GET.
- CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
- // server_url is host-supplied — a self-hosted Nextcloud/Radicale/Baïkal
- // instance on the operator's own private network or even localhost is a
- // legitimate, intended configuration (this is a self-hostable product),
- // so use the narrower metadata-only guard rather than blocking private
- // ranges outright. Cloud-metadata addresses are never a real CalDAV
- // server for anyone. Manual redirect-following (webdav.go) always
- // re-enters c.hc.Do, so every hop gets re-checked too.
- Transport: netutil.MetadataSafeTransport(slog.Default(), "caldav: SSRF block"),
- },
- }, nil
+ c := &Client{db: db, key: key, logger: slog.Default()}
+ for _, o := range opts {
+ o(c)
+ }
+ c.hc = &http.Client{
+ Timeout: 20 * time.Second,
+ // CalDAV discovery follows redirects manually (preserving the PROPFIND method),
+ // so disable Go's auto-follow which would downgrade 301/302 to GET.
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ // Manual redirect-following (webdav.go: propfind re-enters c.do, and so
+ // c.hc.Do, for every hop) means the guard below runs on each hop, not only
+ // on the first.
+ Transport: c.transport(),
+ }
+ return c, nil
+}
+
+// transport picks this instance's dial guard. See WithStrictSSRFGuard for why there are
+// two tiers and why the narrow one is the default.
+//
+// The narrow one blocks only cloud-metadata addresses: server_url is host-supplied, and a
+// self-hosted Nextcloud/Radicale/Baïkal on the operator's own private network or even
+// localhost is a legitimate, intended configuration for a self-hostable product. Cloud
+// metadata is never a real CalDAV server for anyone, in either tier.
+func (c *Client) transport() http.RoundTripper {
+ if !c.strictSSRF {
+ return netutil.MetadataSafeTransport(c.logger, "caldav: SSRF block")
+ }
+ resolve := c.resolve
+ if resolve == nil {
+ resolve = netutil.ResolveSafe
+ }
+ return netutil.GuardedTransport(resolve, c.logger, "caldav: SSRF block (strict)")
}
// Name identifies this provider in the calendar_connections table.
diff --git a/internal/caldav/discovery.go b/internal/caldav/discovery.go
index 874e46d5..beb2fea6 100644
--- a/internal/caldav/discovery.go
+++ b/internal/caldav/discovery.go
@@ -127,7 +127,7 @@ func (c *Client) findPrincipal(ctx context.Context, serverURL, username, passwor
lastErr = fmt.Errorf("caldav: server did not return a user principal")
}
if lastErr == nil {
- lastErr = fmt.Errorf("caldav: could not reach the CalDAV server")
+ lastErr = errCouldNotReach
}
return "", "", lastErr
}
diff --git a/internal/caldav/ssrf_test.go b/internal/caldav/ssrf_test.go
new file mode 100644
index 00000000..d617ca86
--- /dev/null
+++ b/internal/caldav/ssrf_test.go
@@ -0,0 +1,244 @@
+package caldav
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/calnode/calnode/internal/netutil"
+)
+
+// `server_url` is a blind SSRF into the operator's private network on an instance whose
+// users are not the operator — and only there.
+//
+// ⛔ The narrow metadata-only guard is correct for a self-hoster and stays the DEFAULT: a
+// Nextcloud, Radicale or Baïkal on their own LAN or on localhost is the intended
+// configuration of a self-hostable product. Every term of that inverts when the URL is
+// supplied by somebody who is not the operator — the private network it reaches is the
+// operator's, and connect-success versus "could not reach", plus timing, is a slow port
+// scan any account holder can run.
+//
+// So the strict guard (the one webhook delivery already uses) is switched on by
+// WithStrictSSRFGuard, which server.BuildHandler passes cfg.CalDAVStrictSSRF
+// (CALDAV_STRICT_SSRF).
+
+// newGuardedClient builds a client with the strict guard on, and with resolve as its
+// lookup when one is given.
+func newGuardedClient(t *testing.T, resolve netutil.Resolver) *Client {
+ t.Helper()
+ opts := []Option{WithStrictSSRFGuard(true)}
+ if resolve != nil {
+ opts = append(opts, withResolver(resolve))
+ }
+ c, err := New(newTestDB(t), testKeyHex, opts...)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return c
+}
+
+// TestStrictGuard_refusesTheOperatorsNetwork walks the addresses that matter on a typical
+// deployment: a cluster service network, loopback, the cloud metadata address, IPv6
+// loopback. None needs DNS — LookupIPAddr returns an IP literal unchanged — so these run
+// the real production resolver, not a stub.
+func TestStrictGuard_refusesTheOperatorsNetwork(t *testing.T) {
+ for _, target := range []string{
+ "http://10.43.0.1/", // a cluster ClusterIP range: every Service on the node
+ "http://127.0.0.1:9100/", // an exporter on the host
+ "http://169.254.169.254/", // cloud metadata
+ "http://[::1]:5432/", // IPv6 loopback: the database
+ "http://192.168.1.1/", // an operator LAN a self-hoster would allow
+ "http://100.64.0.1/", // CGNAT
+ "http://[fd00:ec2::254]/", // AWS IMDS over IPv6 (a ULA, not link-local)
+ } {
+ t.Run(target, func(t *testing.T) {
+ c := newGuardedClient(t, nil)
+
+ _, _, _, err := c.do(context.Background(), "PROPFIND", target, "u", "p", "0", "")
+ if err == nil {
+ t.Fatal("the dial succeeded; the strict guard let a private address through")
+ }
+ assertRefusedWithoutDisclosing(t, err, target)
+ })
+ }
+}
+
+// ⛔ The case an IP literal cannot cover: a NAME. DNS rebinding is why the guard resolves
+// and then dials the resolved address rather than handing the hostname to the dialer, and
+// a stub resolver is the only way to exercise it without depending on somebody else's
+// zone.
+func TestStrictGuard_refusesANameThatResolvesPrivate(t *testing.T) {
+ var asked string
+ c := newGuardedClient(t, func(_ context.Context, host string) ([]net.IPAddr, error) {
+ asked = host
+ // What the real ResolveSafe does with a name that answers 10.43.0.1.
+ return nil, fmt.Errorf("%q resolved to a private or loopback address", host)
+ })
+
+ _, _, _, err := c.do(context.Background(), "PROPFIND",
+ "https://caldav.customer.example/dav/", "u", "p", "0", "")
+ if err == nil {
+ t.Fatal("the dial succeeded for a name that resolves private")
+ }
+ if asked != "caldav.customer.example" {
+ t.Errorf("the guard resolved %q; want the request's hostname", asked)
+ }
+ assertRefusedWithoutDisclosing(t, err, "10.43.0.1")
+}
+
+// ⛔ propfind follows redirects by hand (Go's auto-follow would downgrade PROPFIND to
+// GET), and every hop re-enters c.do and so c.hc.Do. That is what makes the guard cover
+// the hop, and this is the assertion that keeps it true: an allowed host answering 302
+// toward a blocked one must not reach the blocked one.
+func TestStrictGuard_refusesABlockedRedirectHop(t *testing.T) {
+ var hops []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", "http://internal.operator.example/dav/")
+ w.WriteHeader(http.StatusFound)
+ }))
+ defer srv.Close()
+
+ host, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
+ if err != nil {
+ t.Fatalf("split test server address: %v", err)
+ }
+ c := newGuardedClient(t, func(_ context.Context, h string) ([]net.IPAddr, error) {
+ hops = append(hops, h)
+ if h == "caldav.public.example" {
+ return []net.IPAddr{{IP: net.ParseIP(host)}}, nil
+ }
+ return nil, fmt.Errorf("%q resolved to a private or loopback address", h)
+ })
+
+ _, _, err = c.propfind(context.Background(), "http://caldav.public.example:"+port+"/dav/",
+ "u", "p", "0", propCurrentUserPrincipal)
+ if err == nil {
+ t.Fatal("the redirect hop was followed into a blocked address")
+ }
+ if len(hops) < 2 || hops[1] != "internal.operator.example" {
+ t.Errorf("the guard saw hops %v; want the redirect target re-checked", hops)
+ }
+ assertRefusedWithoutDisclosing(t, err, "internal.operator.example")
+}
+
+// The other half, and the one that says the guard is a guard rather than an outage: a
+// PUBLIC address still connects in the same mode. Without this, "every dial fails" would
+// satisfy every assertion above.
+func TestStrictGuard_aPublicAddressStillConnects(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusMultiStatus)
+ fmt.Fprint(w, ``)
+ }))
+ defer srv.Close()
+
+ // httptest binds 127.0.0.1, which the strict guard refuses — correctly. The stub
+ // resolver stands in for "this name is public", and the dial that follows is a real
+ // one to the real server, so what is proved is that a permitted resolution ends in a
+ // connection rather than in a second refusal.
+ host, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
+ if err != nil {
+ t.Fatalf("split test server address: %v", err)
+ }
+ c := newGuardedClient(t, func(_ context.Context, _ string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP(host)}}, nil
+ })
+
+ status, _, _, err := c.do(context.Background(), "PROPFIND",
+ "http://caldav.public.example:"+port+"/dav/", "u", "p", "0", "")
+ if err != nil {
+ t.Fatalf("a permitted address did not connect: %v", err)
+ }
+ if status != http.StatusMultiStatus {
+ t.Errorf("status = %d; want 207", status)
+ }
+}
+
+// ⛔ The shipped default is unchanged, and this is the assertion that keeps it that way: a
+// self-hoster's CalDAV server on 127.0.0.1 must still connect with no configuration at
+// all. Only the metadata range is refused there.
+func TestNarrowGuard_isTheDefaultAndAllowsAPrivateServer(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusMultiStatus)
+ fmt.Fprint(w, ``)
+ }))
+ defer srv.Close()
+
+ c, err := New(newTestDB(t), testKeyHex) // no options: the shipped default
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if c.strictSSRF {
+ t.Fatal("the strict guard is on by default; a self-hoster's own network would be refused")
+ }
+
+ status, _, _, err := c.do(context.Background(), "PROPFIND", srv.URL, "u", "p", "0", "")
+ if err != nil {
+ t.Fatalf("a self-hosted server on loopback was refused: %v", err)
+ }
+ if status != http.StatusMultiStatus {
+ t.Errorf("status = %d; want 207", status)
+ }
+
+ // The metadata range is still refused, in both modes, for everyone — and the
+ // default surfaces that refusal exactly as it did before this option existed.
+ if _, _, _, err := c.do(context.Background(), "PROPFIND", "http://169.254.169.254/", "u", "p", "0", ""); err == nil {
+ t.Error("cloud metadata was reachable through the narrow guard")
+ }
+}
+
+// TestDefaultTransportIsUnchanged pins the byte-for-byte half of "default behaviour is
+// unchanged": WithStrictSSRFGuard(false) and no option at all must both produce the
+// narrow tier, and a refused dial under it must still surface netutil's own error rather
+// than being collapsed into the generic sentence (the collapse is deliberate, but it is
+// the strict tier's contract; the narrow tier keeps the error it always had).
+func TestDefaultTransportIsUnchanged(t *testing.T) {
+ for _, opts := range [][]Option{nil, {WithStrictSSRFGuard(false)}} {
+ c, err := New(newTestDB(t), testKeyHex, opts...)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if c.strictSSRF {
+ t.Fatalf("strictSSRF = true for options %v; want the narrow default", opts)
+ }
+ _, _, _, err = c.do(context.Background(), "PROPFIND", "http://169.254.169.254/", "u", "p", "0", "")
+ if err == nil {
+ t.Fatal("cloud metadata was reachable")
+ }
+ // Unchanged from before the option existed: the narrow tier's refusal is the
+ // *url.Error carrying netutil's text, not errCouldNotReach.
+ if errors.Is(err, errCouldNotReach) {
+ t.Errorf("the default collapsed a refusal to %q; that is the strict tier's behaviour", errCouldNotReach)
+ }
+ if !strings.Contains(err.Error(), "netutil: target resolved to a blocked address") {
+ t.Errorf("error = %v; want the netutil text the narrow tier has always produced", err)
+ }
+ }
+}
+
+// assertRefusedWithoutDisclosing pins the user-facing half: the sentence a refused dial
+// produces is the same one an unreachable server produces, and it names no address.
+//
+// ⚠️ handler.ConnectCalDAV writes this error's text straight into a 400 for the connect
+// form, so an error that named the blocked address would BE the oracle the guard exists to
+// close.
+func assertRefusedWithoutDisclosing(t *testing.T, err error, secret string) {
+ t.Helper()
+ if !errors.Is(err, errCouldNotReach) {
+ t.Errorf("error = %v; want the generic %q", err, errCouldNotReach)
+ }
+ msg := err.Error()
+ if strings.Contains(msg, secret) {
+ t.Errorf("error %q names %q; the address must stay in the log line", msg, secret)
+ }
+ for _, leak := range []string{"blocked", "private", "loopback", "link-local"} {
+ if strings.Contains(strings.ToLower(msg), leak) {
+ t.Errorf("error %q says %q, which tells the caller WHY it failed and turns "+
+ "connect-failure into a network probe", msg, leak)
+ }
+ }
+}
diff --git a/internal/caldav/webdav.go b/internal/caldav/webdav.go
index de10cc9a..3be0bf2b 100644
--- a/internal/caldav/webdav.go
+++ b/internal/caldav/webdav.go
@@ -3,13 +3,23 @@ package caldav
import (
"context"
"encoding/xml"
+ "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
+
+ "github.com/calnode/calnode/internal/netutil"
)
+// errCouldNotReach is the one sentence a caller gets for a server this instance did not
+// talk to, whichever of the two reasons applies: nothing there, or an address the SSRF
+// guard refused. findPrincipal has always produced exactly this wording when discovery ran
+// out of candidates, so under the strict guard a refused dial is indistinguishable from an
+// unreachable host — which is the whole point.
+var errCouldNotReach = errors.New("caldav: could not reach the CalDAV server")
+
// do issues one WebDAV request with HTTP Basic auth and returns the status, body, and any
// Location header (for manual redirect following). The shared http.Client is configured (in
// New) NOT to auto-follow redirects, because CalDAV discovery must re-issue the SAME method
@@ -32,6 +42,25 @@ func (c *Client) do(ctx context.Context, method, rawURL, username, password, dep
}
resp, err := c.hc.Do(req)
if err != nil {
+ // ⛔ Under the strict guard, a refused dial becomes the SAME sentence a
+ // genuinely unreachable server produces, and carries nothing else.
+ //
+ // This error is surfaced verbatim on the connect form (handler.ConnectCalDAV
+ // writes err.Error() into a 400), so the raw one would tell the person which
+ // addresses are blocked and — with a hostname that resolves several ways —
+ // which one was picked. That is the oracle the strict guard exists to close:
+ // connect-success versus connect-failure, times a hostname the caller
+ // controls, is a port scan of the operator's network. The resolved address is
+ // in the log line netutil already writes.
+ //
+ // The collapse is scoped to the strict tier on purpose, so the default is
+ // unchanged for everyone who does not set CALDAV_STRICT_SSRF. The narrow tier
+ // refuses exactly one thing — the cloud-metadata range, a fixed well-known
+ // address that is nobody's CalDAV server — so its message is an answer about
+ // the URL that was typed, not a probe result about the operator's network.
+ if c.strictSSRF && errors.Is(err, netutil.ErrBlockedAddress) {
+ return 0, nil, "", errCouldNotReach
+ }
return 0, nil, "", err
}
defer resp.Body.Close()
diff --git a/internal/config/config.go b/internal/config/config.go
index 8e19f059..e14c2cce 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -67,6 +67,19 @@ type Config struct {
// walk steps over its edge address and lands on the visitor.
TrustedProxyCIDRs []string
+ // CalDAVStrictSSRF makes every CalDAV dial — the connect probe, discovery, each
+ // redirect hop and every later sync — refuse a private, loopback, link-local, CGNAT
+ // or ULA address, instead of only the cloud-metadata range.
+ //
+ // Default false, because `server_url` is a bring-your-own-server field and a
+ // self-hoster pointing it at a Nextcloud, Radicale or Baïkal on their own LAN (or
+ // localhost) is the intended configuration of a self-hostable product. Set it true
+ // on an instance whose CalDAV server lives on the public internet, or whose users
+ // are not the operator: there the URL is supplied by somebody else and the private
+ // network it can reach is the operator's, so connect-success versus connect-failure
+ // is a port scan.
+ CalDAVStrictSSRF bool
+
// DemoMode turns this instance into a public, self-resetting demo: seeds sample
// data on every boot (there's no persistent volume, so every boot is a fresh DB),
// disables calendar/Zoom connect, serves a disallow-all robots.txt, and exposes
@@ -115,6 +128,7 @@ func Load() *Config {
cfg.PublicBaseURL = getEnv("PUBLIC_BASE_URL", cfg.BaseURL)
cfg.LogLevel = parseLogLevel(getEnv("LOG_LEVEL", "info"))
cfg.CookieSecure = getBool("COOKIE_SECURE", strings.HasPrefix(cfg.BaseURL, "https://"))
+ cfg.CalDAVStrictSSRF = getBool("CALDAV_STRICT_SSRF", false)
cfg.DemoMode = getBool("DEMO_MODE", false)
cfg.DemoResetInterval = getDuration("DEMO_RESET_INTERVAL", 30*time.Minute)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 990f1e4c..83bf505b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -134,3 +134,30 @@ func TestLoad_dataDir(t *testing.T) {
t.Errorf("DataDir = %q; want /var/lib/calnode", cfg.DataDir)
}
}
+
+// CALDAV_STRICT_SSRF switches the CalDAV client from the narrow metadata-only dial guard
+// to the strict one (private / loopback / link-local / CGNAT / ULA all refused).
+//
+// ⛔ Unset must be false. `server_url` is a bring-your-own-server field, and a self-hoster
+// pointing it at a Nextcloud on their own LAN is the intended configuration — turning this
+// on by default would break the feature for the people it was written for.
+func TestLoad_caldavStrictSSRF(t *testing.T) {
+ os.Unsetenv("CALDAV_STRICT_SSRF")
+ if cfg := config.Load(); cfg.CalDAVStrictSSRF {
+ t.Error("CalDAVStrictSSRF defaults to true; a self-hoster's LAN CalDAV server would stop connecting")
+ }
+ t.Setenv("CALDAV_STRICT_SSRF", "true")
+ if cfg := config.Load(); !cfg.CalDAVStrictSSRF {
+ t.Error("CalDAVStrictSSRF = false; want true")
+ }
+ t.Setenv("CALDAV_STRICT_SSRF", "false")
+ if cfg := config.Load(); cfg.CalDAVStrictSSRF {
+ t.Error("CalDAVStrictSSRF = true; want false")
+ }
+ // An unparseable value keeps the default rather than failing closed: same rule as
+ // every other getBool setting, and the default is the permissive one on purpose.
+ t.Setenv("CALDAV_STRICT_SSRF", "yes-please")
+ if cfg := config.Load(); cfg.CalDAVStrictSSRF {
+ t.Error("CalDAVStrictSSRF = true on unparseable input; want the default")
+ }
+}
diff --git a/internal/netutil/guard_test.go b/internal/netutil/guard_test.go
new file mode 100644
index 00000000..c9d67bf6
--- /dev/null
+++ b/internal/netutil/guard_test.go
@@ -0,0 +1,110 @@
+package netutil_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/calnode/calnode/internal/netutil"
+)
+
+// discardLogger keeps the guard's Warn line (which is where the blocked address is
+// allowed to appear) out of the test output.
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+// TestGuardedTransport_blockedResolutionIsASentinel pins both halves of the contract: the
+// refusal is matchable with errors.Is through http.Client's *url.Error wrapper, and the
+// address the resolver objected to is NOT in the error a caller would surface.
+func TestGuardedTransport_blockedResolutionIsASentinel(t *testing.T) {
+ const secret = "10.43.0.1"
+
+ hc := &http.Client{Transport: netutil.GuardedTransport(
+ func(_ context.Context, host string) ([]net.IPAddr, error) {
+ return nil, fmt.Errorf("%q resolved to a private or loopback address (%s)", host, secret)
+ },
+ discardLogger(), "test: SSRF block",
+ )}
+
+ _, err := hc.Get("http://caldav.example.invalid/dav/")
+ if err == nil {
+ t.Fatal("the dial succeeded; the guard let a blocked address through")
+ }
+ if !errors.Is(err, netutil.ErrBlockedAddress) {
+ t.Errorf("error = %v; want it to wrap ErrBlockedAddress", err)
+ }
+ if msg := err.Error(); strings.Contains(msg, secret) {
+ t.Errorf("error %q names %q; the resolved address must stay in the log line", msg, secret)
+ }
+}
+
+// The other half, and the one that says the guard is a guard rather than an outage: a
+// resolution the tier permits ends in a real connection. Without this, "every dial fails"
+// would satisfy the assertion above.
+func TestGuardedTransport_permittedResolutionDials(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer srv.Close()
+
+ host, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
+ if err != nil {
+ t.Fatalf("split test server address: %v", err)
+ }
+ // The stub stands in for "this name is public"; the dial that follows is a real one
+ // to the real server, so what is proved is that a permitted resolution ends in a
+ // connection rather than in a second refusal.
+ hc := &http.Client{Transport: netutil.GuardedTransport(
+ func(_ context.Context, _ string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP(host)}}, nil
+ },
+ discardLogger(), "test: SSRF block",
+ )}
+
+ resp, err := hc.Get("http://caldav.public.example:" + port + "/dav/")
+ if err != nil {
+ t.Fatalf("a permitted address did not connect: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Errorf("status = %d; want 204", resp.StatusCode)
+ }
+}
+
+// SafeTransport and MetadataSafeTransport are the two named tiers over the same guard, and
+// their refusals must stay matchable the same way — the CalDAV client's error collapse and
+// the webhook worker both go through this sentinel.
+func TestNamedTransports_refuseWithTheSameSentinel(t *testing.T) {
+ for name, rt := range map[string]http.RoundTripper{
+ "SafeTransport": netutil.SafeTransport(discardLogger(), "test: SSRF block"),
+ "MetadataSafeTransport": netutil.MetadataSafeTransport(discardLogger(), "test: SSRF block"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ // 169.254.169.254 is cloud metadata: refused by both tiers, and an IP
+ // literal needs no DNS, so this is the real production resolver.
+ _, err := (&http.Client{Transport: rt}).Get("http://169.254.169.254/")
+ if err == nil {
+ t.Fatal("cloud metadata was reachable")
+ }
+ if !errors.Is(err, netutil.ErrBlockedAddress) {
+ t.Errorf("error = %v; want it to wrap ErrBlockedAddress", err)
+ }
+ // http.Client puts the caller's own URL in the *url.Error, which
+ // discloses nothing. What must not appear is the resolver's reason.
+ msg := strings.ToLower(err.Error())
+ for _, leak := range []string{"private", "loopback", "link-local", "metadata"} {
+ if strings.Contains(msg, leak) {
+ t.Errorf("error %q says %q, which tells the caller WHY it failed", msg, leak)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/netutil/netutil.go b/internal/netutil/netutil.go
index b3157f76..7ba2c54f 100644
--- a/internal/netutil/netutil.go
+++ b/internal/netutil/netutil.go
@@ -2,6 +2,7 @@ package netutil
import (
"context"
+ "errors"
"fmt"
"log/slog"
"net"
@@ -105,7 +106,7 @@ func CheckHostnameNotMetadata(ctx context.Context, host string) error {
// Use this for targets that should never legitimately be private (webhook delivery —
// a third party's receiving endpoint, not infrastructure the operator runs).
func SafeTransport(logger *slog.Logger, logMsg string) http.RoundTripper {
- return dialGuardTransport(ResolveSafe, logger, logMsg)
+ return GuardedTransport(ResolveSafe, logger, logMsg)
}
// MetadataSafeTransport is SafeTransport's narrower sibling: it blocks only cloud
@@ -114,10 +115,39 @@ func SafeTransport(logger *slog.Logger, logMsg string) http.RoundTripper {
// private-network and localhost destinations are an intended, self-hosting use case,
// not a red flag — only the metadata range is universally illegitimate for these.
func MetadataSafeTransport(logger *slog.Logger, logMsg string) http.RoundTripper {
- return dialGuardTransport(ResolveNotMetadata, logger, logMsg)
+ return GuardedTransport(ResolveNotMetadata, logger, logMsg)
}
-func dialGuardTransport(resolve func(context.Context, string) ([]net.IPAddr, error), logger *slog.Logger, logMsg string) http.RoundTripper {
+// Resolver is the hostname lookup a dial guard consults. ResolveSafe (strict) and
+// ResolveNotMetadata (narrow) are the two production ones; a test supplies its own to
+// exercise a NAME that resolves private without depending on somebody else's zone. An IP
+// literal needs no stub — LookupIPAddr returns it unchanged — but a rebinding name does,
+// and that is the case the strict guard exists for.
+type Resolver func(context.Context, string) ([]net.IPAddr, error)
+
+// ErrBlockedAddress is what a guarded transport fails a dial with.
+//
+// A sentinel rather than a bare string so a caller can map it to its OWN user-facing
+// sentence: the error a guarded dial produces travels up through http.Client as a
+// *url.Error, and a client that surfaced it verbatim would put "resolved to a blocked
+// address" in front of someone typing their own server's hostname — which is an oracle
+// for what is and is not reachable on the operator's network. errors.Is sees through
+// url.Error's Unwrap, so the mapping is one check at the call site.
+var ErrBlockedAddress = errors.New("target resolved to a blocked address")
+
+// GuardedTransport returns an http.RoundTripper that resolves every dial target through
+// resolve and connects to the resolved IP directly, never re-resolving the hostname — so
+// there is no DNS-rebinding gap between the check and the connection. A caller that
+// follows redirects through the same client re-checks every hop for the same reason.
+// logMsg is the slog message used when a dial is blocked; the resolved address goes in
+// that LOG line and never into the returned error.
+//
+// Exported so a caller can pick its tier per INSTANCE rather than per build: SafeTransport
+// and MetadataSafeTransport are the two named tiers, and internal/caldav chooses between
+// them from configuration, because a self-hoster's CalDAV server legitimately lives on
+// their own private network while an instance serving people who are not the operator has
+// the same field supplied by someone else.
+func GuardedTransport(resolve Resolver, logger *slog.Logger, logMsg string) http.RoundTripper {
baseDialer := &net.Dialer{}
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
@@ -128,7 +158,9 @@ func dialGuardTransport(resolve func(context.Context, string) ([]net.IPAddr, err
addrs, err := resolve(ctx, host)
if err != nil {
logger.Warn(logMsg, "host", host, "error", err)
- return nil, fmt.Errorf("netutil: target resolved to a blocked address")
+ // The resolved address is in the LOG and never in the error: the
+ // person who configured the target is the person this error reaches.
+ return nil, fmt.Errorf("netutil: %w", ErrBlockedAddress)
}
return baseDialer.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
},
diff --git a/internal/server/server.go b/internal/server/server.go
index 460b46f7..20ff398e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -181,7 +181,14 @@ func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *s
// needs no instance-level OAuth app — each host connects their own server with an
// app-specific password — so it's always available. Registered last so it never displaces
// Google/Microsoft as the OAuth-callback primary.
- if cdav, err := caldav.New(db, cfg.EncryptionKey); err != nil {
+ //
+ // The SSRF tier is chosen HERE, from configuration, rather than in the package.
+ // `server_url` is a bring-your-own-server field: a self-hoster pointing it at a
+ // Nextcloud on their own LAN is the intended configuration, so the default keeps the
+ // narrow metadata-only guard. An operator whose users are not the operator sets
+ // CALDAV_STRICT_SSRF=true, and then every dial and every redirect hop goes through
+ // the strict guard webhook delivery already uses.
+ if cdav, err := caldav.New(db, cfg.EncryptionKey, caldav.WithStrictSSRFGuard(cfg.CalDAVStrictSSRF)); err != nil {
logger.Error("caldav: init failed", "error", err)
} else {
calSvc.Register(cdav)