test: cover self-service registration, and fix what it found

Rig legs for the whole author path with no CLI admit anywhere: an unknown
sender drawing the info mail, Twt-Register creating the feed, the token
arriving so she can publish, Twt-Help, the admin notification, Twt-Block
stopping a feed and Twt-Registration closing the door. Pentest probes for the
attacks: no Reply-To, a claim on someone else's address, a claim on an address
nobody holds, an admin command from a non-admin, and a blocked author coming
back.

Two real bugs, both found by asserting on what the author receives rather than
on what the daemon meant to send.

Five catalogue entries were wrapped across lines, and a reason is interpolated
into the reply's Subject. A frontmatter value carrying a line break makes the
whole block malformed, so smolmail fails closed toward display (SPEC.md sec
5.5) and the reply arrives with no subject at all — every rejection, silently.
The same bug class the parent commit fixed sender-side; step 2 reintroduced it
from the catalogue, because the Rust prose it replaced used backslash
continuations that join without a newline. Reasons are single-line now, with a
test over the enum that fails if one ever wraps again.

And the flow had a step that could not work. Telling an author to accept this
mailbox makes their client attach an Accept token to every later mail, and a
published record is verified by rebuilding the exact bytes that were signed —
which cannot include a token without publishing it. So every post from an
author who followed that advice was refused forever. The info mail now says
not to accept the address and where to find replies instead, and that refusal
has its own reason, since "resend the bare text" is useless advice when the
client adds the field by itself.

Three of my own probes were wrong too, kept here as comments so they are not
rewritten the same way: grep -q under `set -o pipefail` SIGPIPEs its producer
and can report failure for a subject that is plainly present; the refusal
reply lands in the main tier precisely because accepting the mailbox is what
moves replies there; and blocking an address that was never accepted is a
store no-op, so the probe has to register first.
This commit is contained in:
randogoth 2026-10-14 20:51:21 +03:00
parent f1084c1056
commit 1171d2157d
6 changed files with 334 additions and 33 deletions

View file

@ -40,13 +40,18 @@ welcome-body =
## reply subject and body above. Each is a sentence fragment completing
## "your mail could not be applied because …", so they start lowercase and
## carry the fix where there is one.
##
## Each MUST be a single line, however long. These are interpolated into the
## reply's Subject, and a frontmatter value carrying a line break makes the
## whole block malformed — smolmail then fails closed toward display (SPEC.md
## sec 5.5) and the reply arrives with no subject at all. Fluent multiline
## values keep their newlines, so there is no safe way to wrap them here.
## `reason::tests::every_reason_is_a_single_line` enforces this.
reason-body-not-utf8 = body is not UTF-8
reason-post-not-canonical = post mail is not in canonical form
reason-repost-not-canonical = repost mail is not in canonical form
reason-fields-beyond-reply-to =
post mail carries fields beyond Reply-To (Subject, headers or an Accept
token); resend the bare text
reason-fields-beyond-reply-to = post mail carries fields beyond Reply-To (Subject or headers); resend the bare text
reason-nick-invalid = nick must be 1-30 bytes of a-z, 0-9 or hyphen
reason-delete-needs-timestamp = Twt-Delete needs an RFC 3339 UTC timestamp
reason-prune-mode-unknown = unknown prune mode `{ $got }`, want fifo or off
@ -64,22 +69,15 @@ reason-duplicate-timestamp = timestamp already exists in this feed
reason-over-quota-post = daily post quota exceeded; try tomorrow
reason-over-quota-repost = daily repost quota exceeded; try tomorrow
reason-collision = timestamp { $timestamp } already exists; wait a second and resend
reason-feed-full =
feed is at its size cap; delete records with Twt-Delete or choose
Twt-Prune: fifo
reason-reply-to-changed =
your Reply-To does not match the feed's bound author { $author }; send from
that address or mail the operator
reason-feed-full = feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo
reason-reply-to-changed = your Reply-To does not match the feed's bound author { $author }; send from that address or mail the operator
reason-no-record-at = no record at { $timestamp }
## Mailbox requests — register, help and the admin commands
reason-one-command-per-mail =
one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or
Twt-Registration on its own, and feed commands in a separate mail
reason-one-command-per-mail = one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or Twt-Registration on its own, and feed commands in a separate mail
reason-bad-address = `{ $got }` is not a smolmail address
reason-registration-mode-unknown =
unknown registration mode `{ $got }`, want open, closed or silent
reason-registration-mode-unknown = unknown registration mode `{ $got }`, want open, closed or silent
## First contact — an unknown sender who may register
@ -87,18 +85,21 @@ info-subject = twtxt: how to get a feed
info-body =
You can publish a twtxt feed here by sending mail.
Two steps. First accept this address in your client, so our replies reach
your inbox instead of your requests tier:
fumi accept { $mailbox }
Then ask for a feed, optionally naming the nick you want:
Ask for one, optionally naming the nick you want:
fumi send { $mailbox } --header "Twt-Register: yournick"
We will reply with your feed's address and the commands for managing it.
Nothing is published until you ask for a feed, and this mail is the only
one you get until you do.
Nothing is published until you ask, and this mail is the only one you get
until you do.
Our replies arrive in your requests tier, so look for them there:
fumi fetch && fumi list --requests
Do not accept this address in your client. If you do, it will attach an
accept token to everything you send, and a published record cannot carry
one and still be verifiable — so your posts would be refused.
## First contact while registration is closed
@ -152,3 +153,5 @@ admin-ack-body =
{ $what }
Sent because your address is listed as an admin of this instance.
reason-accept-field-on-post = your client attached an accept token because this address is in your contacts; a published record is verified by rebuilding the exact bytes you signed and cannot include a token, so withdraw the acceptance and resend

