feat(bimi): hold the certificate, check the whole setup, serve the VMC

A Verified Mark Certificate cannot be made here: an authority issues it after
checking the organisation. What the service can do is hold the file the
authority returns, serve it where the DNS record points, and refuse one that
would make receivers ignore BIMI without a word.

- migration 0015: domain_bimi keeps the PEM chain next to the logo
- bimi.InspectCertificate reads a chain and reports every reason it would not
  work: private key blocks refused outright, the brand-indicator usage, the
  validity period, the domain (exact, wildcard or organisational), and that
  the logo embedded in the certificate (gzipped data URI in the logotype
  extension) is byte for byte the logo being published. Tested against two
  real public VMCs (eBay via DigiCert, CNN via Valimail) plus mutations
- GET /.well-known/bimi/vmc.pem (public, conditional, cacheable for an hour),
  and PUT/DELETE /api/v1/admin/bimi/certificate; the GET of the logo now
  reports the certificate as it stands against the logo being served, and
  replacing a logo under a held certificate says it no longer matches
- GET /api/v1/admin/bimi/check looks at what a receiver would: the logo, DMARC
  enforcement, the BIMI record, the public logo URL, the certificate and its
  public URL. Its HTTP client is HTTPS only and refuses non-public addresses
  after DNS, since the URLs come from DNS and an operator field
- the DNS record points a= at the held certificate when there is one

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-16 16:50:33 -03:00
1 parent c0f556b190
commit 109b8360dd
21 files changed
+2129 -19

No files matched your search

