Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down
93 changes: 73 additions & 20 deletions internal/caldav/caldav.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/caldav/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading