Skip to content
Merged
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
91 changes: 91 additions & 0 deletions crates/buzz-relay/src/handlers/imeta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,55 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> {
}
}

/// Validate an authored link-preview media URL/hash pair.
///
/// Empty pairs are allowed. Non-empty media must be an exact, credential-free
/// URL on this tenant's media origin with a content-addressed image path.
pub fn validate_local_image_media_pair(
media_url: &str,
sha256: &str,
media_base_url: &str,
) -> bool {
const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
if media_url.is_empty() && sha256.is_empty() {
return true;
}
if media_url.is_empty()
|| sha256.len() != 64
|| !sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
{
return false;
}

let Ok(parsed) = url::Url::parse(media_url) else {
return false;
};
let Ok(base) = url::Url::parse(media_base_url) else {
return false;
};
if parsed.scheme() != base.scheme()
|| parsed.host_str() != base.host_str()
|| parsed.port_or_known_default() != base.port_or_known_default()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return false;
}

let Some(filename) = parsed.path().strip_prefix("/media/") else {
return false;
};
let Some((path_hash, ext)) = filename.split_once('.') else {
return false;
};
!filename.contains('/')
&& !filename.contains('%')
&& path_hash == sha256
&& IMAGE_EXTS.contains(&ext)
}

/// Validate that a URL references a valid local media blob path.
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
// A safe extension token: 1–8 lowercase alphanumeric chars. Covers media
Expand Down Expand Up @@ -423,6 +472,48 @@ mod tests {
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const BASE: &str = "https://relay.example.com/media";

#[test]
fn link_preview_media_pair_requires_exact_local_image_url_and_hash() {
let hash = HASH;
let valid = format!("{BASE}/{hash}.png");
assert!(validate_local_image_media_pair(&valid, hash, BASE));
assert!(!validate_local_image_media_pair(
&format!("https://evil.example/media/{hash}.png"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png?token=leak"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png#fragment"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("https://user@relay.example.com/media/{hash}.png"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.svg"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png/extra"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png"),
&"0".repeat(64),
BASE
));
}

#[test]
fn test_local_media_url_relative() {
assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE));
Expand Down
178 changes: 176 additions & 2 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,72 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) {
.increment(1);
}

fn valid_link_preview_text(value: &str, max: usize, allow_newlines: bool) -> bool {
value.len() <= max
&& !value
.chars()
.any(|character| character.is_control() && !(allow_newlines && character == '\n'))
}

fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), String> {
const MAX_SNAPSHOTS: usize = 8;
const MAX_TITLE: usize = 300;
const MAX_SITE: usize = 100;
const MAX_DESCRIPTION: usize = 1000;

let mut count = 0;
let mut suppressed = false;
let mut seen = std::collections::HashSet::new();
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(String::as_str) != Some("link-preview") {
continue;
}
count += 1;
if parts == ["link-preview", "none"] {
if count > 1 {
return Err("link-preview suppression cannot include snapshots".into());
}
suppressed = true;
continue;
}
if suppressed
|| count > MAX_SNAPSHOTS
|| parts.len() != 11
|| parts[1] != "snapshot"
|| parts[2] != "1"
{
return Err("invalid link-preview snapshot tag".into());
}
let canonical =
url::Url::parse(&parts[3]).map_err(|_| "invalid link-preview canonical URL")?;
if canonical.scheme() != "https"
|| !canonical.username().is_empty()
|| canonical.password().is_some()
|| canonical.fragment().is_some()
|| !seen.insert(parts[3].clone())
|| !event.content.contains(&parts[3])
{
return Err("invalid link-preview canonical URL".into());
}
for (value, max, allow_newlines) in [
(&parts[4], MAX_TITLE, false),
(&parts[5], MAX_SITE, false),
(&parts[6], MAX_DESCRIPTION, true),
] {
if !valid_link_preview_text(value, max, allow_newlines) {
return Err("invalid link-preview snapshot text".into());
}
}
if !super::imeta::validate_local_image_media_pair(&parts[7], &parts[8], media_base_url)
|| !super::imeta::validate_local_image_media_pair(&parts[9], &parts[10], media_base_url)
{
return Err("link-preview media must reference matching local image blobs".into());
}
}
Ok(())
}

/// Successful ingestion result.
pub struct IngestResult {
/// Hex-encoded event ID.
Expand Down Expand Up @@ -2647,15 +2713,20 @@ async fn ingest_event_inner(
});
}

let tenant_media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
if kind_u32 == KIND_STREAM_MESSAGE {
validate_link_preview_tags(&event, &tenant_media_base)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}

let imeta_tags: Vec<Vec<String>> = event
.tags
.iter()
.filter(|t| t.kind().to_string() == "imeta")
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
if !imeta_tags.is_empty() {
let tenant_media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage)
Expand Down Expand Up @@ -3632,6 +3703,109 @@ mod tests {
assert!(validate_diff_event(&event).is_err());
}

#[test]
fn link_preview_suppression_accepts_blanket_marker() {
let event = make_event_with_tags(
KIND_STREAM_MESSAGE,
"https://example.com",
&[&["link-preview", "none"]],
);

assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
}

#[test]
fn link_preview_suppression_rejects_duplicate_marker() {
let event = make_event_with_tags(
KIND_STREAM_MESSAGE,
"https://example.com",
&[&["link-preview", "none"], &["link-preview", "none"]],
);

assert_eq!(
validate_link_preview_tags(&event, "https://media.example.com"),
Err("link-preview suppression cannot include snapshots".into())
);
}

#[test]
fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() {
let snapshot = [
"link-preview",
"snapshot",
"1",
"https://example.com",
"Example",
"Example",
"Description",
"",
"",
"",
"",
];
for tags in [
vec![&["link-preview", "none"][..], &snapshot[..]],
vec![&snapshot[..], &["link-preview", "none"][..]],
] {
let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags);
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
}
}

fn make_link_preview_event(title: &str, site: &str, description: &str) -> Event {
make_event_with_tags(
KIND_STREAM_MESSAGE,
"https://example.com",
&[&[
"link-preview",
"snapshot",
"1",
"https://example.com",
title,
site,
description,
"",
"",
"",
"",
]],
)
}

#[test]
fn link_preview_snapshot_accepts_description_newlines() {
let event = make_link_preview_event(
"Example title",
"Example site",
"First paragraph\n\nSecond paragraph",
);

assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
}

#[test]
fn link_preview_snapshot_rejects_title_and_site_newlines() {
for (title, site) in [
("Example\ntitle", "Example site"),
("Example title", "Example\nsite"),
] {
let event = make_link_preview_event(title, site, "Description");
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
}
}

#[test]
fn link_preview_snapshot_rejects_non_newline_controls_in_all_text_fields() {
for (title, site, description) in [
("Example\ttitle", "Example site", "Description"),
("Example title", "Example\rsite", "Description"),
("Example title", "Example site", "Unsafe\tdescription"),
] {
let event = make_link_preview_event(title, site, description);
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
}
}

fn make_dummy_event() -> Event {
let keys = nostr::Keys::generate();
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
Expand Down
Loading
Loading