@@ -2,21 +2,28 @@ package httppresentation
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"lambdamail/protocols/internal/domain/bimi"
)
// BimiStore holds the mark a domain publishes.
// BimiStore holds the mark a domain publishes, and the certificate that
// vouches for it.
type BimiStore interface {
Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error)
Load(ctx context.Context, domain string) (*BimiLogo, error)
Delete(ctx context.Context, domain string) error
// SaveCertificate reports false when the domain has no logo to attach it to.
SaveCertificate(ctx context.Context, domain string, pem []byte) (bool, error)
DeleteCertificate(ctx context.Context, domain string) error
}
// BimiLogo is a stored mark as this layer sees it.
@@ -24,6 +31,8 @@ type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
// VmcPEM is the certificate chain held here, if the operator uploaded one.
VmcPEM []byte
}
type bimiAPI struct {
@@ -32,14 +41,27 @@ type bimiAPI struct {
// domainOf maps the host a request arrived on to the mail domain whose
// logo should answer, so one deployment can serve several.
defaultDomain string
// mailHost is where the logo and the certificate are served from: the host
// the DNS record names.
mailHost string
now func() time.Time
dns bimi.Resolver
fetch bimi.Fetcher
}
// SetBimiAPI wires the BIMI logo store.
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain string) {
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain, mailHost string) {
if store == nil {
return
}
r.bimi = &bimiAPI{store: store, defaultDomain: defaultDomain}
r.bimi = &bimiAPI{
store: store,
defaultDomain: defaultDomain,
mailHost: mailHost,
now: time.Now,
dns: netResolver{},
fetch: newBimiFetcher(),
}
if sessionSecret != "" {
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
}
@@ -101,27 +123,37 @@ func (a *bimiAPI) domainFor(req *http.Request) string {
return a.defaultDomain
}
// handleBimiAdmin uploads, inspects or removes a domain's mark.
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
// requireBimiAdmin answers the admin session and domain checks shared by every
// BIMI console route. It writes the refusal itself and returns false.
func (r *Router) requireBimiAdmin(w http.ResponseWriter, req *http.Request) (string, bool) {
if r.bimi == nil || r.bimi.sessions == nil {
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
"The BIMI panel needs JWT_SECRET and a database")
return
return "", false
}
token := bearerOrCookie(req, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
return "", false
}
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
return "", false
}
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
return "", false
}
return domain, true
}
// handleBimiAdmin uploads, inspects or removes a domain's mark.
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
domain, ok := r.requireBimiAdmin(w, req)
if !ok {
return
}
@@ -142,6 +174,120 @@ func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
}
}
// handleBimiCertificate serves the certificate chain, like the logo: fetched
// by receiving providers that hold no session.
func (r *Router) handleBimiCertificate(w http.ResponseWriter, req *http.Request) {
if r.bimi == nil {
http.NotFound(w, req)
return
}
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))
if err != nil || logo == nil || len(logo.VmcPEM) == 0 {
http.NotFound(w, req)
return
}
sum := sha256.Sum256(logo.VmcPEM)
etag := `"` + hex.EncodeToString(sum[:]) + `"`
if req.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("ETag", etag)
// Shorter than the logo: a renewed certificate should reach receivers
// within the hour, not the day.
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(logo.VmcPEM)
}
// handleBimiCertificateAdmin uploads or removes the certificate chain.
func (r *Router) handleBimiCertificateAdmin(w http.ResponseWriter, req *http.Request) {
domain, ok := r.requireBimiAdmin(w, req)
if !ok {
return
}
switch req.Method {
case http.MethodPut:
r.bimi.putCertificate(w, req, domain)
case http.MethodDelete:
if err := r.bimi.store.DeleteCertificate(req.Context(), domain); err != nil {
log.Printf("bimi: could not delete the certificate for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the certificate")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "PUT or DELETE")
}
}
// handleBimiCheck looks at everything a receiver would look at, and says which
// part is wrong.
func (r *Router) handleBimiCheck(w http.ResponseWriter, req *http.Request) {
domain, ok := r.requireBimiAdmin(w, req)
if !ok {
return
}
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
return
}
logo, err := r.bimi.store.Load(req.Context(), domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
return
}
if logo == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "checks": []any{}})
return
}
ctx, cancel := context.WithTimeout(req.Context(), 25*time.Second)
defer cancel()
in := bimi.Inputs{
Domain: domain,
LogoURL: BimiLogoURL(r.bimi.mailHost),
SVG: logo.SVG,
CertificatePEM: logo.VmcPEM,
ExternalCertificateURL: logo.VmcURL,
Now: r.bimi.now(),
}
// The record must name the certificate that is actually served
switch {
case len(logo.VmcPEM) > 0:
in.CertificateURL = BimiVmcURL(r.bimi.mailHost)
default:
in.CertificateURL = logo.VmcURL
}
results := bimi.RunChecks(ctx, in, r.bimi.dns, r.bimi.fetch)
checks := make([]map[string]string, 0, len(results))
ready := true
for _, c := range results {
checks = append(checks, map[string]string{"id": c.ID, "status": string(c.Status), "detail": c.Detail})
// A missing certificate is a warning, not a fault: some receivers show the logo without one
if c.Status == bimi.StatusFail {
ready = false
}
}
writeJSON(w, http.StatusOK, map[string]any{"configured": true, "ready": ready, "checks": checks})
}
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
logo, err := a.store.Load(req.Context(), domain)
if err != nil {
@@ -153,11 +299,101 @@ func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"etag": logo.ETag,
"vmc_url": logo.VmcURL,
"bytes": len(logo.SVG),
"svg": string(logo.SVG),
"configured": true,
"etag": logo.ETag,
"vmc_url": logo.VmcURL,
"bytes": len(logo.SVG),
"svg": string(logo.SVG),
"logo_url": BimiLogoURL(a.mailHost),
"certificate_url": BimiVmcURL(a.mailHost),
"certificate": a.describeCertificate(domain, logo),
})
}
// describeCertificate reports the held certificate as it stands against the
// logo now being served. Read from the file each time, so it cannot drift from
// what receivers get.
func (a *bimiAPI) describeCertificate(domain string, logo *BimiLogo) map[string]any {
if len(logo.VmcPEM) == 0 {
return map[string]any{"present": false}
}
cert, violations := bimi.InspectCertificate(logo.VmcPEM, domain, logo.SVG, a.now())
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
out := map[string]any{"present": true, "valid": len(violations) == 0, "violations": reasons}
if cert != nil {
out["subject"] = cert.Subject
out["organization"] = cert.Organization
out["issuer"] = cert.Issuer
out["not_before"] = cert.NotBefore.UTC().Format(time.RFC3339)
out["not_after"] = cert.NotAfter.UTC().Format(time.RFC3339)
out["dns_names"] = cert.DNSNames
out["mark_type"] = cert.MarkType
out["kind"] = string(cert.Kind)
out["chain_length"] = cert.ChainLength
out["matches_logo"] = cert.MatchesLogo
out["warnings"] = cert.Warnings
}
return out
}
// putCertificate stores a certificate chain after checking it against the
// logo and the domain. A certificate that does not match is refused rather
// than stored: publishing one makes receivers ignore BIMI without a word.
func (a *bimiAPI) putCertificate(w http.ResponseWriter, req *http.Request, domain string) {
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxCertificateBytes*2))
if err != nil {
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
return
}
var payload struct {
PEM string `json:"pem"`
}
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with a pem field")
return
}
logo, err := a.store.Load(req.Context(), domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
return
}
if logo == nil {
writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first: a certificate only means something next to the logo it was issued for")
return
}
pem := []byte(strings.TrimSpace(payload.PEM) + "\n")
_, violations := bimi.InspectCertificate(pem, domain, logo.SVG, a.now())
if len(violations) > 0 {
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "INVALID_CERTIFICATE",
"message": "The certificate cannot be used for this domain and logo",
"violations": reasons,
})
return
}
saved, err := a.store.SaveCertificate(req.Context(), domain, pem)
if err != nil {
log.Printf("bimi: could not save the certificate for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the certificate")
return
}
if !saved {
writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"certificate": a.describeCertificate(domain, &BimiLogo{SVG: logo.SVG, VmcPEM: pem}),
})
}
@@ -208,10 +444,23 @@ func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "etag": etag})
// A certificate vouches for one exact SVG. Replacing the logo under a held
// certificate breaks it, which receivers do not report; say so now.
response := map[string]any{"ok": true, "etag": etag}
if held, err := a.store.Load(req.Context(), domain); err == nil && held != nil && len(held.VmcPEM) > 0 {
if cert, _ := bimi.InspectCertificate(held.VmcPEM, domain, held.SVG, a.now()); cert != nil && !cert.MatchesLogo {
response["certificate_mismatch"] = true
}
}
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)
}
// 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)
}
@@ -0,0 +1,337 @@
package httppresentation
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/domain/bimi"
)
// memoryBimiStore is the store without a database.
type memoryBimiStore struct {
logos map[string]*BimiLogo
}
func (m *memoryBimiStore) Save(_ context.Context, domain string, svg []byte, vmcURL string) (string, error) {
held := m.logos[domain]
logo := &BimiLogo{SVG: svg, ETag: "etag-" + domain, VmcURL: vmcURL}
if held != nil {
// Replacing the logo keeps a held certificate, as the real store does
logo.VmcPEM = held.VmcPEM
}
m.logos[domain] = logo
return logo.ETag, nil
}
func (m *memoryBimiStore) Load(_ context.Context, domain string) (*BimiLogo, error) {
return m.logos[domain], nil
}
func (m *memoryBimiStore) Delete(_ context.Context, domain string) error {
delete(m.logos, domain)
return nil
}
func (m *memoryBimiStore) SaveCertificate(_ context.Context, domain string, pem []byte) (bool, error) {
logo := m.logos[domain]
if logo == nil {
return false, nil
}
logo.VmcPEM = pem
return true, nil
}
func (m *memoryBimiStore) DeleteCertificate(_ context.Context, domain string) error {
if logo := m.logos[domain]; logo != nil {
logo.VmcPEM = nil
}
return nil
}
type fakeBimiDNS map[string][]string
func (f fakeBimiDNS) LookupTXT(_ context.Context, name string) ([]string, error) {
if records, ok := f[name]; ok {
return records, nil
}
return nil, errors.New("no such host")
}
func fixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile("../../domain/bimi/testdata/" + name)
if err != nil {
t.Fatal(err)
}
return data
}
// The certificates in the fixtures are valid on this date.
var bimiTestNow = time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
func newBimiRouter(store *memoryBimiStore) *Router {
r := &Router{mux: http.NewServeMux()}
r.bimi = &bimiAPI{
store: store,
sessions: testVerifier(interopSecret),
defaultDomain: "ebay.com",
mailHost: "mail.ebay.com",
now: func() time.Time { return bimiTestNow },
dns: fakeBimiDNS{},
fetch: func(context.Context, string) (bimi.Response, error) {
return bimi.Response{}, errors.New("offline")
},
}
r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo)
r.mux.HandleFunc("/.well-known/bimi/vmc.pem", r.handleBimiCertificate)
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)
return r
}
func bimiCall(r *Router, method, target, body string, authed bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if authed {
req.Header.Set("Authorization", "Bearer "+adminSurfaceToken2())
}
rec := httptest.NewRecorder()
r.mux.ServeHTTP(rec, req)
return rec
}
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("not JSON (%d): %s", rec.Code, rec.Body)
}
return out
}
func putLogo(t *testing.T, r *Router, svg []byte) *httptest.ResponseRecorder {
t.Helper()
payload, _ := json.Marshal(map[string]string{"svg": string(svg)})
return bimiCall(r, http.MethodPut, "/api/v1/admin/bimi?domain=ebay.com", string(payload), true)
}
func putCertificate(r *Router, pem []byte) *httptest.ResponseRecorder {
payload, _ := json.Marshal(map[string]string{"pem": string(pem)})
return bimiCall(r, http.MethodPut, "/api/v1/admin/bimi/certificate?domain=ebay.com", string(payload), true)
}
func TestBimiAdminRoutesNeedAnAdminSession(t *testing.T) {
r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{}})
for _, target := range []string{
"/api/v1/admin/bimi?domain=ebay.com",
"/api/v1/admin/bimi/certificate?domain=ebay.com",
"/api/v1/admin/bimi/check?domain=ebay.com",
} {
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
if rec := bimiCall(r, method, target, "{}", false); rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s without a session answered %d", method, target, rec.Code)
}
}
}
if rec := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check", "", true); rec.Code != http.StatusBadRequest {
t.Errorf("no domain answered %d", rec.Code)
}
}
func TestBimiCertificateNeedsALogoFirst(t *testing.T) {
r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{}})
rec := putCertificate(r, fixture(t, "ebay.pem"))
if rec.Code != http.StatusConflict {
t.Fatalf("answered %d: %s", rec.Code, rec.Body)
}
}
func TestBimiAcceptsTheRealCertificateAndServesIt(t *testing.T) {
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
r := newBimiRouter(store)
if rec := putLogo(t, r, fixture(t, "ebay.svg")); rec.Code != http.StatusOK {
t.Fatalf("the real logo was refused: %s", rec.Body)
}
if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false); rec.Code != http.StatusNotFound {
t.Errorf("before upload the public URL answered %d, want 404", rec.Code)
}
rec := putCertificate(r, fixture(t, "ebay.pem"))
if rec.Code != http.StatusOK {
t.Fatalf("the real certificate was refused: %s", rec.Body)
}
cert := jsonBody(t, rec)["certificate"].(map[string]any)
if cert["valid"] != true || cert["matches_logo"] != true || cert["kind"] != "vmc" {
t.Errorf("certificate report = %v", cert)
}
// The public document: no session, the PEM, cacheable, and conditional
public := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false)
if public.Code != http.StatusOK || !strings.Contains(public.Body.String(), "BEGIN CERTIFICATE") {
t.Fatalf("public answered %d", public.Code)
}
if got := public.Header().Get("Content-Type"); got != "application/x-pem-file" {
t.Errorf("content type = %q", got)
}
if public.Header().Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(public.Header().Get("Cache-Control"), "public") {
t.Errorf("headers = %v", public.Header())
}
conditional := httptest.NewRequest(http.MethodGet, "/.well-known/bimi/vmc.pem", nil)
conditional.Header.Set("If-None-Match", public.Header().Get("ETag"))
again := httptest.NewRecorder()
r.mux.ServeHTTP(again, conditional)
if again.Code != http.StatusNotModified {
t.Errorf("conditional request answered %d", again.Code)
}
// 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" {
t.Errorf("urls = %v / %v", got["logo_url"], got["certificate_url"])
}
if got["certificate"].(map[string]any)["present"] != true {
t.Errorf("certificate = %v", got["certificate"])
}
// Removing it leaves the logo
if rec := bimiCall(r, http.MethodDelete, "/api/v1/admin/bimi/certificate?domain=ebay.com", "", true); rec.Code != http.StatusOK {
t.Fatalf("delete answered %d", rec.Code)
}
if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false); rec.Code != http.StatusNotFound {
t.Errorf("after delete the public URL answered %d", rec.Code)
}
if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/default.svg", "", false); rec.Code != http.StatusOK {
t.Errorf("the logo disappeared with the certificate: %d", rec.Code)
}
}
func TestBimiRefusesACertificateForAnotherLogo(t *testing.T) {
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
r := newBimiRouter(store)
// CNN's logo is valid SVG Tiny PS but is not the one eBay's certificate holds
if rec := putLogo(t, r, fixture(t, "cnn.svg")); rec.Code != http.StatusOK {
t.Fatalf("logo refused: %s", rec.Body)
}
rec := putCertificate(r, fixture(t, "ebay.pem"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("answered %d: %s", rec.Code, rec.Body)
}
violations, _ := jsonBody(t, rec)["violations"].([]any)
if len(violations) == 0 || !strings.Contains(violations[0].(string), "logoMismatch") {
t.Errorf("violations = %v", violations)
}
if len(store.logos["ebay.com"].VmcPEM) != 0 {
t.Error("a certificate that does not match was stored")
}
}
func TestBimiNeverStoresAPrivateKey(t *testing.T) {
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
r := newBimiRouter(store)
putLogo(t, r, fixture(t, "ebay.svg"))
key := "-----BEGIN PRIVATE KEY-----\nQUJD\n-----END PRIVATE KEY-----\n"
rec := putCertificate(r, append(fixture(t, "ebay.pem"), key...))
if rec.Code != http.StatusBadRequest {
t.Fatalf("answered %d", rec.Code)
}
if len(store.logos["ebay.com"].VmcPEM) != 0 {
t.Error("a private key was stored and would be served publicly")
}
if strings.Contains(rec.Body.String(), "QUJD") {
t.Error("the refusal echoed the key")
}
}
func TestBimiCertificateBodyMustBeJSON(t *testing.T) {
r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{"ebay.com": {SVG: fixture(t, "ebay.svg")}}})
if rec := bimiCall(r, http.MethodPut, "/api/v1/admin/bimi/certificate?domain=ebay.com", "-----BEGIN CERTIFICATE-----", true); rec.Code != http.StatusBadRequest {
t.Errorf("answered %d", rec.Code)
}
}
func TestBimiReplacingTheLogoWarnsAboutTheHeldCertificate(t *testing.T) {
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
r := newBimiRouter(store)
putLogo(t, r, fixture(t, "ebay.svg"))
putCertificate(r, fixture(t, "ebay.pem"))
// Another valid logo: the certificate no longer vouches for what is served
rec := putLogo(t, r, fixture(t, "cnn.svg"))
if rec.Code != http.StatusOK || jsonBody(t, rec)["certificate_mismatch"] != true {
t.Fatalf("no warning: %d %s", rec.Code, rec.Body)
}
cert := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true))["certificate"].(map[string]any)
if cert["valid"] != false || cert["matches_logo"] != false {
t.Errorf("the console should show the certificate as no longer matching: %v", cert)
}
}
func TestBimiCheckReportsEachStep(t *testing.T) {
store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
r := newBimiRouter(store)
putLogo(t, r, fixture(t, "ebay.svg"))
rec := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=ebay.com", "", true)
body := jsonBody(t, rec)
if rec.Code != http.StatusOK || body["ready"] != false {
t.Fatalf("with nothing published it must not be ready: %d %v", rec.Code, body)
}
ids := map[string]string{}
for _, c := range body["checks"].([]any) {
m := c.(map[string]any)
ids[m["id"].(string)] = m["status"].(string)
}
if ids["logo"] != "ok" || ids["dmarc"] != "fail" || ids["record"] != "fail" {
t.Errorf("checks = %v", ids)
}
none := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=unknown.test", "", true)
if jsonBody(t, none)["configured"] != false {
t.Errorf("a domain with no logo: %s", none.Body)
}
}
func TestPublicAddress(t *testing.T) {
for address, want := range map[string]bool{
"8.8.8.8": true,
"1.1.1.1": true,
"2001:4860::8888": true,
"127.0.0.1": false,
"10.0.0.5": false,
"172.16.3.4": false,
"192.168.1.1": false,
"169.254.169.254": false,
"100.64.0.1": false,
"0.0.0.0": false,
"::1": false,
"fe80::1": false,
"fc00::1": false,
"224.0.0.1": false,
} {
if got := publicAddress(net.ParseIP(address)); got != want {
t.Errorf("publicAddress(%s) = %v, want %v", address, got, want)
}
}
}
// The fetcher is what keeps the console's checks from reaching this server's
// own network. It refuses a literal private address before any request leaves.
func TestFetcherRefusesPrivateAndPlainHTTP(t *testing.T) {
fetch := newBimiFetcher()
for _, url := range []string{"http://example.test/logo.svg", "https://127.0.0.1/logo.svg", "https://169.254.169.254/latest/meta-data/", "https://[::1]/x"} {
if _, err := fetch(context.Background(), url); err == nil {
t.Errorf("%s was fetched", url)
}
}
}
@@ -0,0 +1,95 @@
package httppresentation
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"syscall"
"time"
"lambdamail/protocols/internal/domain/bimi"
)
// netResolver reads TXT records from the system resolver.
type netResolver struct{}
func (netResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return net.DefaultResolver.LookupTXT(ctx, name)
}
// maxFetchBytes bounds what a check reads. A logo is at most 32 KB and a
// certificate chain about 10 KB.
const maxFetchBytes = 256 * 1024
// newBimiFetcher returns the HTTP client the console's checks use.
//
// The URLs it is given come from DNS and from a field an operator filled in,
// so it must not become a way to make this server call its own network: HTTPS
// only, and the address actually connected to (after DNS, so a name that
// rebinds to a private address is caught too) must be a public one.
func newBimiFetcher() bimi.Fetcher {
dialer := &net.Dialer{
Timeout: 6 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || !publicAddress(ip) {
return errors.New("refusing to connect to a non-public address")
}
return nil
},
}
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{DialContext: dialer.DialContext, TLSHandshakeTimeout: 6 * time.Second},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
if req.URL.Scheme != "https" {
return errors.New("redirect to a non-HTTPS URL")
}
return nil
},
}
return func(ctx context.Context, url string) (bimi.Response, error) {
if !strings.HasPrefix(url, "https://") {
return bimi.Response{}, fmt.Errorf("%q is not an https URL", url)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return bimi.Response{}, err
}
resp, err := client.Do(req)
if err != nil {
return bimi.Response{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes))
if err != nil {
return bimi.Response{}, err
}
return bimi.Response{Status: resp.StatusCode, ContentType: resp.Header.Get("Content-Type"), Body: body}, nil
}
}
// publicAddress is false for anything that is not a routable public address.
func publicAddress(ip net.IP) bool {
switch {
case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(),
ip.IsMulticast(), ip.IsUnspecified(), ip.IsInterfaceLocalMulticast():
return false
}
// Carrier-grade NAT, 100.64.0.0/10, which IsPrivate does not cover
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xC0 == 64 {
return false
}
return true
}
@@ -80,7 +80,10 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
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("/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)
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)