diff --git a/README.md b/README.md index 1bec364..0f101b2 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,12 @@ everyone who sends from it. Publishing one needs three things, and the console file, DMARC, the BIMI record in DNS, the public URL of the logo, the certificate and its public URL, and names the one that is wrong. -The logo is served from `https:///.well-known/bimi/default.svg`. +The logo is served from `https:///.well-known/bimi//default.svg` +and the certificate from `...//vmc.pem`: one mail host, a path per +domain, so every domain the server hosts can publish its own. (The earlier +`/.well-known/bimi/default.svg` chose the domain from the host name, which could +name only the primary domain; every other domain's record pointed at a 404. It +still answers, for records already published.) **The DNS record follows the picture.** With a Cloudflare token configured, the `default._bimi.` TXT record is part of what the DNS reconciler diff --git a/services/protocols/cmd/lambdamail-protocols/bimi_publication.go b/services/protocols/cmd/lambdamail-protocols/bimi_publication.go index 81b5c79..e036820 100644 --- a/services/protocols/cmd/lambdamail-protocols/bimi_publication.go +++ b/services/protocols/cmd/lambdamail-protocols/bimi_publication.go @@ -22,15 +22,15 @@ func bimiPublication(ctx context.Context, repo *postgres.BimiRepository, cfg con // Unknown is not "no logo": returning an empty publication here would // have the sync delete the record over a database hiccup. log.Printf("BIMI: could not read the logo for %s, leaving its record alone: %v", domain, err) - return bimi.Publication{LogoURL: httppresentation.BimiLogoURL(cfg.PrimaryMailHost)} + return bimi.Publication{LogoURL: httppresentation.BimiLogoURL(cfg.PrimaryMailHost, domain)} } if logo == nil { return bimi.Publication{} } return bimi.Publish( logo.SVG, logo.VmcPEM, logo.VmcURL, domain, - httppresentation.BimiLogoURL(cfg.PrimaryMailHost), - httppresentation.BimiVmcURL(cfg.PrimaryMailHost), + httppresentation.BimiLogoURL(cfg.PrimaryMailHost, domain), + httppresentation.BimiVmcURL(cfg.PrimaryMailHost, domain), time.Now(), ) } diff --git a/services/protocols/internal/presentation/http/bimi_api.go b/services/protocols/internal/presentation/http/bimi_api.go index ca60afb..0ace047 100644 --- a/services/protocols/internal/presentation/http/bimi_api.go +++ b/services/protocols/internal/presentation/http/bimi_api.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "regexp" "strings" "time" @@ -53,8 +54,12 @@ type bimiAPI struct { publish func(domain string) } -// SetBimiPublisher wires what happens after a logo or certificate changes. +// SetBimiPublisher wires what happens after a logo or certificate changes. It +// may be called before or after SetBimiAPI: it was once called before, found no +// BIMI API to attach to, and was dropped without a word, so saving a logo never +// published anything. func (r *Router) SetBimiPublisher(publish func(domain string)) { + r.bimiPublish = publish if r.bimi != nil { r.bimi.publish = publish } @@ -81,6 +86,7 @@ func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain, mailH now: time.Now, dns: netResolver{}, fetch: newBimiFetcher(), + publish: r.bimiPublish, } if sessionSecret != "" { r.bimi.sessions = NewWebSessionVerifier(sessionSecret) @@ -98,12 +104,15 @@ func (r *Router) handleBimiLogo(w http.ResponseWriter, req *http.Request) { http.NotFound(w, req) return } + r.serveBimiLogo(w, req, r.bimi.domainFor(req)) +} + +func (r *Router) serveBimiLogo(w http.ResponseWriter, req *http.Request, domain string) { if req.Method != http.MethodGet && req.Method != http.MethodHead { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required") return } - domain := r.bimi.domainFor(req) logo, err := r.bimi.store.Load(req.Context(), domain) if err != nil || logo == nil { http.NotFound(w, req) @@ -202,12 +211,16 @@ func (r *Router) handleBimiCertificate(w http.ResponseWriter, req *http.Request) http.NotFound(w, req) return } + r.serveBimiCertificate(w, req, r.bimi.domainFor(req)) +} + +func (r *Router) serveBimiCertificate(w http.ResponseWriter, req *http.Request, domain string) { if req.Method != http.MethodGet && req.Method != http.MethodHead { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required") return } - logo, err := r.bimi.store.Load(req.Context(), r.bimi.domainFor(req)) + logo, err := r.bimi.store.Load(req.Context(), domain) if err != nil || logo == nil || len(logo.VmcPEM) == 0 { http.NotFound(w, req) return @@ -285,7 +298,7 @@ func (r *Router) handleBimiCheck(w http.ResponseWriter, req *http.Request) { // sync writes it, so "the record is wrong" means it differs from what the // reconciler would publish. publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain, - BimiLogoURL(r.bimi.mailHost), BimiVmcURL(r.bimi.mailHost), r.bimi.now()) + BimiLogoURL(r.bimi.mailHost, domain), BimiVmcURL(r.bimi.mailHost, domain), r.bimi.now()) in := bimi.Inputs{ Domain: domain, LogoURL: publication.LogoURL, @@ -321,7 +334,7 @@ func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) { return } publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain, - BimiLogoURL(a.mailHost), BimiVmcURL(a.mailHost), a.now()) + BimiLogoURL(a.mailHost, domain), BimiVmcURL(a.mailHost, domain), a.now()) writeJSON(w, http.StatusOK, map[string]any{ "configured": true, "etag": logo.ETag, @@ -333,8 +346,8 @@ func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) { "vmc_url": logo.VmcURL, "bytes": len(logo.SVG), "svg": string(logo.SVG), - "logo_url": BimiLogoURL(a.mailHost), - "certificate_url": BimiVmcURL(a.mailHost), + "logo_url": BimiLogoURL(a.mailHost, domain), + "certificate_url": BimiVmcURL(a.mailHost, domain), "certificate": a.describeCertificate(domain, logo), }) } @@ -487,12 +500,45 @@ func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) { writeJSON(w, http.StatusOK, response) } -// BimiLogoURL is where a domain's mark is served from. -func BimiLogoURL(mailHost string) string { - return fmt.Sprintf("https://%s/.well-known/bimi/default.svg", mailHost) +// domainName accepts what a mail domain can be, and nothing a path could use to +// reach anything else. +var domainName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +// handleBimiPerDomain serves /.well-known/bimi//default.svg and +// /.well-known/bimi//vmc.pem. +// +// The legacy paths pick the domain from the host the request arrived on, which +// can name only the one domain whose mail host that is. The record a domain +// publishes names a URL on the deployment's mail host whatever the domain is, +// so every domain other than the primary one pointed at a logo that was not its +// own and got a 404. +func (r *Router) handleBimiPerDomain(w http.ResponseWriter, req *http.Request) { + if r.bimi == nil { + http.NotFound(w, req) + return + } + parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/.well-known/bimi/"), "/") + if len(parts) != 2 || !domainName.MatchString(strings.ToLower(parts[0])) { + http.NotFound(w, req) + return + } + domain := strings.ToLower(parts[0]) + switch parts[1] { + case "default.svg": + r.serveBimiLogo(w, req, domain) + case "vmc.pem": + r.serveBimiCertificate(w, req, domain) + default: + http.NotFound(w, req) + } } -// BimiVmcURL is where the certificate held here is served from. -func BimiVmcURL(mailHost string) string { - return fmt.Sprintf("https://%s/.well-known/bimi/vmc.pem", mailHost) +// BimiLogoURL is where a domain's mark is served from. +func BimiLogoURL(mailHost, domain string) string { + return fmt.Sprintf("https://%s/.well-known/bimi/%s/default.svg", mailHost, strings.ToLower(domain)) +} + +// BimiVmcURL is where the certificate held for a domain is served from. +func BimiVmcURL(mailHost, domain string) string { + return fmt.Sprintf("https://%s/.well-known/bimi/%s/vmc.pem", mailHost, strings.ToLower(domain)) } diff --git a/services/protocols/internal/presentation/http/bimi_api_test.go b/services/protocols/internal/presentation/http/bimi_api_test.go index 61b1e39..0a24ac0 100644 --- a/services/protocols/internal/presentation/http/bimi_api_test.go +++ b/services/protocols/internal/presentation/http/bimi_api_test.go @@ -92,6 +92,7 @@ func newBimiRouter(store *memoryBimiStore) *Router { } r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo) r.mux.HandleFunc("/.well-known/bimi/vmc.pem", r.handleBimiCertificate) + r.mux.HandleFunc("/.well-known/bimi/", r.handleBimiPerDomain) r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin) r.mux.HandleFunc("/api/v1/admin/bimi/certificate", r.handleBimiCertificateAdmin) r.mux.HandleFunc("/api/v1/admin/bimi/check", r.handleBimiCheck) @@ -195,7 +196,7 @@ func TestBimiAcceptsTheRealCertificateAndServesIt(t *testing.T) { // The console reads it back, with the URLs the DNS record should carry got := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true)) - if got["logo_url"] != "https://mail.ebay.com/.well-known/bimi/default.svg" || got["certificate_url"] != "https://mail.ebay.com/.well-known/bimi/vmc.pem" { + if got["logo_url"] != "https://mail.ebay.com/.well-known/bimi/ebay.com/default.svg" || got["certificate_url"] != "https://mail.ebay.com/.well-known/bimi/ebay.com/vmc.pem" { t.Errorf("urls = %v / %v", got["logo_url"], got["certificate_url"]) } if got["certificate"].(map[string]any)["present"] != true { @@ -409,7 +410,7 @@ func TestBimiCheckExpectsWhatTheReconcilerPublishes(t *testing.T) { r.bimi.dns = fakeBimiDNS{ "_dmarc.ebay.com": {"v=DMARC1; p=reject"}, - "default._bimi.ebay.com": {"v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg;"}, + "default._bimi.ebay.com": {"v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/ebay.com/default.svg;"}, } body := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=ebay.com", "", true)) @@ -439,12 +440,12 @@ func TestBimiGetReportsTheRecordItWouldPublish(t *testing.T) { } got := read() - if got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg;" || got["certificate_withheld"] != "" { + if got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/ebay.com/default.svg;" || got["certificate_withheld"] != "" { t.Fatalf("logo only: %v", got) } putCertificate(r, fixture(t, "ebay.pem")) - if got := read(); got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/default.svg; a=https://mail.ebay.com/.well-known/bimi/vmc.pem;" { + if got := read(); got["dns_record"] != "v=BIMI1; l=https://mail.ebay.com/.well-known/bimi/ebay.com/default.svg; a=https://mail.ebay.com/.well-known/bimi/ebay.com/vmc.pem;" { t.Fatalf("with its certificate: %v", got["dns_record"]) } @@ -455,3 +456,83 @@ func TestBimiGetReportsTheRecordItWouldPublish(t *testing.T) { t.Fatalf("after replacing the logo: %v / %v", got["dns_record"], got["certificate_withheld"]) } } + +// The bug this pins: every domain's record named the deployment's own mail host, +// and the host chose the domain, so only the primary domain's logo was ever +// found. A second domain's logo has to be served under its own name. +func TestBimiServesEachDomainsLogoUnderItsOwnName(t *testing.T) { + store := &memoryBimiStore{logos: map[string]*BimiLogo{ + "ebay.com": {SVG: fixture(t, "ebay.svg"), ETag: "e1"}, + "cnn.com": {SVG: fixture(t, "cnn.svg"), ETag: "c1", VmcPEM: fixture(t, "ebay.pem")}, + }} + r := newBimiRouter(store) // its default domain, and the host these requests arrive on, is ebay.com + + logo := bimiCall(r, http.MethodGet, "/.well-known/bimi/cnn.com/default.svg", "", false) + if logo.Code != http.StatusOK || logo.Body.String() != string(fixture(t, "cnn.svg")) { + t.Fatalf("the second domain's logo: %d", logo.Code) + } + if got := logo.Header().Get("Content-Type"); got != "image/svg+xml" { + t.Errorf("content type = %q", got) + } + if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/cnn.com/vmc.pem", "", false); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BEGIN CERTIFICATE") { + t.Errorf("the second domain's certificate: %d", rec.Code) + } + // Case does not matter in a host name + if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/CNN.com/default.svg", "", false); rec.Code != http.StatusOK { + t.Errorf("upper case: %d", rec.Code) + } + // The legacy path still answers with the host's own domain + if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/default.svg", "", false); rec.Code != http.StatusOK || rec.Body.String() != string(fixture(t, "ebay.svg")) { + t.Errorf("legacy path: %d", rec.Code) + } +} + +func TestBimiPerDomainPathsRefuseWhatIsNotADomainLogo(t *testing.T) { + store := &memoryBimiStore{logos: map[string]*BimiLogo{"ebay.com": {SVG: fixture(t, "ebay.svg"), ETag: "e1"}}} + r := newBimiRouter(store) + for _, path := range []string{ + "/.well-known/bimi/nobody.test/default.svg", // a domain with no logo + "/.well-known/bimi/ebay.com/other.svg", // not a document we serve + "/.well-known/bimi/ebay.com/", // no document + "/.well-known/bimi/ebay.com/x/default.svg", // extra segment + "/.well-known/bimi/ebay.com/default.svg/x", + "/.well-known/bimi/-bad.com/default.svg", // not a host name + "/.well-known/bimi/a_b.com/default.svg", + "/.well-known/bimi/localhost/default.svg", // a single label is not a mail domain + "/.well-known/bimi/%2e%2e/default.svg", + } { + if rec := bimiCall(r, http.MethodGet, path, "", false); rec.Code != http.StatusNotFound { + t.Errorf("%s answered %d, want 404", path, rec.Code) + } + } + if rec := bimiCall(r, http.MethodPost, "/.well-known/bimi/ebay.com/default.svg", "", false); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST answered %d", rec.Code) + } +} + +// The wiring bug: the publisher was handed over before the BIMI API existed and +// was dropped. Either order has to work. +func TestBimiPublisherSurvivesTheOrderItIsWiredIn(t *testing.T) { + store := &memoryBimiStore{logos: map[string]*BimiLogo{}} + publisher := func(string) {} + + before := &Router{mux: http.NewServeMux()} + before.SetBimiPublisher(publisher) + before.SetBimiAPI(store, "secret", "ebay.com", "mail.ebay.com") + if before.bimi.publish == nil { + t.Error("a publisher set before the API was lost") + } + + after := &Router{mux: http.NewServeMux()} + after.SetBimiAPI(store, "secret", "ebay.com", "mail.ebay.com") + after.SetBimiPublisher(publisher) + if after.bimi.publish == nil { + t.Error("a publisher set after the API was lost") + } + + none := &Router{mux: http.NewServeMux()} + none.SetBimiAPI(store, "secret", "ebay.com", "mail.ebay.com") + if none.bimi.publish != nil { + t.Error("with no DNS provider there is nothing to publish to") + } +} diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index b779ef1..37354c2 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -41,6 +41,7 @@ type Router struct { dnsReconciler DomainReconciler dnsStatus DnsStatusWriter bimi *bimiAPI + bimiPublish func(domain string) // degradedFunc reports a condition that leaves the service running but // not fit for production, the clearest case being a self-signed // certificate standing in for one Traefik never issued @@ -81,6 +82,7 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt) r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo) r.mux.HandleFunc("/.well-known/bimi/vmc.pem", r.handleBimiCertificate) + r.mux.HandleFunc("/.well-known/bimi/", r.handleBimiPerDomain) r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin) r.mux.HandleFunc("/api/v1/admin/bimi/certificate", r.handleBimiCertificateAdmin) r.mux.HandleFunc("/api/v1/admin/bimi/check", r.handleBimiCheck) diff --git a/services/webmail/next.config.ts b/services/webmail/next.config.ts index 57adf04..698e468 100644 --- a/services/webmail/next.config.ts +++ b/services/webmail/next.config.ts @@ -36,6 +36,16 @@ const nextConfig: NextConfig = { source: "/.well-known/bimi/default.svg", destination: `${PROTOCOLS_SERVICE_URL}/.well-known/bimi/default.svg`, }, + { + // A record names its own domain in the path, so every domain the server + // hosts can publish its own logo and certificate from this one host. + source: "/.well-known/bimi/:domain/default.svg", + destination: `${PROTOCOLS_SERVICE_URL}/.well-known/bimi/:domain/default.svg`, + }, + { + source: "/.well-known/bimi/:domain/vmc.pem", + destination: `${PROTOCOLS_SERVICE_URL}/.well-known/bimi/:domain/vmc.pem`, + }, { // The BIMI certificate (VMC/CMC) the DNS record's a= tag points at, // fetched the same way as the logo above. diff --git a/services/webmail/src/wellknown-routing.test.ts b/services/webmail/src/wellknown-routing.test.ts index cc0357d..963b1a6 100644 --- a/services/webmail/src/wellknown-routing.test.ts +++ b/services/webmail/src/wellknown-routing.test.ts @@ -66,4 +66,12 @@ describe("the routes the protocols service owns", () => { expect(config).toContain("/.well-known/bimi/vmc.pem"); expect(config).toMatch(/bimi\/vmc\.pem[\s\S]{0,200}PROTOCOLS_SERVICE_URL/); }); + + // Each domain's record names its own logo and certificate under its own name. + // Without these rewrites only the host's primary domain had a logo that could + // be fetched; every other domain's record pointed at a 404. + it("routes the per-domain BIMI logo and certificate to the service", () => { + expect(config).toMatch(/bimi\/:domain\/default\.svg[\s\S]{0,200}PROTOCOLS_SERVICE_URL[^\n]*:domain\/default\.svg/); + expect(config).toMatch(/bimi\/:domain\/vmc\.pem[\s\S]{0,200}PROTOCOLS_SERVICE_URL[^\n]*:domain\/vmc\.pem/); + }); });