checker-dav/internal/dav/principal.go
Pierre-Olivier Mercier 7d5535fddf Initial commit
CalDAV and CardDAV checkers sharing a single Go module. Discovery follows
RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal,
home-set, collections and a minimal REPORT query on top of go-webdav.
Common shape in internal/dav/; CalDAV adds a scheduling rule.

Surfaces its context URL (and each secure-SRV target) as TLS endpoints via
the EndpointDiscoverer interface, so the dedicated TLS checker can pick
them up without re-parsing observations.

HTML report foregrounds common misconfigs (well-known returning 200,
missing SRV, plaintext-only SRV, missing DAV capability, skipped auth
phase) as action-item callouts before the full phase breakdown.
2026-04-26 21:47:40 +07:00

154 lines
3.8 KiB
Go

package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// FindPrincipal requires authenticated credentials on client.
func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
hrefs, err := propFind(ctx, client, contextURL, "0", body)
if err != nil {
return "", err
}
for _, href := range hrefs.principalHref() {
return resolveReference(contextURL, href), nil
}
return "", fmt.Errorf("no current-user-principal returned")
}
func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL string) (inbox, outbox string, err error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:schedule-inbox-URL/>
<c:schedule-outbox-URL/>
</d:prop>
</d:propfind>`
resp, err := propFind(ctx, client, principalURL, "0", body)
if err != nil {
return "", "", err
}
for _, r := range resp.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, p := range ps.Prop.Props {
switch p.XMLName.Local {
case "schedule-inbox-URL":
if h := p.firstHref(); h != "" {
inbox = resolveReference(principalURL, h)
}
case "schedule-outbox-URL":
if h := p.firstHref(); h != "" {
outbox = resolveReference(principalURL, h)
}
}
}
}
}
return inbox, outbox, nil
}
// multistatus is intentionally a permissive subset: unknown elements are
// ignored so server-specific extensions don't break parsing.
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Response []msResponse `xml:"response"`
}
type msResponse struct {
Href string `xml:"href"`
Propstat []propstat `xml:"propstat"`
}
type propstat struct {
Prop prop `xml:"prop"`
Status string `xml:"status"`
}
type prop struct {
Props []msProp `xml:",any"`
}
type msProp struct {
XMLName xml.Name
Hrefs []string `xml:"href"`
}
func (p msProp) firstHref() string {
if len(p.Hrefs) > 0 {
return p.Hrefs[0]
}
return ""
}
func (m *multistatus) principalHref() []string {
var out []string
for _, r := range m.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, pr := range ps.Prop.Props {
if pr.XMLName.Local == "current-user-principal" {
if h := pr.firstHref(); h != "" {
out = append(out, h)
}
}
}
}
}
return out
}
// propFind is tuned for small single-resource probes; not for large listings.
func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", depth)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 10 MiB cap: probes here read a handful of props on one resource; more
// is either misbehaviour or an attempt at memory exhaustion.
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode)
}
var ms multistatus
if err := xml.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("invalid multistatus: %w", err)
}
return &ms, nil
}
func resolveReference(base, ref string) string {
r, err := url.Parse(ref)
if err != nil {
return ref
}
b, err := url.Parse(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}