diff --git a/internal/api/controller/resolver.go b/internal/api/controller/resolver.go index 523bcc98..70087143 100644 --- a/internal/api/controller/resolver.go +++ b/internal/api/controller/resolver.go @@ -136,3 +136,35 @@ func (rc *ResolverController) FetchMTASTSPolicy(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +// CheckDMARCReportAuth resolves the RFC 7489 sec. 7.1 authorization record +// at ._report._dmarc. and reports whether the external +// domain accepts reports for the protected owner. +// +// @Summary Check DMARC cross-domain reporting authorization. +// @Schemes +// @Description Resolve the TXT record at ._report._dmarc. (RFC 7489 sec. 7.1) and report whether the external reporting destination authorizes receiving DMARC reports for the protected owner. +// @Tags resolver +// @Accept json +// @Produce json +// @Param body body happydns.DMARCReportAuthRequest true "DMARC report authorization check" +// @Success 200 {object} happydns.DMARCReportAuthResponse +// @Failure 400 {object} happydns.ErrorResponse "Invalid input" +// @Failure 500 {object} happydns.ErrorResponse +// @Router /resolver/dmarc-report-auth [post] +func (rc *ResolverController) CheckDMARCReportAuth(c *gin.Context) { + var req happydns.DMARCReportAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("%s sends invalid DMARCReportAuthRequest JSON: %s", c.ClientIP(), err.Error()) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())}) + return + } + + resp, err := rc.resolverService.CheckDMARCReportAuth(req) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/route/resolver.go b/internal/api/route/resolver.go index 572a154a..3bec7c2e 100644 --- a/internal/api/route/resolver.go +++ b/internal/api/route/resolver.go @@ -34,4 +34,5 @@ func DeclareResolverRoutes(router *gin.RouterGroup, resolverUC happydns.Resolver router.POST("/resolver", rc.RunResolver) router.POST("/resolver/spf-flatten", rc.FlattenSPF) router.POST("/resolver/mta-sts-policy", rc.FetchMTASTSPolicy) + router.POST("/resolver/dmarc-report-auth", rc.CheckDMARCReportAuth) } diff --git a/internal/usecase/dmarc_report_auth.go b/internal/usecase/dmarc_report_auth.go new file mode 100644 index 00000000..107a0605 --- /dev/null +++ b/internal/usecase/dmarc_report_auth.go @@ -0,0 +1,104 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package usecase + +import ( + "strings" + "time" + + "github.com/miekg/dns" + + "git.happydns.org/happyDomain/model" +) + +const dmarcReportAuthTimeout = 5 * time.Second + +// CheckDMARCReportAuth resolves the RFC 7489 sec. 7.1 cross-domain reporting +// authorization record for (Owner, ExternalDomain). +func (ru *resolverUsecase) CheckDMARCReportAuth(req happydns.DMARCReportAuthRequest) (*happydns.DMARCReportAuthResponse, error) { + owner := strings.TrimSuffix(strings.TrimSpace(req.Owner), ".") + external := strings.TrimSuffix(strings.TrimSpace(req.ExternalDomain), ".") + if owner == "" { + return nil, happydns.ValidationError{Msg: "owner is required"} + } + if external == "" { + return nil, happydns.ValidationError{Msg: "externalDomain is required"} + } + + queried := dns.Fqdn(owner + "._report._dmarc." + external) + resp := &happydns.DMARCReportAuthResponse{QueriedName: strings.TrimSuffix(queried, ".")} + + resolver, err := ru.pickResolver(req.Resolver, req.Custom) + if err != nil { + return nil, err + } + + client := dns.Client{Timeout: dmarcReportAuthTimeout} + m := new(dns.Msg) + m.SetQuestion(queried, dns.TypeTXT) + m.RecursionDesired = true + m.SetEdns0(4096, true) + + r, _, err := client.Exchange(m, resolver) + if err != nil { + resp.Status = "resolver-error" + resp.ErrorMsg = err.Error() + return resp, nil + } + if r == nil { + resp.Status = "resolver-error" + resp.ErrorMsg = "no answer" + return resp, nil + } + switch r.Rcode { + case dns.RcodeNameError: + resp.Status = "not-found" + resp.ErrorMsg = "NXDOMAIN" + return resp, nil + case dns.RcodeSuccess: + // fallthrough + default: + resp.Status = "dns-error" + resp.ErrorMsg = dns.RcodeToString[r.Rcode] + return resp, nil + } + + for _, ans := range r.Answer { + if txt, ok := ans.(*dns.TXT); ok { + resp.Records = append(resp.Records, strings.Join(txt.Txt, "")) + } + } + if len(resp.Records) == 0 { + resp.Status = "not-found" + resp.ErrorMsg = "no TXT record" + return resp, nil + } + for _, rec := range resp.Records { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") { + resp.Status = "ok" + return resp, nil + } + } + resp.Status = "no-dmarc-record" + resp.ErrorMsg = "no TXT starts with v=DMARC1" + return resp, nil +} diff --git a/model/resolver.go b/model/resolver.go index db75ea35..8614b722 100644 --- a/model/resolver.go +++ b/model/resolver.go @@ -88,6 +88,48 @@ type ResolverUsecase interface { ResolveQuestion(ResolverRequest) (*dns.Msg, error) FlattenSPF(SPFFlattenRequest) (*SPFFlattenResponse, error) FetchMTASTSPolicy(MTASTSPolicyRequest) (*MTASTSPolicyResponse, error) + CheckDMARCReportAuth(DMARCReportAuthRequest) (*DMARCReportAuthResponse, error) +} + +// DMARCReportAuthRequest asks the backend to check whether ExternalDomain +// authorizes receiving DMARC reports for Owner, by resolving the TXT record +// at ._report._dmarc. per RFC 7489 sec. 7.1. +type DMARCReportAuthRequest struct { + // Resolver is the name of the resolver to use (or local or custom). + Resolver string `json:"resolver,omitempty"` + + // Custom is the address to the recursive server to use. + Custom string `json:"custom,omitempty"` + + // Owner is the protected domain whose DMARC record references the + // external reporting destination (e.g. "example.com"). + Owner string `json:"owner"` + + // ExternalDomain is the FQDN of the reporting destination, taken from + // the host part of a rua/ruf URI (e.g. "reports.thirdparty.tld"). + ExternalDomain string `json:"externalDomain"` +} + +// DMARCReportAuthResponse reports whether ExternalDomain authorizes Owner to +// send it DMARC aggregate or forensic reports. +type DMARCReportAuthResponse struct { + // QueriedName is the FQDN that was looked up (echoes the synthesized + // name so the UI can surface it without rebuilding it). + QueriedName string `json:"queriedName"` + + // Status is the high-level outcome: + // "ok" at least one TXT starting with "v=DMARC1" was found + // "no-dmarc-record" TXT records exist but none start with v=DMARC1 + // "not-found" NXDOMAIN or no TXT at the synthesized name + // "dns-error" resolver returned an error or refused + // "resolver-error" the resolver could not be contacted + Status string `json:"status"` + + // ErrorMsg gives a short human-readable reason when Status != "ok". + ErrorMsg string `json:"errorMsg,omitempty"` + + // Records is the list of TXT records returned (may be empty). + Records []string `json:"records,omitempty"` } // SPFFlattenRequest asks the backend to recursively walk an SPF record and