mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
fix(relay): validate kind:30175 persona d-tag slug grammar on ingest
A persona event with an empty or malformed d-tag would win NIP-33 replacement against the (pubkey, 30175, "") slot, collapsing every persona into one address and silently destroying data. The relay now rejects persona events at ingest unless they carry exactly one d tag matching the NIP-AP slug grammar ^[a-z0-9][a-z0-9_-]{0,63}$ (1-64 bytes). Mirrors the existing validate_engram_envelope gate so malformed events never reach storage.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
a2e9c8c077
commit
632e736acd
@@ -885,6 +885,56 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the envelope of a kind:30175 persona event.
|
||||
///
|
||||
/// Enforces:
|
||||
/// * exactly one `d` tag with a non-empty value matching the slug grammar
|
||||
/// `^[a-z0-9][a-z0-9_-]{0,63}$`.
|
||||
///
|
||||
/// Without this, an empty d-tag collapses every persona into the
|
||||
/// `(pubkey, 30175, "")` slot — last-write-wins data loss.
|
||||
fn validate_persona_envelope(event: &Event) -> Result<(), String> {
|
||||
let mut d_tags: Vec<&str> = Vec::new();
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.len() >= 2 && parts[0].as_str() == "d" {
|
||||
d_tags.push(&parts[1]);
|
||||
}
|
||||
}
|
||||
if d_tags.len() != 1 {
|
||||
return Err(format!(
|
||||
"persona event must have exactly one `d` tag (got {})",
|
||||
d_tags.len()
|
||||
));
|
||||
}
|
||||
let d = d_tags[0];
|
||||
if d.is_empty() {
|
||||
return Err("persona event `d` tag must not be empty".to_string());
|
||||
}
|
||||
// Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
|
||||
if d.len() > 64 {
|
||||
return Err(format!(
|
||||
"persona event `d` tag too long ({} chars, max 64)",
|
||||
d.len()
|
||||
));
|
||||
}
|
||||
let bytes = d.as_bytes();
|
||||
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
|
||||
return Err(
|
||||
"persona event `d` tag must start with a lowercase letter or digit".to_string(),
|
||||
);
|
||||
}
|
||||
if !bytes[1..]
|
||||
.iter()
|
||||
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
{
|
||||
return Err(
|
||||
"persona event `d` tag must match [a-z0-9_-] after the first character".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
|
||||
///
|
||||
/// Checks:
|
||||
@@ -1370,6 +1420,12 @@ pub async fn ingest_event(
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// ── 15b. Persona envelope (kind:30175) ──────────────────────────────
|
||||
if kind_u32 == KIND_PERSONA {
|
||||
validate_persona_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// Track pre-created channel UUID for compensation on insert failure.
|
||||
let mut pre_created_channel: Option<Uuid> = None;
|
||||
|
||||
@@ -2285,4 +2341,97 @@ mod tests {
|
||||
let err = validate_engram_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("base64"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── NIP-AP persona envelope validation ───────────────────────────────
|
||||
|
||||
fn make_persona(tags: &[&[&str]]) -> Event {
|
||||
make_event_with_tags(
|
||||
KIND_PERSONA,
|
||||
r#"{"display_name":"x","system_prompt":"y"}"#,
|
||||
tags,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_valid_slug() {
|
||||
let ev = make_persona(&[&["d", "my-persona-1"]]);
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_single_char() {
|
||||
let ev = make_persona(&[&["d", "a"]]);
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_max_length() {
|
||||
let slug = "a".repeat(64);
|
||||
let ev = make_persona(&[&["d", &slug]]);
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_missing_d_tag() {
|
||||
let ev = make_persona(&[]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_empty_d_tag() {
|
||||
let ev = make_persona(&[&["d", ""]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("must not be empty"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_duplicate_d_tags() {
|
||||
let ev = make_persona(&[&["d", "slug-a"], &["d", "slug-b"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_too_long() {
|
||||
let slug = "a".repeat(65);
|
||||
let ev = make_persona(&[&["d", &slug]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("too long"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_uppercase() {
|
||||
let ev = make_persona(&[&["d", "My-Persona"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_leading_underscore() {
|
||||
let ev = make_persona(&[&["d", "_invalid"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("start with"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_leading_hyphen() {
|
||||
let ev = make_persona(&[&["d", "-invalid"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("start with"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_spaces() {
|
||||
let ev = make_persona(&[&["d", "has space"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_rejects_dots() {
|
||||
let ev = make_persona(&[&["d", "has.dot"]]);
|
||||
let err = validate_persona_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user