View file

@ -241,6 +241,12 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, Reason> {
&& !fields.contains_key(F_PRUNE)
&& !fields.contains_key(F_REPOST)
{
// An Accept token is the one extra field a client adds by itself, so
// it gets its own answer: the author did nothing wrong and "resend the
// bare text" would not help them.
if beyond_reply_to == ["accept"] {
return Err(Reason::AcceptFieldOnPost);
}
return Err(Reason::FieldsBeyondReplyTo);
}
@ -350,14 +356,25 @@ mod tests {
#[test]
fn extra_fields_make_a_rejected_post() {
// A Subject, or the Accept token of an accepted recipient.
// A Subject the author typed: generic answer, resend the bare text.
assert_eq!(
dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n"))
.unwrap_err(),
Reason::FieldsBeyondReplyTo
);
// An Accept token, which the author's *client* attached because this
// mailbox is in their contacts. Same refusal, different reason: a
// record carrying a token could never be verified, and telling them to
// resend the bare text would not help because the client would attach
// it again.
for body in [
"---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n",
"---\nAccept: token\n---\nbody\n",
"---\nReply-To: smol://a/b\nAccept: token\n---\nbody\n",
] {
assert_eq!(
dispatch(&incoming(body)).unwrap_err(),
Reason::FieldsBeyondReplyTo,
Reason::AcceptFieldOnPost,
"{body:?}"
);
}

View file

@ -108,6 +108,7 @@ mod tests {
"admin-notify-body",
"admin-ack-subject",
"admin-ack-body",
"reason-accept-field-on-post",
];
#[test]

View file

@ -36,6 +36,13 @@ pub enum Reason {
OneCommandPerMail,
BadAddress { got: String },
RegistrationModeUnknown { got: String },
/// The post carried an `Accept:` token, which its client attached because
/// the author accepted this mailbox. A published record is verified by
/// rebuilding the exact body that was signed, and that body cannot be
/// rebuilt without the token — which must not be published. So this is
/// not a mistake the author made, it is one their client made for them,
/// and the fix is to stop accepting this address.
AcceptFieldOnPost,
}
impl From<RuleError> for Reason {
@ -70,6 +77,7 @@ impl Reason {
Reason::OneCommandPerMail => "reason-one-command-per-mail",
Reason::BadAddress { .. } => "reason-bad-address",
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
Reason::AcceptFieldOnPost => "reason-accept-field-on-post",
}
}
@ -103,9 +111,8 @@ mod tests {
/// Every variant must resolve to real copy. `i18n`'s own test covers the
/// ids it knows about; this one walks the enum, so a variant added without
/// a catalogue entry fails here.
#[test]
fn every_variant_resolves_to_copy() {
let all = [
fn all_reasons() -> Vec<Reason> {
vec![
Reason::BodyNotUtf8,
Reason::PostNotCanonical,
Reason::RepostNotCanonical,
@ -127,8 +134,15 @@ mod tests {
Reason::OneCommandPerMail,
Reason::BadAddress { got: "not an address".into() },
Reason::RegistrationModeUnknown { got: "sideways".into() },
];
for reason in all {
Reason::AcceptFieldOnPost,
]
}
/// Every variant must resolve to real copy. Walking the enum cannot drift
/// the way a hand-kept id list can.
#[test]
fn every_variant_resolves_to_copy() {
for reason in all_reasons() {
let text = reason.text();
assert!(
!text.starts_with("???"),
@ -140,6 +154,24 @@ mod tests {
}
}
/// A reason is interpolated into the reply's Subject, and a frontmatter
/// value containing a line break makes the entire block malformed — the
/// recipient then sees no subject at all, because smolmail fails closed
/// toward display. Caught in the rig once already: a wrapped catalogue
/// entry silently stripped the subject from every refusal.
#[test]
fn every_reason_is_a_single_line() {
for reason in all_reasons() {
let text = reason.text();
assert!(
!text.contains('\n') && !text.contains('\r'),
"`{}` spans lines, which would strip the reply's subject:\n{text}",
reason.id()
);
assert!(!text.is_empty(), "{reason:?} resolves to empty copy");
}
}
#[test]
fn interpolated_reasons_carry_their_value() {
assert!(Reason::PruneModeUnknown { got: "sideways".into() }