mail_send's ask names recipients and masks addresses so the operator channel can carry it (issue 868)
messenger's content rule (ADR 0253 §3) refuses any message carrying a mail address, so mail_send's ask never reached the operator: no send could ever be approved. A recipient that is one of the operator's own mesh-mail accounts is now named by that account's name; any other loses its address and host; the subject and body's first lines have every address and link masked before they reach the ask. The mail itself is sent unchanged. Tests run messenger's real content rule against the asks mail_send builds (content.go copied verbatim as content_test.go, pinned byte-identical by copies_test.go, since messenger's package main cannot be imported from another module).
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any
|
||||
// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the
|
||||
// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in
|
||||
// a form the content rule lets through (novox/hq issue 868, "Decided" note on issue 868). These tests build
|
||||
// real asks through mail_send and judge them with the real rule, copied verbatim as content_test.go.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wholeAsk is every word the operator sees for one ask, the same text messenger's content rule judges as a
|
||||
// whole (the asks router hands it Headline, Explanation and Details together).
|
||||
func wholeAsk(r *sendRouter, id string) string {
|
||||
a := r.published[id]
|
||||
return a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
||||
}
|
||||
|
||||
// mustNotBeRefused fails the test naming what the content rule refused, and why.
|
||||
func mustNotBeRefused(t *testing.T, whole string) {
|
||||
t.Helper()
|
||||
if refusal, ok := Check(whole); !ok {
|
||||
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, whole)
|
||||
}
|
||||
}
|
||||
|
||||
// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address
|
||||
// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and
|
||||
// passes the real content rule. This is the first test issue 868's decision asked for; it fails today
|
||||
// because AskMailSend puts the raw address into the ask.
|
||||
func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"},
|
||||
"subject": "Testing issue 868", "body": "Does the ask reach the operator now?"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
if json.Unmarshal([]byte(text), &out) != nil {
|
||||
t.Fatalf("not JSON: %s", text)
|
||||
}
|
||||
id, _ := out["ask"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") {
|
||||
t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "your account mesh") {
|
||||
t.Errorf("the ask does not name the own account:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, whole)
|
||||
}
|
||||
|
||||
// (2) A recipient outside the operator's own accounts is said without its address or host: its first
|
||||
// letter, an ellipsis and the domain's labels as words.
|
||||
func TestMailSendAskForOutsideRecipientCarriesNoAddress(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendTool, "novox.triage", sendArgs) // to: ops@example.org, lead@example.org
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") {
|
||||
t.Errorf("the ask carries the recipients' address or host:\n%s", whole)
|
||||
}
|
||||
for _, want := range []string{"o… at example org", "l… at example org"} {
|
||||
if !strings.Contains(whole, want) {
|
||||
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
||||
}
|
||||
}
|
||||
mustNotBeRefused(t, whole)
|
||||
}
|
||||
|
||||
// (3) More than three recipients are counted rather than said one by one.
|
||||
func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh",
|
||||
"to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"},
|
||||
"subject": "Many recipients", "body": "body"}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") {
|
||||
t.Errorf("the ask carries a mail address among many recipients:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "and 2 more") {
|
||||
t.Errorf("the ask does not count the recipients past the first three:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, whole)
|
||||
}
|
||||
|
||||
// (4) A subject holding a mail address and a link has both masked before the ask carries it.
|
||||
func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"},
|
||||
"subject": "Contact ops@example.org or see https://example.com/issue/42",
|
||||
"body": "See https://example.com/issue/42 and write to ops@example.org for details."}
|
||||
text, isErr := rt.call(SendTool, "novox.triage", args)
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &out)
|
||||
id := out["ask"].(string)
|
||||
whole := wholeAsk(router, id)
|
||||
if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") {
|
||||
t.Errorf("the ask carries an unmasked address or link:\n%s", whole)
|
||||
}
|
||||
if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") {
|
||||
t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole)
|
||||
}
|
||||
mustNotBeRefused(t, whole)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A
|
||||
// message carrying an address, a path or anything shaped like a secret is refused here, by the holder,
|
||||
// because Telegram is not end-to-end encrypted for bots and this rule is the only thing between a
|
||||
// condition's words and someone else's server. It is not trusted to each source.
|
||||
//
|
||||
// Deliberately wider than it must be: a commit id or a long random name is refused too. A source
|
||||
// that wants its message through says it in words; a refusal is said, never silent.
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Refusal says why a text may not leave: the class of what it carried, never the text itself.
|
||||
type Refusal struct {
|
||||
Class string // address, path or secret
|
||||
What string // a few words: "an IPv4 address", "a URL", …
|
||||
}
|
||||
|
||||
func (r Refusal) String() string { return r.Class + " (" + r.What + ")" }
|
||||
|
||||
var (
|
||||
reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`)
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`)
|
||||
reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`)
|
||||
reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`)
|
||||
rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`)
|
||||
reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)
|
||||
reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`)
|
||||
reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`)
|
||||
reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`)
|
||||
reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`)
|
||||
reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
|
||||
reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`)
|
||||
)
|
||||
|
||||
// topLevel are names that end a host name — the generic and country ones a mesh's names use, and the
|
||||
// private ones (.internal, .lan, .home, .local) a mesh is likelier to.
|
||||
var topLevel = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
|
||||
site online tech page link
|
||||
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
|
||||
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
|
||||
topLevel[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check says whether a text may leave the mesh, and when not, why.
|
||||
func Check(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case reURL.MatchString(text):
|
||||
return Refusal{"address", "a URL"}, false
|
||||
case reEmail.MatchString(text):
|
||||
return Refusal{"address", "a mail address"}, false
|
||||
case reIPv4.MatchString(text):
|
||||
return Refusal{"address", "an IPv4 address"}, false
|
||||
case reMAC.MatchString(text):
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
}
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
if reWinPath.MatchString(text) {
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
if refusal, ok := randomRun(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
}) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one:
|
||||
// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a
|
||||
// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a
|
||||
// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever
|
||||
// the run holds.
|
||||
func CheckSecret(text string) (Refusal, bool) {
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
return randomRunOutsidePaths(text)
|
||||
}
|
||||
|
||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
for _, piece := range strings.Split(run, "/") {
|
||||
if len(piece) >= 20 && looksRandom(piece) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// secretShape is the secret shapes a pattern names.
|
||||
func secretShape(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
return Refusal{"secret", "a signed token"}, false
|
||||
case reBotToken.MatchString(text):
|
||||
return Refusal{"secret", "a bot token"}, false
|
||||
case reKnown.MatchString(text):
|
||||
return Refusal{"secret", "a known token shape"}, false
|
||||
case reAssigned.MatchString(text):
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
||||
func randomRun(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
|
||||
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
func isPath(w string) bool {
|
||||
if w == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(w, "/") && len(w) > 1 {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"~/", "./", "../", "$HOME", "${"} {
|
||||
if strings.HasPrefix(w, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\")
|
||||
}
|
||||
|
||||
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
|
||||
// (`machine.ace.silent`), which none of these is.
|
||||
func isHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(w, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return topLevel[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// looksRandom: letters and digits mixed, and the characters spread as a random string's are. A
|
||||
// sentence's words are separated, so only an unbroken run reaches here.
|
||||
func looksRandom(s string) bool {
|
||||
var letters, digits int
|
||||
counts := map[rune]int{}
|
||||
for _, r := range s {
|
||||
counts[r]++
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
letters++
|
||||
case unicode.IsDigit(r):
|
||||
digits++
|
||||
}
|
||||
}
|
||||
if letters == 0 || digits == 0 {
|
||||
// One class only: a word, or a number. A long number of digits alone is a count or a time.
|
||||
return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0
|
||||
}
|
||||
return entropy(counts, len(s)) >= 3.3
|
||||
}
|
||||
|
||||
func hasUpperAndLower(s string) bool {
|
||||
return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0
|
||||
}
|
||||
|
||||
func entropy(counts map[rune]int, n int) float64 {
|
||||
e := 0.0
|
||||
for _, c := range counts {
|
||||
p := float64(c) / float64(n)
|
||||
e -= p * math.Log2(p)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
// content_test.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR
|
||||
// 0253 §3), copied here verbatim so mail_send's tests can judge its own asks against the real rule rather
|
||||
// than a guess at it (novox/hq issue 868). Messenger's content rule stays messenger's: this copy is never
|
||||
// built into the mesh-mail binary (it is a _test.go file, test-only), and this test pins it to the same
|
||||
// bytes as its source, the way modules/networkmanager/cmd/uplink-tools/copies_test.go pins uplink.go.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentRuleCopyMatchesMessengers(t *testing.T) {
|
||||
src := filepath.Join("..", "..", "..", "messenger", "cmd", "messenger", "content.go")
|
||||
theirs, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err)
|
||||
}
|
||||
mine, err := os.ReadFile("content_test.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(mine, theirs) {
|
||||
t.Error("mesh-mail's copy of messenger's content.go (content_test.go) differs from " + src +
|
||||
": change this copy to match messenger's content rule, verbatim, whenever it changes")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -169,6 +170,12 @@ type SendRequest struct {
|
||||
Body string `json:"body"`
|
||||
Caller string `json:"caller"`
|
||||
At time.Time `json:"at"`
|
||||
// AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq
|
||||
// issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's
|
||||
// name, any other said without its address or host. Composed once, from the accounts configured when
|
||||
// the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide,
|
||||
// mail_send_status) as it did when the operator was first asked.
|
||||
AskTo string `json:"ask_to"`
|
||||
// Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2).
|
||||
Decided *SendDecision `json:"decided,omitempty"`
|
||||
}
|
||||
@@ -205,19 +212,24 @@ func firstChars(s string, n int) string {
|
||||
}
|
||||
|
||||
// Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first
|
||||
// lines of the body (novox/hq ADR 0361 §2).
|
||||
// lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets
|
||||
// through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and
|
||||
// body have every mail address and link or host name masked (maskText) before they reach the ask. The mail
|
||||
// itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body (novox/hq issue 868).
|
||||
func (s SendRequest) Ask() asks.Ask {
|
||||
to := strings.Join(s.To, ", ")
|
||||
to := s.AskTo
|
||||
subject := maskText(s.Subject)
|
||||
body := maskText(firstChars(s.Body, ShownBodyChars))
|
||||
headline := fmt.Sprintf("Send mail from %s?", s.Account)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = "Send mail?"
|
||||
}
|
||||
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, s.Subject)
|
||||
explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject)
|
||||
if len([]rune(explanation)) > asks.SummaryLength {
|
||||
explanation = fmt.Sprintf("From %s: %q.", s.Account, s.Subject)
|
||||
explanation = fmt.Sprintf("From %s: %q.", s.Account, subject)
|
||||
}
|
||||
details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s",
|
||||
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, s.Subject, firstChars(s.Body, ShownBodyChars))
|
||||
s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body)
|
||||
a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details,
|
||||
Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: "nothing is sent"}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
@@ -298,13 +310,16 @@ type routerRecord struct {
|
||||
// ---- asking ----------------------------------------------------------------------------------------------------
|
||||
|
||||
// SendInput is one send a caller asks for: the account is already the caller's to use (the grants check, the same
|
||||
// as mail_read's, is the tool's to make before this is called).
|
||||
// as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has
|
||||
// configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know
|
||||
// which recipients are the operator's own (novox/hq issue 868).
|
||||
type SendInput struct {
|
||||
Account Account
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
Caller string
|
||||
Account Account
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
Caller string
|
||||
Accounts []Account
|
||||
}
|
||||
|
||||
// cleanAddresses trims and drops empty entries.
|
||||
@@ -368,7 +383,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
|
||||
return refuse(fmt.Sprintf("the account %s has no outgoing server configured", in.Account.Name))
|
||||
}
|
||||
req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body,
|
||||
Caller: in.Caller, At: now.UTC()}
|
||||
Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)}
|
||||
ask := req.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return refuse(err.Error())
|
||||
@@ -402,6 +417,113 @@ func onwards(to []string) string {
|
||||
return fmt.Sprintf(" and %d more", len(to)-1)
|
||||
}
|
||||
|
||||
// ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------
|
||||
//
|
||||
// messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because
|
||||
// Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the
|
||||
// subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead:
|
||||
// a recipient of the operator's own is named by its account; any other loses its address and host; the subject
|
||||
// and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ.
|
||||
|
||||
// mostShownRecipients is how many recipients the ask names one by one before it counts the rest.
|
||||
const mostShownRecipients = 3
|
||||
|
||||
// composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your
|
||||
// account <name>"; any other is said without its address or host (its first letter, an ellipsis, and its
|
||||
// domain's labels as words); past the first few, the rest are counted rather than named.
|
||||
func composeAskTo(to []string, accounts []Account) string {
|
||||
own := map[string]string{}
|
||||
for _, a := range accounts {
|
||||
if a.Address != "" {
|
||||
own[strings.ToLower(a.Address)] = a.Name
|
||||
}
|
||||
}
|
||||
shown, more := to, 0
|
||||
if len(shown) > mostShownRecipients {
|
||||
shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, addr := range shown {
|
||||
parts = append(parts, oneAskRecipient(addr, own))
|
||||
}
|
||||
line := strings.Join(parts, ", ")
|
||||
if more > 0 {
|
||||
line += fmt.Sprintf(" and %d more", more)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// oneAskRecipient is one recipient's words in the ask: "your account <name>" for one of the accounts given, or
|
||||
// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com").
|
||||
func oneAskRecipient(addr string, own map[string]string) string {
|
||||
if name, ok := own[strings.ToLower(addr)]; ok {
|
||||
return "your account " + name
|
||||
}
|
||||
local, domain, found := strings.Cut(addr, "@")
|
||||
if !found || local == "" {
|
||||
return "a recipient"
|
||||
}
|
||||
first := string([]rune(local)[:1])
|
||||
return first + "… at " + strings.ReplaceAll(domain, ".", " ")
|
||||
}
|
||||
|
||||
// maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL),
|
||||
// so what they catch here is masked before the rule ever judges it.
|
||||
var (
|
||||
maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`)
|
||||
// maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators),
|
||||
// so a bare host name is found on the same tokens the rule itself would judge.
|
||||
maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`)
|
||||
)
|
||||
|
||||
// maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list
|
||||
// content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused.
|
||||
var maskTopLevel = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
|
||||
site online tech page link
|
||||
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
|
||||
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
|
||||
maskTopLevel[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the
|
||||
// subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves:
|
||||
// the mail is sent with what was actually written (novox/hq issue 868).
|
||||
func maskText(s string) string {
|
||||
s = maskEmail.ReplaceAllString(s, "[address]")
|
||||
s = maskURL.ReplaceAllString(s, "[link]")
|
||||
return maskWord.ReplaceAllStringFunc(s, func(word string) string {
|
||||
trimmed := strings.TrimRight(word, ".,:;!?")
|
||||
if looksLikeHostName(trimmed) {
|
||||
return "[link]" + word[len(trimmed):]
|
||||
}
|
||||
return word
|
||||
})
|
||||
}
|
||||
|
||||
// looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName
|
||||
// judges a bare host name by.
|
||||
func looksLikeHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(w, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return maskTopLevel[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// ---- deciding ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked:
|
||||
|
||||
@@ -334,7 +334,9 @@ func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first lines.
|
||||
// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first
|
||||
// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3)
|
||||
// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one).
|
||||
func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) {
|
||||
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
@@ -358,11 +360,14 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T)
|
||||
t.Errorf("the ask does not pass the asks contract: %v", err)
|
||||
}
|
||||
whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
||||
for _, want := range []string{"mesh", "ops@example.org", "lead@example.org", "The nightly gate is red", "The merge gate failed on the resolver change"} {
|
||||
for _, want := range []string{"mesh", "o… at example org", "l… at example org", "The nightly gate is red", "The merge gate failed on the resolver change"} {
|
||||
if !strings.Contains(whole, want) {
|
||||
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
||||
}
|
||||
}
|
||||
if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") {
|
||||
t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || o.Binds == "" {
|
||||
t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds)
|
||||
|
||||
@@ -471,7 +471,8 @@ func (b *Bundle) sendTool(args map[string]any) (any, error) {
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account)
|
||||
}
|
||||
return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller}, b.sends(), b.askBus(), b.now())
|
||||
return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts},
|
||||
b.sends(), b.askBus(), b.now())
|
||||
}
|
||||
|
||||
func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
|
||||
|
||||
Reference in New Issue
Block a user