diff --git a/Dockerfile b/Dockerfile index 64d656c..d7967c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,5 +11,6 @@ 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 850566b..d078f32 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -17,8 +17,8 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// maxBodyBytes caps the response bodies we download. Autoconfig/autodiscover -// documents are small; anything larger is either misconfigured or hostile. +// Real autoconfig/autodiscover documents are tiny; anything bigger is +// misconfigured or hostile. const maxBodyBytes = 256 * 1024 func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { @@ -27,6 +27,10 @@ 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 == "" { @@ -46,6 +50,13 @@ 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)" @@ -99,7 +110,7 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption }) } - // Autoconfig probes run after MX (MX parent needed), overlapping with the SRV/Autodiscover goroutines. + // Runs synchronously: needs MX, but overlaps 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 { @@ -114,8 +125,8 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption } func newHTTPClient(timeout time.Duration) *http.Client { - // We keep certificate validation ON and surface the failure as a - // soft error on the probe, so the rule engine can flag it. + // Keep cert validation ON; failures are surfaced as soft probe errors + // so the rule engine can flag them. tr := &http.Transport{ Proxy: http.ProxyFromEnvironment, MaxIdleConns: 8, @@ -159,7 +170,6 @@ 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() @@ -175,7 +185,6 @@ 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 @@ -216,10 +225,8 @@ func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, doma if tryISPDB { targets = append(targets, target{"ispdb", ispdbURL + domain}) } - // 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). + // MX fallback catches gmail/MS365-hosted domains. Bucksch suggests + // iterating every MX; the lowest-preference one is enough 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 { @@ -257,6 +264,38 @@ 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 { @@ -272,12 +311,9 @@ func pickMXParent(mx []MXRecord) string { return registrableDomain(best.Host) } -// 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). +// 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. 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 63fad06..e57c596 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -6,10 +6,9 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Version is the checker version reported in CheckerDefinition.Version. +// Version is overridden at link time by the build, or by the plugin loader. 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 27eb822..6d35f7c 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 advertises the minimal inputs a human needs to run the -// checker from its standalone /check page. +// RenderForm describes the standalone /check page inputs. func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { @@ -54,10 +54,13 @@ func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField { } } -// 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. +// ParseForm builds CheckerOptions from the submitted form. All probing is +// deferred to Collect, so we only validate the domain shape here. 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") @@ -69,9 +72,14 @@ func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, err if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" { opts["probeEmail"] = v } - opts["tryISPDB"] = r.FormValue("tryISPDB") == "true" - opts["tryHTTPAutoconfig"] = r.FormValue("tryHTTPAutoconfig") == "true" - opts["tryAutodiscoverPost"] = r.FormValue("tryAutodiscoverPost") == "true" + + // 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" + } return opts, nil } diff --git a/checker/parse.go b/checker/parse.go index ca1ee28..ec0cd6a 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") } - // Fast sanity check: must contain clientConfig. + // Cheap reject before invoking the XML decoder. if !bytes.Contains(body, []byte(" elements relevant -// to IMAP/POP/SMTP configuration. Exchange-specific protocols are captured -// but not deeply analysed. +// AutodiscoverProtocol covers IMAP/POP/SMTP fields; Exchange-only protocols +// (EXCH/EXPR/MobileSync) are stored but not analysed. type AutodiscoverProtocol struct { Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync Server string `json:"server,omitempty"`