revert: foto de perfil e BIMI, para restaurar o servico
O site ficou a responder 502 em todos os hostnames depois do deploy destas duas funcionalidades. A reversao vem primeiro; o diagnostico vem a seguir, com o servico de pe. As migracoes 0012 e 0013 ficam. Sao aditivas, nada no codigo revertido as usa, e apaga-las deixaria a base de dados numa versao sem ficheiro correspondente, o que parte o proprio migrate no arranque seguinte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e06162fa0
commit
e30d000e43
26 files changed
+21
-1899
No files matched your search
@@ -1,30 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||
)
|
||||
|
||||
// bimiStore adapts the repository to the shape the HTTP layer asks for, so
|
||||
// that layer does not import the database package.
|
||||
type bimiStore struct {
|
||||
repo *postgres.BimiRepository
|
||||
}
|
||||
|
||||
func (s bimiStore) Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error) {
|
||||
return s.repo.Save(ctx, domain, svg, vmcURL)
|
||||
}
|
||||
|
||||
func (s bimiStore) Load(ctx context.Context, domain string) (*httppresentation.BimiLogo, error) {
|
||||
logo, err := s.repo.Load(ctx, domain)
|
||||
if err != nil || logo == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httppresentation.BimiLogo{SVG: logo.SVG, ETag: logo.ETag, VmcURL: logo.VmcURL}, nil
|
||||
}
|
||||
|
||||
func (s bimiStore) Delete(ctx context.Context, domain string) error {
|
||||
return s.repo.Delete(ctx, domain)
|
||||
}
|
||||
@@ -230,15 +230,11 @@ func run(cfg config) {
|
||||
// list stops disagreeing with the page next to it.
|
||||
router.SetDnsStatusWriter(postgres.NewDomainRepository(pool))
|
||||
router.SetAdminDnsAPI(
|
||||
&dnsSpecSource{cfg: cfg, dkim: dkimRepo, bimi: postgres.NewBimiRepository(pool)},
|
||||
&dnsSpecSource{cfg: cfg, dkim: dkimRepo},
|
||||
netdns.NewPublicVerifier(),
|
||||
cfg.JwtSecret,
|
||||
)
|
||||
}
|
||||
// The BIMI mark: served to receivers over the well-known URL, managed from
|
||||
// the domains panel.
|
||||
router.SetBimiAPI(bimiStore{postgres.NewBimiRepository(pool)}, cfg.JwtSecret, cfg.domain())
|
||||
|
||||
// The TLS panel reads the live watcher instead of the constants the admin
|
||||
// service used to return, which reported a healthy certificate whatever
|
||||
// the process actually held.
|
||||
@@ -702,7 +698,6 @@ func runHealthcheck() {
|
||||
type dnsSpecSource struct {
|
||||
cfg config
|
||||
dkim *postgres.DkimRepository
|
||||
bimi *postgres.BimiRepository
|
||||
}
|
||||
|
||||
func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) {
|
||||
@@ -715,15 +710,6 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e
|
||||
RelaySpfInclude: relayConfig(s.cfg).SpfInclude(),
|
||||
}
|
||||
|
||||
// The BIMI record is published only when a mark is actually stored, so
|
||||
// the panel never advertises a logo that would 404.
|
||||
if s.bimi != nil {
|
||||
if logo, err := s.bimi.Load(ctx, domain); err == nil && logo != nil {
|
||||
spec.BimiLogoURL = httppresentation.BimiLogoURL(s.cfg.PrimaryMailHost)
|
||||
spec.BimiVmcURL = logo.VmcURL
|
||||
}
|
||||
}
|
||||
|
||||
// Read rather than provisioned: verifying must not create keys as a side
|
||||
// effect of somebody opening a screen.
|
||||
if s.dkim != nil {
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// Package bimi validates the logo a domain publishes for BIMI.
|
||||
//
|
||||
// BIMI is the only mechanism by which an external mail client shows an image
|
||||
// for a sender it has never met. It is per domain, not per mailbox, and the
|
||||
// receiving side is strict: a logo that is not SVG Tiny Portable/Secure is
|
||||
// ignored without explanation, so the checks that matter are made here rather
|
||||
// than discovered from a mailbox that quietly shows nothing.
|
||||
package bimi
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLogoBytes is the ceiling the BIMI specification sets. Receivers fetch
|
||||
// this over the network for every sender they evaluate.
|
||||
const MaxLogoBytes = 32 * 1024
|
||||
|
||||
// Violation is one reason a logo would be rejected.
|
||||
type Violation struct {
|
||||
Rule string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (v Violation) String() string { return fmt.Sprintf("%s: %s", v.Rule, v.Detail) }
|
||||
|
||||
// Elements that must not appear. Script and event handlers make the document
|
||||
// active; external references make it fetch; raster images defeat the point of
|
||||
// a vector mark and are how a tracking pixel would arrive.
|
||||
var forbiddenElements = []string{"script", "a", "image", "foreignObject", "use", "animate", "animateTransform", "animateMotion", "set"}
|
||||
|
||||
var (
|
||||
svgOpenRe = regexp.MustCompile(`(?is)<svg\b[^>]*>`)
|
||||
attrRe = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*"([^"]*)"|([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*'([^']*)'`)
|
||||
onEventRe = regexp.MustCompile(`(?is)\son[a-z]+\s*=`)
|
||||
externalRefRe = regexp.MustCompile(`(?is)(href|xlink:href|src)\s*=\s*["']\s*(https?:|//|data:)`)
|
||||
titleElementRe = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
|
||||
)
|
||||
|
||||
// Validate reports every reason the logo would not be accepted.
|
||||
//
|
||||
// All of them, not the first: an operator fixing one rule at a time against a
|
||||
// receiver that simply shows nothing is the worst way to learn these.
|
||||
func Validate(payload []byte) []Violation {
|
||||
var out []Violation
|
||||
add := func(rule, detail string) { out = append(out, Violation{Rule: rule, Detail: detail}) }
|
||||
|
||||
if len(payload) == 0 {
|
||||
return []Violation{{Rule: "empty", Detail: "the file is empty"}}
|
||||
}
|
||||
if len(payload) > MaxLogoBytes {
|
||||
add("size", fmt.Sprintf("%d bytes; the limit is %d", len(payload), MaxLogoBytes))
|
||||
}
|
||||
|
||||
// Parseable at all: a receiver will not repair a broken document.
|
||||
if err := xml.Unmarshal(payload, new(struct {
|
||||
XMLName xml.Name
|
||||
})); err != nil {
|
||||
return append(out, Violation{Rule: "xml", Detail: "the file is not well-formed XML"})
|
||||
}
|
||||
|
||||
text := string(payload)
|
||||
open := svgOpenRe.FindString(text)
|
||||
if open == "" {
|
||||
return append(out, Violation{Rule: "svg", Detail: "no <svg> root element"})
|
||||
}
|
||||
|
||||
attrs := map[string]string{}
|
||||
for _, m := range attrRe.FindAllStringSubmatch(open, -1) {
|
||||
if m[1] != "" {
|
||||
attrs[strings.ToLower(m[1])] = m[2]
|
||||
} else {
|
||||
attrs[strings.ToLower(m[3])] = m[4]
|
||||
}
|
||||
}
|
||||
|
||||
// The profile a receiver looks for. Without it the document is a plain
|
||||
// SVG and is ignored.
|
||||
if got := attrs["baseprofile"]; !strings.EqualFold(got, "tiny-ps") {
|
||||
add("baseProfile", fmt.Sprintf("must be \"tiny-ps\", found %q", got))
|
||||
}
|
||||
if got := attrs["version"]; got != "1.2" {
|
||||
add("version", fmt.Sprintf("must be \"1.2\", found %q", got))
|
||||
}
|
||||
|
||||
// A non-square mark is cropped or letterboxed differently by every client,
|
||||
// so the specification requires the viewBox itself be square.
|
||||
if err := checkSquareViewBox(attrs["viewbox"]); err != nil {
|
||||
add("viewBox", err.Error())
|
||||
}
|
||||
|
||||
// x and y are meaningful only when the mark is placed in another document,
|
||||
// and the specification forbids them on the root.
|
||||
for _, forbidden := range []string{"x", "y"} {
|
||||
if _, present := attrs[forbidden]; present {
|
||||
add("rootAttributes", fmt.Sprintf("the root <svg> must not carry %q", forbidden))
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one title, and it is what a screen reader announces.
|
||||
titles := titleElementRe.FindAllStringSubmatch(text, -1)
|
||||
switch {
|
||||
case len(titles) == 0:
|
||||
add("title", "a <title> element is required")
|
||||
case len(titles) > 1:
|
||||
add("title", fmt.Sprintf("found %d <title> elements; there must be exactly one", len(titles)))
|
||||
case strings.TrimSpace(titles[0][1]) == "":
|
||||
add("title", "the <title> element is empty")
|
||||
}
|
||||
|
||||
lower := strings.ToLower(text)
|
||||
for _, name := range forbiddenElements {
|
||||
if strings.Contains(lower, "<"+strings.ToLower(name)) {
|
||||
add("forbiddenElement", fmt.Sprintf("<%s> is not allowed", name))
|
||||
}
|
||||
}
|
||||
if onEventRe.MatchString(text) {
|
||||
add("script", "event handler attributes are not allowed")
|
||||
}
|
||||
if externalRefRe.MatchString(text) {
|
||||
add("externalReference", "the logo must not reference anything outside itself")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// checkSquareViewBox verifies the mark occupies a square.
|
||||
func checkSquareViewBox(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("a viewBox is required")
|
||||
}
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' || r == '\n' })
|
||||
if len(fields) != 4 {
|
||||
return fmt.Errorf("must be four numbers, found %q", value)
|
||||
}
|
||||
|
||||
var width, height float64
|
||||
if _, err := fmt.Sscanf(fields[2], "%g", &width); err != nil {
|
||||
return fmt.Errorf("width %q is not a number", fields[2])
|
||||
}
|
||||
if _, err := fmt.Sscanf(fields[3], "%g", &height); err != nil {
|
||||
return fmt.Errorf("height %q is not a number", fields[3])
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return fmt.Errorf("width and height must be positive")
|
||||
}
|
||||
if width != height {
|
||||
return fmt.Errorf("must be square; found %g by %g", width, height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package bimi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A logo that satisfies every rule, used as the starting point each case
|
||||
// then breaks in exactly one way.
|
||||
const goodLogo = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512">
|
||||
<title>LambdaMail</title>
|
||||
<rect width="512" height="512" fill="#1b1b25"/>
|
||||
<path d="M128 384 L256 128 L384 384 Z" fill="#9184d9"/>
|
||||
</svg>`
|
||||
|
||||
func rulesOf(vs []Violation) []string {
|
||||
out := make([]string, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
out = append(out, v.Rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(rules []string, want string) bool {
|
||||
for _, r := range rules {
|
||||
if r == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestValidate_AcceptsAConformingLogo(t *testing.T) {
|
||||
if vs := Validate([]byte(goodLogo)); len(vs) != 0 {
|
||||
t.Errorf("a conforming logo was rejected: %v", vs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RequiresTheProfileReceiversLookFor(t *testing.T) {
|
||||
cases := map[string]struct{ from, to, rule string }{
|
||||
// Without the profile the document is a plain SVG, and a receiver
|
||||
// ignores it without saying so.
|
||||
"missing baseProfile": {`baseProfile="tiny-ps" `, "", "baseProfile"},
|
||||
"wrong baseProfile": {`baseProfile="tiny-ps"`, `baseProfile="full"`, "baseProfile"},
|
||||
"missing version": {`version="1.2" `, "", "version"},
|
||||
"wrong version": {`version="1.2"`, `version="1.1"`, "version"},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logo := strings.Replace(goodLogo, tc.from, tc.to, 1)
|
||||
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
|
||||
t.Errorf("no %s violation reported", tc.rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every client crops or letterboxes a non-square mark differently, so the
|
||||
// specification requires the viewBox itself be square.
|
||||
func TestValidate_RequiresASquareViewBox(t *testing.T) {
|
||||
for _, viewBox := range []string{"0 0 512 256", "0 0 100 200", "0 0 512", "", "0 0 0 0", "0 0 a b"} {
|
||||
t.Run(viewBox, func(t *testing.T) {
|
||||
logo := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, fmt.Sprintf(`viewBox=%q`, viewBox), 1)
|
||||
if !contains(rulesOf(Validate([]byte(logo))), "viewBox") {
|
||||
t.Errorf("viewBox %q was accepted", viewBox)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Commas are a legal separator and must not be read as a malformed box.
|
||||
commas := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, `viewBox="0,0,512,512"`, 1)
|
||||
if contains(rulesOf(Validate([]byte(commas))), "viewBox") {
|
||||
t.Error("a comma-separated square viewBox was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RequiresExactlyOneTitle(t *testing.T) {
|
||||
none := strings.Replace(goodLogo, "<title>LambdaMail</title>", "", 1)
|
||||
if !contains(rulesOf(Validate([]byte(none))), "title") {
|
||||
t.Error("a logo with no title was accepted")
|
||||
}
|
||||
|
||||
two := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title>A</title><title>B</title>", 1)
|
||||
if !contains(rulesOf(Validate([]byte(two))), "title") {
|
||||
t.Error("a logo with two titles was accepted")
|
||||
}
|
||||
|
||||
empty := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title> </title>", 1)
|
||||
if !contains(rulesOf(Validate([]byte(empty))), "title") {
|
||||
t.Error("an empty title was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The logo is fetched and rendered by other people's mail clients. Anything
|
||||
// active or anything that reaches out over the network has no business in it.
|
||||
func TestValidate_RefusesAnythingActiveOrExternal(t *testing.T) {
|
||||
cases := map[string]struct{ insert, rule string }{
|
||||
"script element": {`<script>alert(1)</script>`, "forbiddenElement"},
|
||||
"link": {`<a href="https://example.test">x</a>`, "forbiddenElement"},
|
||||
"raster image": {`<image href="https://example.test/p.png"/>`, "forbiddenElement"},
|
||||
"foreign object": {`<foreignObject><b>x</b></foreignObject>`, "forbiddenElement"},
|
||||
"animation": {`<animate attributeName="x" to="9"/>`, "forbiddenElement"},
|
||||
"event handler": {`<rect width="1" height="1" onload="alert(1)"/>`, "script"},
|
||||
"external href": {`<rect width="1" height="1" xlink:href="https://example.test/x"/>`, "externalReference"},
|
||||
"data uri": {`<rect width="1" height="1" href="data:image/png;base64,AAAA"/>`, "externalReference"},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logo := strings.Replace(goodLogo, "</svg>", tc.insert+"</svg>", 1)
|
||||
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
|
||||
t.Errorf("%s was accepted: %v", name, Validate([]byte(logo)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RefusesWhatCannotBeReadAtAll(t *testing.T) {
|
||||
if !contains(rulesOf(Validate([]byte(""))), "empty") {
|
||||
t.Error("an empty file was accepted")
|
||||
}
|
||||
if !contains(rulesOf(Validate([]byte("<svg><unclosed>"))), "xml") {
|
||||
t.Error("malformed XML was accepted")
|
||||
}
|
||||
// A PNG renamed to .svg is the likeliest wrong upload of all.
|
||||
if len(Validate([]byte{0x89, 'P', 'N', 'G'})) == 0 {
|
||||
t.Error("a PNG was accepted as a logo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RefusesALogoTooBigToBeFetched(t *testing.T) {
|
||||
padding := strings.Repeat(" ", MaxLogoBytes)
|
||||
logo := strings.Replace(goodLogo, "</svg>", "<!--"+padding+"--></svg>", 1)
|
||||
if !contains(rulesOf(Validate([]byte(logo))), "size") {
|
||||
t.Error("an oversized logo was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// One rule at a time, against a receiver that shows nothing either way, is a
|
||||
// miserable way to learn these - so every violation is reported at once.
|
||||
func TestValidate_ReportsEveryViolationAtOnce(t *testing.T) {
|
||||
broken := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"><script/></svg>`
|
||||
rules := rulesOf(Validate([]byte(broken)))
|
||||
for _, want := range []string{"baseProfile", "version", "viewBox", "title", "forbiddenElement"} {
|
||||
if !contains(rules, want) {
|
||||
t.Errorf("missing %s; got %v", want, rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,6 @@ type DnsRecordSpec struct {
|
||||
// this domain. Without it every message the relay forwards fails SPF
|
||||
// (PLAN.md section 10.4).
|
||||
RelaySpfInclude string
|
||||
// BimiLogoURL is where the published mark is served from. Empty when the
|
||||
// domain has no logo, and then no BIMI record is published at all: an
|
||||
// advertised record pointing at nothing is worse than none, because a
|
||||
// receiver stops looking once it finds one.
|
||||
BimiLogoURL string
|
||||
// BimiVmcURL is the Verified Mark Certificate. Gmail and Outlook render
|
||||
// the logo only when the record carries one; without it the record is
|
||||
// still valid and other clients may use it.
|
||||
BimiVmcURL string
|
||||
}
|
||||
|
||||
// BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13
|
||||
@@ -205,21 +196,6 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
|
||||
})
|
||||
}
|
||||
|
||||
// BIMI, published only when there is a logo to point at.
|
||||
if spec.BimiLogoURL != "" {
|
||||
value := fmt.Sprintf("v=BIMI1; l=%s;", spec.BimiLogoURL)
|
||||
if spec.BimiVmcURL != "" {
|
||||
value = fmt.Sprintf("v=BIMI1; l=%s; a=%s;", spec.BimiLogoURL, spec.BimiVmcURL)
|
||||
}
|
||||
records = append(records, DnsRecord{
|
||||
Type: "TXT",
|
||||
Name: fmt.Sprintf("default._bimi.%s", domainName),
|
||||
Value: value,
|
||||
TTL: 1,
|
||||
Comment: "LambdaMail BIMI Record",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Conditional AAAA record if IPv6 is supplied
|
||||
if ownsMailHost && serverIPv6 != "" {
|
||||
records = append(records, DnsRecord{
|
||||
|
||||
@@ -195,72 +195,3 @@ func TestBuildDnsRecordSpecs_KeepsMailHostAddressesInsideTheDomain(t *testing.T)
|
||||
t.Errorf("lost the mail host addresses (A=%v AAAA=%v)", a, aaaa)
|
||||
}
|
||||
}
|
||||
|
||||
// BIMI is published only when there is a mark to point at.
|
||||
//
|
||||
// A record that advertises a logo which is not there is worse than no record:
|
||||
// a receiver stops looking once it finds one, so an empty pointer buys a
|
||||
// failed fetch instead of the fallback it would otherwise have used.
|
||||
func TestBuildDnsRecordSpecs_BimiOnlyWhenThereIsALogo(t *testing.T) {
|
||||
without := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||
DomainName: "example.test",
|
||||
MailHost: "mail.example.test",
|
||||
ServerIPv4: "192.0.2.1",
|
||||
})
|
||||
for _, r := range without {
|
||||
if strings.Contains(r.Name, "_bimi") {
|
||||
t.Errorf("published %s with no logo configured", r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
with := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||
DomainName: "example.test",
|
||||
MailHost: "mail.example.test",
|
||||
ServerIPv4: "192.0.2.1",
|
||||
BimiLogoURL: "https://mail.example.test/.well-known/bimi/default.svg",
|
||||
})
|
||||
|
||||
var found *DnsRecord
|
||||
for i, r := range with {
|
||||
if r.Name == "default._bimi.example.test" {
|
||||
found = &with[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("no BIMI record was published for a domain with a logo")
|
||||
}
|
||||
if found.Type != "TXT" {
|
||||
t.Errorf("BIMI record type is %q, want TXT", found.Type)
|
||||
}
|
||||
if !strings.HasPrefix(found.Value, "v=BIMI1;") {
|
||||
t.Errorf("value %q does not start with the version tag", found.Value)
|
||||
}
|
||||
// No certificate configured, so no a= tag: an empty one would advertise a
|
||||
// certificate that cannot be fetched.
|
||||
if strings.Contains(found.Value, "a=") {
|
||||
t.Errorf("value %q carries an empty certificate tag", found.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Gmail and Outlook render the mark only when the record points at a Verified
|
||||
// Mark Certificate, so the tag has to be carried when there is one.
|
||||
func TestBuildDnsRecordSpecs_BimiCarriesTheCertificateWhenConfigured(t *testing.T) {
|
||||
specs := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||
DomainName: "example.test",
|
||||
MailHost: "mail.example.test",
|
||||
ServerIPv4: "192.0.2.1",
|
||||
BimiLogoURL: "https://mail.example.test/.well-known/bimi/default.svg",
|
||||
BimiVmcURL: "https://mail.example.test/.well-known/bimi/default.pem",
|
||||
})
|
||||
|
||||
for _, r := range specs {
|
||||
if r.Name != "default._bimi.example.test" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(r.Value, "a=https://mail.example.test/.well-known/bimi/default.pem") {
|
||||
t.Errorf("value %q does not carry the certificate", r.Value)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no BIMI record was published")
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// BimiRepository stores the logo a domain publishes for BIMI.
|
||||
type BimiRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewBimiRepository(pool *pgxpool.Pool) *BimiRepository {
|
||||
return &BimiRepository{pool: pool}
|
||||
}
|
||||
|
||||
// BimiLogo is a stored mark.
|
||||
type BimiLogo struct {
|
||||
SVG []byte
|
||||
ETag string
|
||||
VmcURL string
|
||||
}
|
||||
|
||||
// Save replaces a domain's logo, returning the digest receivers can cache on.
|
||||
func (r *BimiRepository) Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error) {
|
||||
sum := sha256.Sum256(svg)
|
||||
etag := hex.EncodeToString(sum[:])
|
||||
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO domain_bimi (domain_id, svg, etag, vmc_url, updated_at)
|
||||
SELECT d.id, $2, $3, NULLIF($4, ''), NOW() FROM domains d WHERE lower(d.name) = lower($1)
|
||||
ON CONFLICT (domain_id) DO UPDATE
|
||||
SET svg = EXCLUDED.svg, etag = EXCLUDED.etag,
|
||||
vmc_url = EXCLUDED.vmc_url, updated_at = NOW()
|
||||
`, domain, svg, etag, vmcURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("save BIMI logo for %s: %w", domain, err)
|
||||
}
|
||||
return etag, nil
|
||||
}
|
||||
|
||||
// Load returns a domain's logo, or nil when it has none.
|
||||
func (r *BimiRepository) Load(ctx context.Context, domain string) (*BimiLogo, error) {
|
||||
var logo BimiLogo
|
||||
var vmc *string
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT b.svg, b.etag, b.vmc_url
|
||||
FROM domain_bimi b JOIN domains d ON d.id = b.domain_id
|
||||
WHERE lower(d.name) = lower($1)
|
||||
`, domain).Scan(&logo.SVG, &logo.ETag, &vmc)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load BIMI logo for %s: %w", domain, err)
|
||||
}
|
||||
if vmc != nil {
|
||||
logo.VmcURL = *vmc
|
||||
}
|
||||
return &logo, nil
|
||||
}
|
||||
|
||||
// Delete removes a domain's logo.
|
||||
func (r *BimiRepository) Delete(ctx context.Context, domain string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
DELETE FROM domain_bimi b USING domains d
|
||||
WHERE b.domain_id = d.id AND lower(d.name) = lower($1)
|
||||
`, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete BIMI logo for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package httppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"lambdamail/protocols/internal/domain/bimi"
|
||||
)
|
||||
|
||||
// BimiStore holds the mark a domain publishes.
|
||||
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
|
||||
}
|
||||
|
||||
// BimiLogo is a stored mark as this layer sees it.
|
||||
type BimiLogo struct {
|
||||
SVG []byte
|
||||
ETag string
|
||||
VmcURL string
|
||||
}
|
||||
|
||||
type bimiAPI struct {
|
||||
store BimiStore
|
||||
sessions *WebSessionVerifier
|
||||
// domainOf maps the host a request arrived on to the mail domain whose
|
||||
// logo should answer, so one deployment can serve several.
|
||||
defaultDomain string
|
||||
}
|
||||
|
||||
// SetBimiAPI wires the BIMI logo store.
|
||||
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain string) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
r.bimi = &bimiAPI{store: store, defaultDomain: defaultDomain}
|
||||
if sessionSecret != "" {
|
||||
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePublicLogo serves the mark itself.
|
||||
//
|
||||
// Deliberately open: this is fetched by receiving mail providers evaluating a
|
||||
// message, which hold no session here and never will. It is also the only
|
||||
// thing in this service that is meant to be read by strangers, so it answers
|
||||
// nothing but the stored bytes.
|
||||
func (r *Router) handleBimiLogo(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
|
||||
}
|
||||
|
||||
domain := r.bimi.domainFor(req)
|
||||
logo, err := r.bimi.store.Load(req.Context(), domain)
|
||||
if err != nil || logo == nil {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Header.Get("If-None-Match") == `"`+logo.ETag+`"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("ETag", `"`+logo.ETag+`"`)
|
||||
// Public and long: receivers fetch this for every message they evaluate,
|
||||
// and the mark changes about as often as a company rebrands.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// The document is served from the same origin as the console, and an SVG
|
||||
// is a document a browser will happily execute. Nothing in a validated
|
||||
// Tiny PS mark is active, but the header costs nothing and covers the case
|
||||
// where validation is one day loosened.
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
if req.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(logo.SVG)
|
||||
}
|
||||
|
||||
func (a *bimiAPI) domainFor(req *http.Request) string {
|
||||
host := extractHost(req.Host)
|
||||
// mail.example.test serves example.test's mark; anything else falls back
|
||||
// to the domain this deployment was configured with.
|
||||
if trimmed := strings.TrimPrefix(host, "mail."); trimmed != host && trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return a.defaultDomain
|
||||
}
|
||||
|
||||
// handleBimiAdmin uploads, inspects or removes a domain's mark.
|
||||
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
|
||||
if r.bimi == nil || r.bimi.sessions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
|
||||
"The BIMI panel needs JWT_SECRET and a database")
|
||||
return
|
||||
}
|
||||
|
||||
token := bearerOrCookie(req, "lm_admin_session")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
||||
return
|
||||
}
|
||||
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
||||
return
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
|
||||
if domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.bimi.get(w, req, domain)
|
||||
case http.MethodPut:
|
||||
r.bimi.put(w, req, domain)
|
||||
case http.MethodDelete:
|
||||
if err := r.bimi.store.Delete(req.Context(), domain); err != nil {
|
||||
log.Printf("bimi: could not delete the logo for %s: %v", domain, err)
|
||||
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the logo")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET, PUT or DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
|
||||
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 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
|
||||
// A shade over the specification's ceiling, so an oversized upload is
|
||||
// refused by the validator with a reason rather than truncated here.
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxLogoBytes*2))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
SVG string `json:"svg"`
|
||||
VmcURL string `json:"vmc_url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with an svg field")
|
||||
return
|
||||
}
|
||||
|
||||
// Every reason at once. Fixing one rule at a time against a receiver that
|
||||
// silently shows nothing is a miserable way to learn this format.
|
||||
if violations := bimi.Validate([]byte(payload.SVG)); 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_LOGO",
|
||||
"message": "The logo is not SVG Tiny Portable/Secure",
|
||||
"violations": reasons,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
vmc := strings.TrimSpace(payload.VmcURL)
|
||||
if vmc != "" && !strings.HasPrefix(vmc, "https://") {
|
||||
// A certificate fetched over plain HTTP is not evidence of anything,
|
||||
// and every receiver refuses it.
|
||||
writeError(w, http.StatusBadRequest, "INSECURE_VMC", "The certificate URL must be https")
|
||||
return
|
||||
}
|
||||
|
||||
etag, err := a.store.Save(req.Context(), domain, []byte(payload.SVG), vmc)
|
||||
if err != nil {
|
||||
log.Printf("bimi: could not save the logo for %s: %v", domain, err)
|
||||
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "etag": etag})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -40,7 +40,6 @@ type Router struct {
|
||||
// dnsReconciler is held here so it survives being set before dns exists.
|
||||
dnsReconciler DomainReconciler
|
||||
dnsStatus DnsStatusWriter
|
||||
bimi *bimiAPI
|
||||
// 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
|
||||
@@ -79,8 +78,6 @@ func (r *Router) registerRoutes() {
|
||||
r.mux.HandleFunc("/health", r.handleHealth)
|
||||
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("/api/v1/admin/bimi", r.handleBimiAdmin)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user