From 8040c3f764c8181415999155bb25eba2eef51dac Mon Sep 17 00:00:00 2001
From: Distronode <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:30:32 -0400
Subject: [PATCH] caldav: a strict SSRF tier for instances whose users are not
the operator, off by default
`server_url` is a bring-your-own-server field, so the CalDAV client deliberately uses
netutil's narrow, metadata-only dial guard: a self-hoster pointing Calnode at a Nextcloud,
Radicale or Baikal 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. That reasoning is correct and the default is unchanged.
Every term of it inverts once the people using an instance are not the operator. The URL is
supplied by somebody else; the private network it can reach is the OPERATOR's, the service
network, the node's exporters, the database; and the connect endpoint is available to any
account holder. Connect-success versus connect-failure, plus timing, times a hostname the
caller chooses, is a port scan of that network, and the connect error is the oracle that
reads it out: handler.ConnectCalDAV writes err.Error() straight into a 400.
So the tier becomes a property of the instance rather than of the package.
CALDAV_STRICT_SSRF (config.CalDAVStrictSSRF, default false) is passed to
caldav.WithStrictSSRFGuard from server.BuildHandler, and in that mode every dial goes
through netutil.ResolveSafe, the guard webhook delivery already uses: private, loopback,
link-local, CGNAT and ULA all refused. Each redirect hop is covered too, because CalDAV
discovery follows redirects by hand to preserve the PROPFIND method and every hop re-enters
c.do and so the same guarded client (internal/caldav/webdav.go:79).
The user-facing error is the second half, not a detail. A refusal saying "resolved to a
blocked address" would hand the caller exactly the oracle the guard closes, and with a
hostname that resolves several ways, which address was picked. Under the strict tier a
blocked dial now produces the sentence discovery has always produced for an unreachable
server, verbatim and with no address in it. The resolved address goes to the log line
netutil already writes and nowhere else. netutil gains ErrBlockedAddress as a sentinel so
the mapping is one errors.Is at the call site rather than string matching through
*url.Error, and GuardedTransport plus the Resolver type so a caller can pick its tier per
instance; SafeTransport and MetadataSafeTransport are now thin wrappers over it and their
behaviour, including the text of the error they produce, is unchanged.
The default is byte-identical to before: same transport, and the error collapse is scoped
to the strict tier, so the narrow tier still surfaces netutil's own text for the one thing
it refuses. TestDefaultTransportIsUnchanged pins both halves.
---
CHANGELOG.md | 18 +++
docs/ARCHITECTURE.md | 20 +++
internal/caldav/caldav.go | 93 ++++++++++---
internal/caldav/discovery.go | 2 +-
internal/caldav/ssrf_test.go | 244 +++++++++++++++++++++++++++++++++
internal/caldav/webdav.go | 29 ++++
internal/config/config.go | 14 ++
internal/config/config_test.go | 27 ++++
internal/netutil/guard_test.go | 110 +++++++++++++++
internal/netutil/netutil.go | 40 +++++-
internal/server/server.go | 9 +-
11 files changed, 580 insertions(+), 26 deletions(-)
create mode 100644 internal/caldav/ssrf_test.go
create mode 100644 internal/netutil/guard_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f45a54c..c45b7dd 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 e91d5db..dca3133 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 cf143e7..8a90419 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 874e46d..beb2fea 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 0000000..d617ca8
--- /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 de10cc9..3be0bf2 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 8e19f05..e14c2cc 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 990f1e4..83bf505 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 0000000..c9d67bf
--- /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 b3157f7..7ba2c54 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 460b46f..20ff398 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)