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)