diff --git a/Cargo.lock b/Cargo.lock index 3244c30..94f9654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,7 +652,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smolboid" -version = "0.2.2" +version = "0.2.1" dependencies = [ "fluent-bundle", "fumi-core", diff --git a/README.md b/README.md index c36bf98..8880866 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ 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 @@ -163,7 +162,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 `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. +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. ## What a feed looks like diff --git a/audit.md b/audit.md index b12b58a..bf21529 100644 --- a/audit.md +++ b/audit.md @@ -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 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. +- **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. - **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. diff --git a/core/src/feed.rs b/core/src/feed.rs index 96cd2c6..571a13d 100644 --- a/core/src/feed.rs +++ b/core/src/feed.rs @@ -228,7 +228,7 @@ impl Feed { }) } - /// Appends a record and its signature line, post rule 4's append-only + /// Appends a record and its signature line, post rule 5's append-only /// half. The caller has validated the record and checked uniqueness. pub fn append(&mut self, record: Record, mail: Option) { let mail_item = mail.map(Item::Mail); diff --git a/core/src/lib.rs b/core/src/lib.rs index 320b851..b2e9928 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -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}; +pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS}; pub use time::{parse as parse_timestamp, render as render_timestamp}; pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State}; diff --git a/core/src/rules.rs b/core/src/rules.rs index b39d3f2..c37a251 100644 --- a/core/src/rules.rs +++ b/core/src/rules.rs @@ -1,8 +1,13 @@ -//! Post rules 1 and 3 from PLAN.md, enforced at receipt by the daemon and +//! Post rules 1, 3 and 4 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, @@ -11,7 +16,9 @@ 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::ForbiddenChar => "contains a control character", + RuleError::TooManyCodepoints => "more than 160 Unicode code points", + RuleError::TooManyBytes => "more than 640 bytes", + RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028", RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision", RuleError::DuplicateTimestamp => "timestamp already exists in this feed", }; @@ -21,13 +28,18 @@ impl std::fmt::Display for RuleError { impl std::error::Error for RuleError {} -/// 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. +/// 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. pub fn validate_text(text: &str) -> Result<(), RuleError> { - if text.chars().any(char::is_control) { + 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}']) { return Err(RuleError::ForbiddenChar); } Ok(()) @@ -52,14 +64,24 @@ mod tests { } #[test] - fn accepts_long_and_multiline_text() { - assert_eq!(validate_text(&"🙂".repeat(1000)), Ok(())); - assert_eq!(validate_text("first\u{2028}second"), Ok(())); + 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" + ); } #[test] - 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"] { + fn rejects_record_breaking_characters() { + for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] { assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar)); } } diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 9dbf20b..cea236c 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smolboid" -version = "0.2.2" +version = "0.2.1" edition = "2021" description = "smolboid daemon: twtxt feeds published by smolmail" license = "Apache-2.0" diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index 36f018b..e06031a 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -38,8 +38,9 @@ reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }". ## Limits on what a post can contain -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-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-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. @@ -149,7 +150,7 @@ help-body = Your feed: { $url } Send a message to this address and it becomes a post. We publish exactly - what you write, on one line. + what you write, up to 160 characters, on one line. To manage your feed, add one of these headers. One per message. diff --git a/daemon/src/config.rs b/daemon/src/config.rs index ed8e6a7..61d9488 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -24,9 +24,6 @@ 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, @@ -63,7 +60,6 @@ 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, @@ -104,7 +100,6 @@ 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)?, diff --git a/daemon/src/dispatch.rs b/daemon/src/dispatch.rs index 85cd65b..cfca5bc 100644 --- a/daemon/src/dispatch.rs +++ b/daemon/src/dispatch.rs @@ -342,6 +342,10 @@ 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) diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index 363110f..2f5f91b 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -94,7 +94,6 @@ pub struct PendingRecord { /// Failures that draw an error reply (PLAN.md, Management mail). #[derive(Debug)] pub enum ApplyError { - TooLong, OverQuota(bool), Collision(String), FeedFull, @@ -103,7 +102,7 @@ pub enum ApplyError { ReplyToChanged, } -/// Applies a published record to the feed: length, quota, uniqueness, retention +/// Applies a published record to the feed: 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( @@ -113,9 +112,6 @@ 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) @@ -933,7 +929,6 @@ 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, @@ -1073,18 +1068,6 @@ 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(); @@ -1153,7 +1136,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 { max_post_chars: 700, ..config(1300) }; + let cfg = 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(); diff --git a/daemon/src/reason.rs b/daemon/src/reason.rs index 06b7e9d..db8d69f 100644 --- a/daemon/src/reason.rs +++ b/daemon/src/reason.rs @@ -25,8 +25,6 @@ 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, @@ -69,8 +67,9 @@ 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", @@ -98,7 +97,6 @@ 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) @@ -156,8 +154,9 @@ 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 }, diff --git a/flake.nix b/flake.nix index 2ff4d6e..af8eedf 100644 --- a/flake.nix +++ b/flake.nix @@ -148,7 +148,6 @@ 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} @@ -255,12 +254,6 @@ 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; diff --git a/tests/pentest.sh b/tests/pentest.sh index 7f51bec..f24d2d5 100755 --- a/tests/pentest.sh +++ b/tests/pentest.sh @@ -218,14 +218,13 @@ 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: control-character and hostile payloads are rejected, not published" -author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null +step "mail: oversized and hostile payloads are rejected, not published" +author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @ #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" \ @@ -379,13 +378,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 "$i"))$(printf '\tz')" >/dev/null + author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null done sleep 5 author fetch >/dev/null -# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent. +# Fifteen distinct oversized payloads; 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: A post has to fit on one line" >"$RIG/replies.count" || true +author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$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"