feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable
This commit is contained in:
parent
dd6865782a
commit
801c66d3b0
12 changed files with 63 additions and 58 deletions
|
|
@ -42,6 +42,7 @@ poll_secs = 60 # fetch interval
|
||||||
timeout = 30 # smolmail network timeout, seconds
|
timeout = 30 # smolmail network timeout, seconds
|
||||||
posts_per_day = 50 # per-feed daily quota
|
posts_per_day = 50 # per-feed daily quota
|
||||||
reposts_per_day = 50
|
reposts_per_day = 50
|
||||||
|
max_post_chars = 288 # longest post, in characters (code points)
|
||||||
cap_bytes = 1048576 # per-feed size cap, default 1 MiB
|
cap_bytes = 1048576 # per-feed size cap, default 1 MiB
|
||||||
registration = silent # open | closed | silent — see Admission
|
registration = silent # open | closed | silent — see Admission
|
||||||
registrations_per_hour = 10 # global cap on attempts that reach the network
|
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 ""
|
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
|
## What a feed looks like
|
||||||
|
|
||||||
|
|
|
||||||
2
audit.md
2
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
|
## 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 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// half. The caller has validated the record and checked uniqueness.
|
||||||
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
|
pub fn append(&mut self, record: Record, mail: Option<MailLine>) {
|
||||||
let mail_item = mail.map(Item::Mail);
|
let mail_item = mail.map(Item::Mail);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,6 @@ pub mod verify;
|
||||||
|
|
||||||
pub use envelope::{open, Opened};
|
pub use envelope::{open, Opened};
|
||||||
pub use feed::{Entry, Feed, MailLine, Record};
|
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 time::{parse as parse_timestamp, render as render_timestamp};
|
||||||
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};
|
pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State};
|
||||||
|
|
|
||||||
|
|
@ -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.
|
//! usable by any author-side tool.
|
||||||
|
|
||||||
pub const MAX_CODEPOINTS: usize = 160;
|
|
||||||
pub const MAX_BYTES: usize = 640;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RuleError {
|
pub enum RuleError {
|
||||||
TooManyCodepoints,
|
|
||||||
TooManyBytes,
|
|
||||||
ForbiddenChar,
|
ForbiddenChar,
|
||||||
BadTimestamp,
|
BadTimestamp,
|
||||||
DuplicateTimestamp,
|
DuplicateTimestamp,
|
||||||
|
|
@ -16,9 +11,7 @@ pub enum RuleError {
|
||||||
impl std::fmt::Display for RuleError {
|
impl std::fmt::Display for RuleError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
let msg = match self {
|
let msg = match self {
|
||||||
RuleError::TooManyCodepoints => "more than 160 Unicode code points",
|
RuleError::ForbiddenChar => "contains a control character",
|
||||||
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::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision",
|
||||||
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
RuleError::DuplicateTimestamp => "timestamp already exists in this feed",
|
||||||
};
|
};
|
||||||
|
|
@ -28,18 +21,13 @@ impl std::fmt::Display for RuleError {
|
||||||
|
|
||||||
impl std::error::Error 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
|
/// Rule 3: no control characters, as twtxt requires. TAB, LF and CR are among
|
||||||
/// or U+2028 — any of those would break the one-record-per-line invariant.
|
/// them, so a record can never break the one-record-per-line invariant. Length
|
||||||
/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but
|
/// is an instance policy, so the daemon caps it, not this rule. U+2028
|
||||||
/// is kept as an explicit guard.
|
/// 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> {
|
pub fn validate_text(text: &str) -> Result<(), RuleError> {
|
||||||
if text.chars().count() > MAX_CODEPOINTS {
|
if text.chars().any(char::is_control) {
|
||||||
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);
|
return Err(RuleError::ForbiddenChar);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -64,24 +52,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_over_length_text() {
|
fn accepts_long_and_multiline_text() {
|
||||||
let chars: String = std::iter::repeat('a').take(161).collect();
|
assert_eq!(validate_text(&"🙂".repeat(1000)), Ok(()));
|
||||||
assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints));
|
assert_eq!(validate_text("first\u{2028}second"), Ok(()));
|
||||||
|
|
||||||
// 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]
|
#[test]
|
||||||
fn rejects_record_breaking_characters() {
|
fn rejects_control_characters() {
|
||||||
for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] {
|
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));
|
assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,8 @@ reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }".
|
||||||
|
|
||||||
## Limits on what a post can contain
|
## 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-long = A post can be up to { $max } 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, without tabs or other control characters. Take them out 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-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.
|
reason-duplicate-timestamp = Your feed already has a post at that time.
|
||||||
|
|
||||||
|
|
@ -150,7 +149,7 @@ help-body =
|
||||||
Your feed: { $url }
|
Your feed: { $url }
|
||||||
|
|
||||||
Send a message to this address and it becomes a post. We publish exactly
|
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.
|
To manage your feed, add one of these headers. One per message.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ pub struct Config {
|
||||||
pub data: PathBuf,
|
pub data: PathBuf,
|
||||||
pub posts_per_day: u32,
|
pub posts_per_day: u32,
|
||||||
pub reposts_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).
|
/// Per-feed size cap in bytes (PLAN.md, Retention).
|
||||||
pub cap_bytes: usize,
|
pub cap_bytes: usize,
|
||||||
pub poll_secs: u64,
|
pub poll_secs: u64,
|
||||||
|
|
@ -60,6 +63,7 @@ impl Default for Config {
|
||||||
data: PathBuf::from("data"),
|
data: PathBuf::from("data"),
|
||||||
posts_per_day: 50,
|
posts_per_day: 50,
|
||||||
reposts_per_day: 50,
|
reposts_per_day: 50,
|
||||||
|
max_post_chars: 288,
|
||||||
cap_bytes: 1_048_576,
|
cap_bytes: 1_048_576,
|
||||||
poll_secs: 60,
|
poll_secs: 60,
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
|
|
@ -100,6 +104,7 @@ impl Config {
|
||||||
"data" => cfg.data = PathBuf::from(value),
|
"data" => cfg.data = PathBuf::from(value),
|
||||||
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
|
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
|
||||||
"reposts_per_day" => cfg.reposts_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)?,
|
"cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?,
|
||||||
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
|
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
|
||||||
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,
|
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,
|
||||||
|
|
|
||||||
|
|
@ -342,10 +342,6 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn post_rules_are_enforced_with_reasons() {
|
fn post_rules_are_enforced_with_reasons() {
|
||||||
use smolboid_core::rules::RuleError;
|
use smolboid_core::rules::RuleError;
|
||||||
assert_eq!(
|
|
||||||
dispatch(&incoming(&"a".repeat(161))).unwrap_err(),
|
|
||||||
Reason::Rule(RuleError::TooManyCodepoints)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dispatch(&incoming("a\tb")).unwrap_err(),
|
dispatch(&incoming("a\tb")).unwrap_err(),
|
||||||
Reason::Rule(RuleError::ForbiddenChar)
|
Reason::Rule(RuleError::ForbiddenChar)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ pub struct PendingRecord {
|
||||||
/// Failures that draw an error reply (PLAN.md, Management mail).
|
/// Failures that draw an error reply (PLAN.md, Management mail).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ApplyError {
|
pub enum ApplyError {
|
||||||
|
TooLong,
|
||||||
OverQuota(bool),
|
OverQuota(bool),
|
||||||
Collision(String),
|
Collision(String),
|
||||||
FeedFull,
|
FeedFull,
|
||||||
|
|
@ -102,7 +103,7 @@ pub enum ApplyError {
|
||||||
ReplyToChanged,
|
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.
|
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
|
||||||
/// Returns the rendered size after appending, for the 90% warning.
|
/// Returns the rendered size after appending, for the 90% warning.
|
||||||
pub fn apply_record(
|
pub fn apply_record(
|
||||||
|
|
@ -112,6 +113,9 @@ pub fn apply_record(
|
||||||
record: PendingRecord,
|
record: PendingRecord,
|
||||||
now_unix: i64,
|
now_unix: i64,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
|
if record.text.chars().count() > cfg.max_post_chars {
|
||||||
|
return Err(ApplyError::TooLong);
|
||||||
|
}
|
||||||
State::roll_counters(fs, now_unix);
|
State::roll_counters(fs, now_unix);
|
||||||
let (counter, limit) = if record.repost {
|
let (counter, limit) = if record.repost {
|
||||||
(&mut fs.reposts, cfg.reposts_per_day)
|
(&mut fs.reposts, cfg.reposts_per_day)
|
||||||
|
|
@ -929,6 +933,7 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let reason = match e {
|
let reason = match e {
|
||||||
|
ApplyError::TooLong => Reason::TooLong { max: self.cfg.max_post_chars },
|
||||||
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
|
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
|
||||||
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
|
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
|
||||||
ApplyError::FeedFull => Reason::FeedFull,
|
ApplyError::FeedFull => Reason::FeedFull,
|
||||||
|
|
@ -1068,6 +1073,18 @@ mod tests {
|
||||||
assert_eq!(feed.entries().len(), 1);
|
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]
|
#[test]
|
||||||
fn reposts_publish_as_repost_records() {
|
fn reposts_publish_as_repost_records() {
|
||||||
let mut feed = smolboid_core::Feed::new();
|
let mut feed = smolboid_core::Feed::new();
|
||||||
|
|
@ -1136,7 +1153,7 @@ mod tests {
|
||||||
fn warning_flag_flips_at_ninety_percent() {
|
fn warning_flag_flips_at_ninety_percent() {
|
||||||
let mut feed = smolboid_core::Feed::new();
|
let mut feed = smolboid_core::Feed::new();
|
||||||
let mut fs = feed_state();
|
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();
|
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
|
||||||
assert!(!fs.warned);
|
assert!(!fs.warned);
|
||||||
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ pub enum Reason {
|
||||||
/// maps it to copy and the library keeps its own `Display` for
|
/// maps it to copy and the library keeps its own `Display` for
|
||||||
/// author-side tools.
|
/// author-side tools.
|
||||||
Rule(RuleError),
|
Rule(RuleError),
|
||||||
|
/// Longer than the instance's `max_post_chars`.
|
||||||
|
TooLong { max: usize },
|
||||||
OverQuota { repost: bool },
|
OverQuota { repost: bool },
|
||||||
Collision { timestamp: String },
|
Collision { timestamp: String },
|
||||||
FeedFull,
|
FeedFull,
|
||||||
|
|
@ -67,9 +69,8 @@ impl Reason {
|
||||||
Reason::NickInvalid => "reason-nick-invalid",
|
Reason::NickInvalid => "reason-nick-invalid",
|
||||||
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
|
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
|
||||||
Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown",
|
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::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char",
|
||||||
|
Reason::TooLong { .. } => "reason-too-long",
|
||||||
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
|
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
|
||||||
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
|
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
|
||||||
Reason::OverQuota { repost: false } => "reason-over-quota-post",
|
Reason::OverQuota { repost: false } => "reason-over-quota-post",
|
||||||
|
|
@ -97,6 +98,7 @@ impl Reason {
|
||||||
args.set("timestamp", timestamp.clone())
|
args.set("timestamp", timestamp.clone())
|
||||||
}
|
}
|
||||||
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
|
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
|
||||||
|
Reason::TooLong { max } => args.set("max", max.to_string()),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
Some(args)
|
Some(args)
|
||||||
|
|
@ -154,9 +156,8 @@ mod tests {
|
||||||
Reason::NickInvalid,
|
Reason::NickInvalid,
|
||||||
Reason::DeleteNeedsTimestamp,
|
Reason::DeleteNeedsTimestamp,
|
||||||
Reason::PruneModeUnknown { got: "sideways".into() },
|
Reason::PruneModeUnknown { got: "sideways".into() },
|
||||||
Reason::Rule(RuleError::TooManyCodepoints),
|
|
||||||
Reason::Rule(RuleError::TooManyBytes),
|
|
||||||
Reason::Rule(RuleError::ForbiddenChar),
|
Reason::Rule(RuleError::ForbiddenChar),
|
||||||
|
Reason::TooLong { max: 288 },
|
||||||
Reason::Rule(RuleError::BadTimestamp),
|
Reason::Rule(RuleError::BadTimestamp),
|
||||||
Reason::Rule(RuleError::DuplicateTimestamp),
|
Reason::Rule(RuleError::DuplicateTimestamp),
|
||||||
Reason::OverQuota { repost: false },
|
Reason::OverQuota { repost: false },
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@
|
||||||
data = ${cfg.dataDir}
|
data = ${cfg.dataDir}
|
||||||
posts_per_day = ${toString cfg.postsPerDay}
|
posts_per_day = ${toString cfg.postsPerDay}
|
||||||
reposts_per_day = ${toString cfg.repostsPerDay}
|
reposts_per_day = ${toString cfg.repostsPerDay}
|
||||||
|
max_post_chars = ${toString cfg.maxPostChars}
|
||||||
cap_bytes = ${toString cfg.capBytes}
|
cap_bytes = ${toString cfg.capBytes}
|
||||||
poll_secs = ${toString cfg.pollSecs}
|
poll_secs = ${toString cfg.pollSecs}
|
||||||
timeout = ${toString cfg.timeout}
|
timeout = ${toString cfg.timeout}
|
||||||
|
|
@ -254,6 +255,12 @@
|
||||||
description = "Per-feed daily repost quota.";
|
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 {
|
capBytes = mkOption {
|
||||||
type = types.ints.positive;
|
type = types.ints.positive;
|
||||||
default = 1048576;
|
default = 1048576;
|
||||||
|
|
|
||||||
|
|
@ -218,13 +218,14 @@ sleep 4
|
||||||
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
[[ "$(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"
|
[[ "$(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"
|
step "mail: control-character 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 "$(printf 'escape\033[31mred')" >/dev/null
|
||||||
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/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
|
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
||||||
sleep 4
|
sleep 4
|
||||||
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
||||||
# what must never happen is a line that IS a smol record.
|
# 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: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"
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
||||||
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
||||||
|
|
@ -378,13 +379,13 @@ fi
|
||||||
|
|
||||||
step "mail: reply-flood is capped at 10 per hour"
|
step "mail: reply-flood is capped at 10 per hour"
|
||||||
for i in $(seq 1 15); do
|
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
|
done
|
||||||
sleep 5
|
sleep 5
|
||||||
author fetch >/dev/null
|
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.)
|
# (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)
|
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
||||||
echo "flood answers: $REPLIES of 15"
|
echo "flood answers: $REPLIES of 15"
|
||||||
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue