diff --git a/Dockerfile b/Dockerfile index d7967c0..64d656c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,5 @@ RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_ FROM scratch COPY --from=builder /checker-autoconfig /checker-autoconfig COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt -USER 65534:65534 EXPOSE 8080 ENTRYPOINT ["/checker-autoconfig"] diff --git a/checker/collect.go b/checker/collect.go index d078f32..850566b 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -17,8 +17,8 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Real autoconfig/autodiscover documents are tiny; anything bigger is -// misconfigured or hostile. +// maxBodyBytes caps the response bodies we download. Autoconfig/autodiscover +// documents are small; anything larger is either misconfigured or hostile. const maxBodyBytes = 256 * 1024 func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { @@ -27,10 +27,6 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption if domain == "" { return nil, fmt.Errorf("domain_name is required") } - domain, err := validateDomain(domain) - if err != nil { - return nil, err - } localPart, _ := sdk.GetOption[string](opts, "probeEmail") if localPart == "" { @@ -50,13 +46,6 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption if !strings.HasSuffix(ispdbURL, "/") { ispdbURL += "/" } - ispdbParsed, err := url.Parse(ispdbURL) - if err != nil || (ispdbParsed.Scheme != "http" && ispdbParsed.Scheme != "https") || ispdbParsed.Host == "" { - return nil, fmt.Errorf("invalid ispdbURL: must be an absolute http(s) URL") - } - if _, err := validateDomain(ispdbParsed.Hostname()); err != nil { - return nil, fmt.Errorf("invalid ispdbURL host: %w", err) - } userAgent, _ := sdk.GetOption[string](opts, "userAgent") if userAgent == "" { userAgent = "happyDomain-autoconfig/1.0 (+https://happydomain.org)" @@ -110,7 +99,7 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption }) } - // Runs synchronously: needs MX, but overlaps the SRV/Autodiscover goroutines. + // Autoconfig probes run after MX (MX parent needed), overlapping with the SRV/Autodiscover goroutines. data.Autoconfig = collectAutoconfig(ctx, client, userAgent, domain, email, data.MX, tryISPDB, tryHTTPAutoconfig, ispdbURL) for _, p := range data.Autoconfig { if p.Parsed != nil { @@ -125,8 +114,8 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption } func newHTTPClient(timeout time.Duration) *http.Client { - // Keep cert validation ON; failures are surfaced as soft probe errors - // so the rule engine can flag them. + // We keep certificate validation ON and surface the failure as a + // soft error on the probe, so the rule engine can flag it. tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, MaxIdleConns: 8, @@ -170,6 +159,7 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s res.DurationMs = time.Since(start).Milliseconds() if err != nil { res.Error = err.Error() + // Try to distinguish a TLS verification failure. var tlsErr *tls.CertificateVerificationError if errors.As(err, &tlsErr) { res.TLSError = err.Error() @@ -185,6 +175,7 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s if resp.TLS != nil { res.TLSServerName = resp.TLS.ServerName + res.TLSValid = true if len(resp.TLS.PeerCertificates) > 0 { leaf := resp.TLS.PeerCertificates[0] res.TLSSubject = leaf.Subject.CommonName @@ -225,8 +216,10 @@ func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, doma if tryISPDB { targets = append(targets, target{"ispdb", ispdbURL + domain}) } - // MX fallback catches gmail/MS365-hosted domains. Bucksch suggests - // iterating every MX; the lowest-preference one is enough in practice. + // MX fallback: repeat with the registrable parent domain of the MX + // host. We pick the lowest-preference MX for simplicity (Bucksch + // suggests iterating; one is enough to catch gmail/MS365-hosted + // domains in practice). if mxParent := pickMXParent(mx); mxParent != "" && mxParent != domain { targets = append(targets, target{"mx-autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", mxParent, encoded)}) if tryISPDB { @@ -264,38 +257,6 @@ func runAutoconfigProbe(ctx context.Context, client *http.Client, userAgent, sou return probe } -// validateDomain rejects anything that could escape URL interpolation -// (path/query injection, IP literals). IP-range filtering is left to the -// network layer. -func validateDomain(domain string) (string, error) { - domain = strings.ToLower(domain) - if len(domain) == 0 || len(domain) > 253 { - return "", fmt.Errorf("invalid domain name: length must be 1..253") - } - if net.ParseIP(domain) != nil { - return "", fmt.Errorf("invalid domain name: IP literals are not accepted") - } - labels := strings.Split(domain, ".") - if len(labels) < 2 { - return "", fmt.Errorf("invalid domain name: must contain at least one dot") - } - for _, label := range labels { - if len(label) == 0 || len(label) > 63 { - return "", fmt.Errorf("invalid domain name: label length must be 1..63") - } - if label[0] == '-' || label[len(label)-1] == '-' { - return "", fmt.Errorf("invalid domain name: label %q cannot start or end with '-'", label) - } - for i := 0; i < len(label); i++ { - c := label[i] - if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { - return "", fmt.Errorf("invalid domain name: label %q contains forbidden character", label) - } - } - } - return domain, nil -} - // pickMXParent returns the parent domain of the lowest-preference MX, or // empty when no suitable MX is present. func pickMXParent(mx []MXRecord) string { @@ -311,9 +272,12 @@ func pickMXParent(mx []MXRecord) string { return registrableDomain(best.Host) } -// registrableDomain approximates a PSL lookup with last-two-labels (or -// three when the SLD looks like a ccTLD second level, e.g. co.uk). Good -// enough for the gmail / MS365 MX-fallback case we actually care about. +// registrableDomain returns a best-effort "effective" domain. We do not +// ship a Public Suffix List; stripping one label covers the common case +// (e.g. aspmx.l.google.com → l.google.com, while l.google.com → +// google.com after a second call; the caller only needs one level). +// For the MX case we approximate with the last two labels, falling back +// to three when the second-to-last label is short (e.g. co.uk). func registrableDomain(host string) string { host = strings.TrimSuffix(strings.ToLower(host), ".") parts := strings.Split(host, ".") diff --git a/checker/definition.go b/checker/definition.go index e57c596..63fad06 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -6,9 +6,10 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Version is overridden at link time by the build, or by the plugin loader. +// Version is the checker version reported in CheckerDefinition.Version. var Version = "built-in" +// Definition returns the CheckerDefinition for the autoconfig checker. func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "email-autoconfig", diff --git a/checker/interactive.go b/checker/interactive.go index 6d35f7c..27eb822 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -4,14 +4,14 @@ package checker import ( "errors" - "fmt" "net/http" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderForm describes the standalone /check page inputs. +// RenderForm advertises the minimal inputs a human needs to run the +// checker from its standalone /check page. func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { @@ -54,13 +54,10 @@ func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm builds CheckerOptions from the submitted form. All probing is -// deferred to Collect, so we only validate the domain shape here. +// ParseForm turns a submitted /check form into the CheckerOptions the +// Collect step expects. Collect itself handles all DNS/HTTP probing, so +// there is nothing to resolve up-front here beyond accepting the domain. func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { - if err := r.ParseForm(); err != nil { - return nil, fmt.Errorf("parse form: %w", err) - } - domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(sdk.AutoFillDomainName)), ".") if domain == "" { return nil, errors.New("domain name is required") @@ -72,14 +69,9 @@ func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, err if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" { opts["probeEmail"] = v } - - // HTML omits unchecked boxes; treat absence as "use the documented default". - for _, key := range []string{"tryISPDB", "tryHTTPAutoconfig", "tryAutodiscoverPost"} { - if _, ok := r.Form[key]; !ok { - continue - } - opts[key] = r.FormValue(key) == "true" - } + opts["tryISPDB"] = r.FormValue("tryISPDB") == "true" + opts["tryHTTPAutoconfig"] = r.FormValue("tryHTTPAutoconfig") == "true" + opts["tryAutodiscoverPost"] = r.FormValue("tryAutodiscoverPost") == "true" return opts, nil } diff --git a/checker/parse.go b/checker/parse.go index ec0cd6a..ca1ee28 100644 --- a/checker/parse.go +++ b/checker/parse.go @@ -62,7 +62,7 @@ func parseClientConfig(body []byte) (*ClientConfig, error) { if len(body) == 0 { return nil, fmt.Errorf("empty body") } - // Cheap reject before invoking the XML decoder. + // Fast sanity check: must contain clientConfig. if !bytes.Contains(body, []byte(" elements relevant +// to IMAP/POP/SMTP configuration. Exchange-specific protocols are captured +// but not deeply analysed. type AutodiscoverProtocol struct { Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync Server string `json:"server,omitempty"`