From 8807180993e32255c3c25f874abec5014257f784 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 29 Oct 2026 18:20:09 +0800 Subject: [PATCH] Add-to: only a participant of the stored original may add recipients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleAddToPath checked a stored parent for terminal and time travel but not who was adding. readAddToRecipients only checks add-to-from against the incoming header's own from and to, which the sender writes. So a host on any domain that knew a message's hash (e.g. psha256 on a later reply) could name itself in from and add-to-from and have the stored original, body and attachments, attached to an address it chose (code 65). Now add-to-from must be in the stored parent's from or to, else code 1. A recipient added by an earlier batch is not an original participant (SPEC §12). SPEC.md step 7 says so explicitly. Co-Authored-By: Claude Opus 5.5 --- SPEC.md | 2 +- cmd/fmsgd/host.go | 35 +++++++++++++++++++++++++++++++++ cmd/fmsgd/terminal_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index d35fcb9..b262fdc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -225,7 +225,7 @@ When _has add to_ IS set: perform the steps below for each unique participant do - **add-to set** (adding recipients): - pid MUST also be set. Fail → respond code 1, close. - Check if parent stored (§11): - - **Stored**: parent must not be terminal (code 1 if fail); check time travel (code 9 if fail). + - **Stored**: parent must not be terminal (code 1 if fail); check time travel (code 9 if fail); _add to from_ must be in the stored parent's _from_ or _to_ (code 1 if fail). The incoming header's _from_ and _to_ are not evidence of this: the sender wrote them. - **Not stored**: if ≥ 1 recipient in _to_ or _add to_ belongs to Host B's domain, treat as full message delivery. Otherwise (Host B hosts only non-recipient participants) respond code 6 (parent not found), close. 8. Optionally issue a CHALLENGE on Connection 2 (see §10.5). diff --git a/cmd/fmsgd/host.go b/cmd/fmsgd/host.go index 53e8635..6fea271 100644 --- a/cmd/fmsgd/host.go +++ b/cmd/fmsgd/host.go @@ -365,6 +365,26 @@ func isMessageRetrievable(msg *FMsgHeader) bool { return isMessageRetrievable(parentMsg) } +// isOriginalParticipant reports whether addr is in the stored message's own +// from or to. Only these may add recipients to it (SPEC §12): a recipient added +// by an earlier batch is a participant of that batch, not of the original, and +// a loaded message's AddToFrom/AddTo aren't its own fields anyway. +func isOriginalParticipant(msg *FMsgHeader, addr *FMsgAddress) bool { + if msg == nil || addr == nil { + return false + } + target := strings.ToLower(addr.ToString()) + if strings.ToLower(msg.From.ToString()) == target { + return true + } + for i := range msg.To { + if strings.ToLower(msg.To[i].ToString()) == target { + return true + } + } + return false +} + func isParentParticipant(parent *FMsgHeader, addr *FMsgAddress) bool { if parent == nil || addr == nil { return false @@ -628,6 +648,21 @@ func handleAddToPath(c net.Conn, h *FMsgHeader) (*FMsgHeader, error) { return h, fmt.Errorf("add-to: time travel detected (parent time %f, current %f)", parentMsg.Timestamp, h.Timestamp) } + // Only a participant of the stored original may add recipients to it + // (SPEC §10.3 step 7, §12). The header's own from and to can't be + // trusted for this: an outsider could list themselves in both, have the + // original (which they never had) attached to an address they choose. + if !isOriginalParticipant(parentMsg, h.AddToFrom) { + if err := sendCode(c, RejectCodeInvalid); err != nil { + return h, err + } + adder := "(none)" + if h.AddToFrom != nil { + adder = h.AddToFrom.ToString() + } + return h, fmt.Errorf("add-to: add-to-from %s is not in parent msg %d's from or to", adder, parentID) + } + // Batch identity is the batch message hash — this add-to header combined // with the stored parent's payload (SPEC §11) — so map the parent's data // in and compute it before the duplicate check. diff --git a/cmd/fmsgd/terminal_test.go b/cmd/fmsgd/terminal_test.go index 415d2c7..07017aa 100644 --- a/cmd/fmsgd/terminal_test.go +++ b/cmd/fmsgd/terminal_test.go @@ -138,6 +138,46 @@ func TestHandleAddToPathRejectsTerminalParent(t *testing.T) { } } +// Only the stored original's from or to may add recipients: an outsider +// naming themselves in the add-to header's from and to is refused, and so is +// someone who was only added by an earlier batch. +func TestHandleAddToPathRejectsNonParticipant(t *testing.T) { + mallory := FMsgAddress{User: "mallory", Domain: "evil.example"} + parent := storedParentForTest(t, 0) + parent.AddToFrom = &testAlice // a loaded parent carries no batch, but don't rely on it + parent.AddTo = []FMsgAddress{testCarol} + for name, adder := range map[string]FMsgAddress{"outsider": mallory, "added later": testCarol} { + t.Run(name, func(t *testing.T) { + stubStoredParent(t, parent) + h := addToForTest() + adder := adder + h.From, h.To, h.AddToFrom = adder, []FMsgAddress{testBob}, &adder + c := &testConn{} + if _, err := handleAddToPath(c, h); err == nil { + t.Fatal("expected add-to by a non-participant to be refused") + } + if got := c.Bytes(); len(got) != 1 || got[0] != RejectCodeInvalid { + t.Fatalf("wrote %v, want single code %d (invalid)", got, RejectCodeInvalid) + } + }) + } +} + +// A recipient of the original (in to) may add recipients, as may its sender. +func TestHandleAddToPathAcceptsRecipientAsAdder(t *testing.T) { + stubStoredParent(t, storedParentForTest(t, 0)) + h := addToForTest() + bob := testBob + h.AddToFrom = &bob + c := &testConn{} + if _, err := handleAddToPath(c, h); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.Len() != 0 { + t.Fatalf("unexpected bytes written: %v", c.Bytes()) + } +} + func TestHandleAddToPathAcceptsNonTerminalParent(t *testing.T) { stubStoredParent(t, storedParentForTest(t, 0)) c := &testConn{}