feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable

This commit is contained in:
randogoth 2026-10-15 13:59:39 +03:00
parent dd6865782a
commit 801c66d3b0
12 changed files with 63 additions and 58 deletions

View file

@ -42,6 +42,7 @@ poll_secs = 60 # fetch interval
timeout = 30 # smolmail network timeout, seconds
posts_per_day = 50 # per-feed daily quota
reposts_per_day = 50
max_post_chars = 288 # longest post, in characters (code points)
cap_bytes = 1048576 # per-feed size cap, default 1 MiB
registration = silent # open | closed | silent — see Admission
registrations_per_hour = 10 # global cap on attempts that reach the network
@ -162,7 +163,7 @@ One command per mail. Two of them together, or one mixed with another, is refuse
fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body ""
```
The rules the daemon enforces: text is at most 160 Unicode code points and 640 bytes and may not contain TAB, LF, CR or U+2028; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload.
The rules the daemon enforces: text is at most `max_post_chars` characters (288 by default; twtxt itself sets no hard limit) and may not contain control characters such as TAB, LF or CR, as twtxt requires, while U+2028 is allowed as the Multiline extension's line break; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload.
## What a feed looks like

View file

@ -19,7 +19,7 @@ The reader of a feed verifies the author's own §5.3 payload signature, rebuilt
## Security properties, each with its enforcement point and proof
- **Feed-line injection is impossible.** Post and repost text is rejected on TAB, LF, CR or U+2028 (post rule 4, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes.
- **Feed-line injection is impossible.** Post and repost text is rejected on any control character, TAB, LF and CR included (post rule 3, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes.
- **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test.
The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated.

View file

@ -228,7 +228,7 @@ impl Feed {
})
}
/// Appends a record and its signature line, post rule 5's append-only
/// Appends a record and its signature line, post rule 4's append-only
/// half. The caller has validated the record and checked uniqueness.
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
let mail_item = mail.map(Item::Mail);

View file

@ -13,6 +13,6 @@ pub mod verify;
pub use envelope::{open, Opened};
pub use feed::{Entry, Feed, MailLine, Record};
pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS};
pub use rules::{validate_text, validate_timestamp, RuleError};
pub use time::{parse as parse_timestamp, render as render_timestamp};
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};

View file

@ -1,13 +1,8 @@
//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and
//! Post rules 1 and 3 from PLAN.md, enforced at receipt by the daemon and
//! usable by any author-side tool.
pub const MAX_CODEPOINTS: usize = 160;
pub const MAX_BYTES: usize = 640;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleError {
TooManyCodepoints,
TooManyBytes,
ForbiddenChar,
BadTimestamp,
DuplicateTimestamp,
@ -16,9 +11,7 @@ pub enum RuleError {
impl std::fmt::Display for RuleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
RuleError::TooManyCodepoints => "more than 160 Unicode code points",
RuleError::TooManyBytes => "more than 640 bytes",
RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028",
RuleError::ForbiddenChar => "contains a control character",
RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
};
@ -28,18 +21,13 @@ impl std::fmt::Display for RuleError {
impl std::error::Error for RuleError {}
/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR
/// or U+2028 — any of those would break the one-record-per-line invariant.
/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but
/// is kept as an explicit guard.
/// Rule 3: no control characters, as twtxt requires. TAB, LF and CR are among
/// them, so a record can never break the one-record-per-line invariant. Length
/// is an instance policy, so the daemon caps it, not this rule. U+2028
/// is not a control character and stays allowed: it is the twtxt Multiline
/// extension's line break.
pub fn validate_text(text: &str) -> Result<(), RuleError> {
if text.chars().count() > MAX_CODEPOINTS {
return Err(RuleError::TooManyCodepoints);
}
if text.len() > MAX_BYTES {
return Err(RuleError::TooManyBytes);
}
if text.contains(['\t', '\n', '\r', '\u{2028}']) {
if text.chars().any(char::is_control) {
return Err(RuleError::ForbiddenChar);
}
Ok(())
@ -64,24 +52,14 @@ mod tests {
}
#[test]
fn rejects_over_length_text() {
let chars: String = std::iter::repeat('a').take(161).collect();
assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints));
// 160 four-byte code points sit exactly on both caps.
let four_byte: String = "🙂".repeat(160);
assert_eq!(validate_text(&four_byte), Ok(()));
let over: String = "🙂".repeat(161);
assert_eq!(
validate_text(&over),
Err(RuleError::TooManyCodepoints),
"codepoints are checked before bytes"
);
fn accepts_long_and_multiline_text() {
assert_eq!(validate_text(&"🙂".repeat(1000)), Ok(()));
assert_eq!(validate_text("first\u{2028}second"), Ok(()));
}
#[test]
fn rejects_record_breaking_characters() {
for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] {
fn rejects_control_characters() {
for bad in ["a\tb", "a\nb", "a\rb", "a\u{0}b", "a\u{1b}[31mb", "a\u{85}b"] {
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
}
}

View file

@ -38,9 +38,8 @@ reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }".
## Limits on what a post can contain
reason-too-many-codepoints = A post can be up to 160 characters. Shorten it and send it again.
reason-too-many-bytes = A post can be up to 640 bytes, and accents and emoji use several each. Shorten it and send it again.
reason-forbidden-char = A post has to fit on one line. Take out the line breaks and tabs, then send it again.
reason-too-long = A post can be up to { $max } characters. Shorten it and send it again.
reason-forbidden-char = A post has to fit on one line, without tabs or other control characters. Take them out and send it again.
reason-bad-timestamp = That isn't a time we can read. Use the form 2026-10-14T19:39:39Z.
reason-duplicate-timestamp = Your feed already has a post at that time.
@ -150,7 +149,7 @@ help-body =
Your feed: { $url }
Send a message to this address and it becomes a post. We publish exactly
what you write, up to 160 characters, on one line.
what you write, on one line.
To manage your feed, add one of these headers. One per message.

View file

@ -24,6 +24,9 @@ pub struct Config {
pub data: PathBuf,
pub posts_per_day: u32,
pub reposts_per_day: u32,
/// Longest post or repost text, in Unicode code points. twtxt itself sets
/// no hard limit, so this is instance policy.
pub max_post_chars: usize,
/// Per-feed size cap in bytes (PLAN.md, Retention).
pub cap_bytes: usize,
pub poll_secs: u64,
@ -60,6 +63,7 @@ impl Default for Config {
data: PathBuf::from("data"),
posts_per_day: 50,
reposts_per_day: 50,
max_post_chars: 288,
cap_bytes: 1_048_576,
poll_secs: 60,
timeout: 30,
@ -100,6 +104,7 @@ impl Config {
"data" => cfg.data = PathBuf::from(value),
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
"reposts_per_day" => cfg.reposts_per_day = value.parse().map_err(&bad)?,
"max_post_chars" => cfg.max_post_chars = value.parse().map_err(&bad)?,
"cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?,
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,

View file

@ -342,10 +342,6 @@ mod tests {
#[test]
fn post_rules_are_enforced_with_reasons() {
use smolboid_core::rules::RuleError;
assert_eq!(
dispatch(&incoming(&"a".repeat(161))).unwrap_err(),
Reason::Rule(RuleError::TooManyCodepoints)
);
assert_eq!(
dispatch(&incoming("a\tb")).unwrap_err(),
Reason::Rule(RuleError::ForbiddenChar)

View file

@ -94,6 +94,7 @@ pub struct PendingRecord {
/// Failures that draw an error reply (PLAN.md, Management mail).
#[derive(Debug)]
pub enum ApplyError {
TooLong,
OverQuota(bool),
Collision(String),
FeedFull,
@ -102,7 +103,7 @@ pub enum ApplyError {
ReplyToChanged,
}
/// Applies a published record to the feed: quota, uniqueness, retention
/// Applies a published record to the feed: length, quota, uniqueness, retention
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
/// Returns the rendered size after appending, for the 90% warning.
pub fn apply_record(
@ -112,6 +113,9 @@ pub fn apply_record(
record: PendingRecord,
now_unix: i64,
) -> Result<(), ApplyError> {
if record.text.chars().count() > cfg.max_post_chars {
return Err(ApplyError::TooLong);
}
State::roll_counters(fs, now_unix);
let (counter, limit) = if record.repost {
(&mut fs.reposts, cfg.reposts_per_day)
@ -929,6 +933,7 @@ impl Daemon {
}
Err(e) => {
let reason = match e {
ApplyError::TooLong => Reason::TooLong { max: self.cfg.max_post_chars },
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
ApplyError::FeedFull => Reason::FeedFull,
@ -1068,6 +1073,18 @@ mod tests {
assert_eq!(feed.entries().len(), 1);
}
#[test]
fn length_cap_is_configurable() {
let mut feed = smolboid_core::Feed::new();
let mut fs = feed_state();
let cfg = Config { max_post_chars: 5, ..config(1 << 20) };
let five = record("2026-10-01T12:47:00Z", "🙂🙂🙂🙂🙂");
apply_record(&mut feed, &mut fs, &cfg, five, 0).unwrap();
let six = record("2026-10-01T12:47:01Z", "abcdef");
assert!(matches!(apply_record(&mut feed, &mut fs, &cfg, six, 0), Err(ApplyError::TooLong)));
assert_eq!(fs.posts, 1, "a refused post spends no quota");
}
#[test]
fn reposts_publish_as_repost_records() {
let mut feed = smolboid_core::Feed::new();
@ -1136,7 +1153,7 @@ mod tests {
fn warning_flag_flips_at_ninety_percent() {
let mut feed = smolboid_core::Feed::new();
let mut fs = feed_state();
let cfg = config(1300);
let cfg = Config { max_post_chars: 700, ..config(1300) };
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
assert!(!fs.warned);
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();

View file

@ -25,6 +25,8 @@ pub enum Reason {
/// maps it to copy and the library keeps its own `Display` for
/// author-side tools.
Rule(RuleError),
/// Longer than the instance's `max_post_chars`.
TooLong { max: usize },
OverQuota { repost: bool },
Collision { timestamp: String },
FeedFull,
@ -67,9 +69,8 @@ impl Reason {
Reason::NickInvalid => "reason-nick-invalid",
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown",
Reason::Rule(RuleError::TooManyCodepoints) => "reason-too-many-codepoints",
Reason::Rule(RuleError::TooManyBytes) => "reason-too-many-bytes",
Reason::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char",
Reason::TooLong { .. } => "reason-too-long",
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
Reason::OverQuota { repost: false } => "reason-over-quota-post",
@ -97,6 +98,7 @@ impl Reason {
args.set("timestamp", timestamp.clone())
}
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
Reason::TooLong { max } => args.set("max", max.to_string()),
_ => return None,
}
Some(args)
@ -154,9 +156,8 @@ mod tests {
Reason::NickInvalid,
Reason::DeleteNeedsTimestamp,
Reason::PruneModeUnknown { got: "sideways".into() },
Reason::Rule(RuleError::TooManyCodepoints),
Reason::Rule(RuleError::TooManyBytes),
Reason::Rule(RuleError::ForbiddenChar),
Reason::TooLong { max: 288 },
Reason::Rule(RuleError::BadTimestamp),
Reason::Rule(RuleError::DuplicateTimestamp),
Reason::OverQuota { repost: false },

View file

@ -148,6 +148,7 @@
data = ${cfg.dataDir}
posts_per_day = ${toString cfg.postsPerDay}
reposts_per_day = ${toString cfg.repostsPerDay}
max_post_chars = ${toString cfg.maxPostChars}
cap_bytes = ${toString cfg.capBytes}
poll_secs = ${toString cfg.pollSecs}
timeout = ${toString cfg.timeout}
@ -254,6 +255,12 @@
description = "Per-feed daily repost quota.";
};
maxPostChars = mkOption {
type = types.ints.positive;
default = 288;
description = "Longest post or repost text, in Unicode code points.";
};
capBytes = mkOption {
type = types.ints.positive;
default = 1048576;

View file

@ -218,13 +218,14 @@ sleep 4
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
step "mail: oversized and hostile payloads are rejected, not published"
author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null
step "mail: control-character and hostile payloads are rejected, not published"
author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
sleep 4
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
# what must never happen is a line that IS a smol record.
[[ "$(grep -c "escape" "$FEED")" == 0 ]] || fail "a post with a control character published"
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
@ -378,13 +379,13 @@ fi
step "mail: reply-flood is capped at 10 per hour"
for i in $(seq 1 15); do
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 "$i"))$(printf '\tz')" >/dev/null
done
sleep 5
author fetch >/dev/null
# Fifteen distinct oversized payloads; at most ten answers, the rest silent.
# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent.
# (Earlier probes may have drawn their own replies for other reasons.)
author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$RIG/replies.count" || true
author list --requests 2>/dev/null | grep -c "twtxt: A post has to fit on one line" >"$RIG/replies.count" || true
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
echo "flood answers: $REPLIES of 15"
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"