commit 2e55fd5d76fd15e8e5f082c32f24588c6959c8a4
parent b8ed88d8cd99f3d1c3c84ee6925b621ecf4c49bf
Author: triesap <tyson@radroots.org>
Date: Thu, 9 Jul 2026 04:28:39 +0000
events_codec: reject duplicate http auth tags
- Adds a duplicate-tag parse error for singleton event tags.
- Makes NIP-98 HTTP auth reject duplicate u, method, and payload tags.
- Preserves order-envelope error mapping for duplicate tag inputs.
- Documents and tests the stricter HTTP auth parse contract.
Diffstat:
6 files changed, 68 insertions(+), 6 deletions(-)
diff --git a/crates/events_codec/README b/crates/events_codec/README
@@ -27,7 +27,9 @@ The Field codec surface validates the public Nostr event substrate exposed by
address, a farm group id, an owner document tag, lowercase SHA-256 hashes,
and optional caption text as content;
* NIP-42 relay auth and NIP-98 HTTP auth events require empty content and the
- auth tags required by their protocols;
+ auth tags required by their protocols; NIP-98 `u`, `method`, and `payload`
+ tags are parsed as singleton security tags and duplicate occurrences fail
+ closed;
* NIP-29 group codecs preserve the protocol distinction between `h`-routed
group operations and `d`-routed addressable group state for the supported
`9000`, `9001`, `9002`, `9005`, `9007`, `9008`, `9009`, `9021`, `9022`,
diff --git a/crates/events_codec/src/error.rs b/crates/events_codec/src/error.rs
@@ -4,6 +4,7 @@ use core::fmt;
pub enum EventParseError {
MissingTag(&'static str),
InvalidTag(&'static str),
+ DuplicateTag(&'static str),
InvalidKind { expected: &'static str, got: u32 },
InvalidNumber(&'static str, core::num::ParseIntError),
InvalidJson(&'static str),
@@ -14,6 +15,7 @@ impl EventParseError {
match self {
Self::MissingTag(_) => "missing_tag",
Self::InvalidTag(_) => "invalid_tag",
+ Self::DuplicateTag(_) => "duplicate_tag",
Self::InvalidKind { .. } => "invalid_kind",
Self::InvalidNumber(_, _) => "invalid_number",
Self::InvalidJson(_) => "invalid_json",
@@ -26,6 +28,7 @@ impl fmt::Display for EventParseError {
match self {
EventParseError::MissingTag(t) => write!(f, "missing tag: {}", t),
EventParseError::InvalidTag(t) => write!(f, "invalid tag structure for '{}'", t),
+ EventParseError::DuplicateTag(t) => write!(f, "duplicate tag: {}", t),
EventParseError::InvalidKind { expected, got } => {
write!(f, "invalid kind {} (expected {})", got, expected)
}
diff --git a/crates/events_codec/src/field_helpers.rs b/crates/events_codec/src/field_helpers.rs
@@ -184,6 +184,13 @@ pub(crate) fn required_tag_value(
})
}
+pub(crate) fn required_unique_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<String, EventParseError> {
+ unique_tag_value(tags, key)?.ok_or(EventParseError::MissingTag(key))
+}
+
pub(crate) fn optional_tag_value(
tags: &[Vec<String>],
key: &'static str,
@@ -202,6 +209,34 @@ pub(crate) fn optional_tag_value(
Ok(Some(value))
}
+pub(crate) fn optional_unique_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ unique_tag_value(tags, key)
+}
+
+fn unique_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let mut matches = tags
+ .iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(key));
+ let Some(tag) = matches.next() else {
+ return Ok(None);
+ };
+ if matches.next().is_some() {
+ return Err(EventParseError::DuplicateTag(key));
+ }
+ let value = tag
+ .get(1)
+ .map(ToString::to_string)
+ .ok_or(EventParseError::InvalidTag(key))?;
+ validate_non_empty_tag_value(&value, key)?;
+ Ok(Some(value))
+}
+
pub(crate) fn tag_values(
tags: &[Vec<String>],
key: &'static str,
diff --git a/crates/events_codec/src/http_auth/decode.rs b/crates/events_codec/src/http_auth/decode.rs
@@ -9,7 +9,8 @@ use radroots_events::{
use crate::error::EventParseError;
use crate::field_helpers::{
- optional_tag_value, require_empty_content, required_tag_value, validate_lowercase_hex_64_tag,
+ optional_unique_tag_value, require_empty_content, required_unique_tag_value,
+ validate_lowercase_hex_64_tag,
};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
@@ -27,13 +28,13 @@ pub fn http_auth_from_event(
});
}
require_empty_content(content, "content")?;
- let payload_sha256 = optional_tag_value(tags, TAG_PAYLOAD)?;
+ let payload_sha256 = optional_unique_tag_value(tags, TAG_PAYLOAD)?;
if let Some(payload) = payload_sha256.as_deref() {
validate_lowercase_hex_64_tag(payload, TAG_PAYLOAD)?;
}
Ok(RadrootsHttpAuth {
- url: required_tag_value(tags, TAG_URL_AUTH)?,
- method: required_tag_value(tags, TAG_METHOD)?,
+ url: required_unique_tag_value(tags, TAG_URL_AUTH)?,
+ method: required_unique_tag_value(tags, TAG_METHOD)?,
payload_sha256,
})
}
diff --git a/crates/events_codec/src/http_auth/mod.rs b/crates/events_codec/src/http_auth/mod.rs
@@ -143,6 +143,26 @@ mod tests {
}
#[test]
+ fn http_auth_rejects_duplicate_security_tags() {
+ let auth = RadrootsHttpAuth {
+ url: "https://media.example.invalid/upload".to_string(),
+ method: "POST".to_string(),
+ payload_sha256: Some(PAYLOAD.to_string()),
+ };
+ let parts = to_wire_parts(&auth).expect("http auth wire parts");
+
+ for key in ["u", "method", "payload"] {
+ let mut duplicate = parts.tags.clone();
+ duplicate.push(tag(key, "duplicate"));
+ let err = http_auth_from_event(parts.kind, &duplicate, "").unwrap_err();
+ assert!(
+ matches!(err, EventParseError::DuplicateTag(tag) if tag == key),
+ "expected duplicate {key}, got {err:?}"
+ );
+ }
+ }
+
+ #[test]
fn http_auth_wrappers_preserve_event_metadata() {
let auth = RadrootsHttpAuth {
url: "https://media.example.invalid/upload".to_string(),
diff --git a/crates/events_codec/src/order/decode.rs b/crates/events_codec/src/order/decode.rs
@@ -363,7 +363,8 @@ fn map_tag_parse_error_for_order_envelope(
crate::error::EventParseError::MissingTag(tag) => {
RadrootsOrderEnvelopeParseError::MissingTag(tag)
}
- crate::error::EventParseError::InvalidTag(tag) => {
+ crate::error::EventParseError::InvalidTag(tag)
+ | crate::error::EventParseError::DuplicateTag(tag) => {
RadrootsOrderEnvelopeParseError::InvalidTag(tag)
}
crate::error::EventParseError::InvalidKind { expected: _, got } => {