Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,15 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri
matches
}

fn validate_message_content(content: &str) -> Result<(), CliError> {
if let Some(byte_offset) = content.find('\0') {
return Err(CliError::Usage(format!(
"content contains NUL byte at byte offset {byte_offset}"
)));
}
Ok(())
}

pub struct SendMessageParams {
pub channel_id: String,
pub content: String,
Expand All @@ -580,6 +589,7 @@ pub async fn cmd_send_message(
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_message_content(&p.content)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
Expand Down Expand Up @@ -993,9 +1003,9 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::{
event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions,
missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys,
cmd_send_message, event_mention_pubkeys, find_root_from_tags, match_profiles_by_name,
merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys,
resolve_names_to_pubkeys, SendMessageParams,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
Expand All @@ -1006,6 +1016,37 @@ mod tests {
const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";

#[tokio::test]
async fn send_message_rejects_nul_locally_with_byte_offset() {
let client = crate::client::BuzzClient::new(
"http://127.0.0.1:9".into(),
nostr::Keys::generate(),
None,
None,
)
.unwrap();
let error = cmd_send_message(
&client,
SendMessageParams {
channel_id: uuid::Uuid::nil().to_string(),
content: "ab\0cd".into(),
kind: None,
reply_to: None,
broadcast: false,
files: vec![],
mentions: vec![],
},
)
.await
.unwrap_err();

assert!(matches!(error, crate::error::CliError::Usage(_)));
assert_eq!(
error.to_string(),
"content contains NUL byte at byte offset 2"
);
}

// Three real pubkeys (lowercase 64-char hex) used by parse_member_pubkeys tests.
// See the test's own comment on what `PublicKey::from_hex` actually validates.
const PK_VALID_A: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4";
Expand Down
59 changes: 59 additions & 0 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1873,6 +1873,7 @@ async fn ingest_event_inner(
event.content.len()
)));
}
validate_no_nul_bytes(&event)?;

let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP;
if event.pubkey != *auth.pubkey() && !is_gift_wrap {
Expand Down Expand Up @@ -2903,6 +2904,26 @@ async fn ingest_event_inner(
})
}

fn validate_no_nul_bytes(event: &Event) -> Result<(), IngestError> {
if let Some(byte_offset) = event.content.find('\0') {
return Err(IngestError::Rejected(format!(
"invalid: content contains NUL byte at byte offset {byte_offset}"
)));
}

for (tag_index, tag) in event.tags.iter().enumerate() {
for (value_index, value) in tag.as_slice().iter().enumerate() {
if let Some(byte_offset) = value.find('\0') {
return Err(IngestError::Rejected(format!(
"invalid: tag {tag_index} value {value_index} contains NUL byte at byte offset {byte_offset}"
)));
}
}
}

Ok(())
}

#[cfg(test)]
mod tests {
use std::sync::Mutex;
Expand Down Expand Up @@ -3518,6 +3539,44 @@ mod tests {
.unwrap()
}

#[test]
fn event_text_validation_rejects_nul_in_content() {
let event = make_event_with_tags(KIND_STREAM_MESSAGE, "before\0after", &[]);
match validate_no_nul_bytes(&event) {
Err(IngestError::Rejected(message)) => assert_eq!(
message,
"invalid: content contains NUL byte at byte offset 6"
),
other => panic!("content NUL must be rejected, got {other:?}"),
}
}

#[test]
fn event_text_validation_rejects_nul_in_tag_value() {
let event = make_event_with_tags(
KIND_STREAM_MESSAGE,
"content without NUL",
&[&["h", "before\0after"]],
);
match validate_no_nul_bytes(&event) {
Err(IngestError::Rejected(message)) => assert_eq!(
message,
"invalid: tag 0 value 1 contains NUL byte at byte offset 6"
),
other => panic!("tag-value NUL must be rejected, got {other:?}"),
}
}

#[test]
fn event_text_validation_accepts_same_event_without_nul() {
let event = make_event_with_tags(
KIND_STREAM_MESSAGE,
"before-after",
&[&["h", "before-after"]],
);
assert!(validate_no_nul_bytes(&event).is_ok());
}

#[test]
fn count_e_tags_includes_malformed() {
// A deletion event with one valid e-tag and one malformed e-tag
Expand Down