commit 78f8befa5bf2a3d71170865d21057d854c3fe5b8
parent 1c2e5bbe39282ebfb73d885f224e412248165589
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 04:07:37 +0000
fix: validate raw Blossom URL authorities
- reject Unicode control and format text before URL parsing
- enforce exact ASCII DNS host and label grammar on raw input
- preserve canonical IP and URL-parser-valid punycode authorities
- execute boundary cases through packaged conformance vectors
Diffstat:
9 files changed, 330 insertions(+), 23 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,6 +9,12 @@ publish policy both pass for the same source revision.
### Changed
+- Blossom blob URLs now validate complete raw Unicode text before URL parsing
+ and exact raw ASCII DNS label grammar before returning a typed value. Unicode
+ control/format text, implicit IDNA conversion, empty labels, underscores,
+ edge hyphens, and oversized DNS names can no longer enter approved or
+ byte-verified media typestates; URL-parser-valid explicit ASCII punycode and
+ canonical IP authorities remain supported.
- NIP-99 kind `30402` now has an explicit two-level taxonomy: the standard
protocol kind and coordinate are **Classified Listing**, while the richer
Radroots farm, bin, inventory, and price profile is **Operational Listing**.
diff --git a/Cargo.lock b/Cargo.lock
@@ -4295,6 +4295,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "unicode-general-category",
"url",
]
@@ -7799,6 +7800,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -200,6 +200,7 @@ tracing = { version = "0.1", default-features = false }
tracing-appender = { version = "0.2" }
tracing-log = { version = "0.2" }
tracing-subscriber = { version = "0.3" }
+unicode-general-category = { version = "=1.1.0" }
url = { version = "2" }
url_nostd = { package = "url", version = "2", default-features = false }
uuid = { version = "1.22.0", features = ["v4", "v7"] }
diff --git a/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json b/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json
@@ -433,6 +433,104 @@
}
},
{
+ "id": "blossom_blob_url_explicit_punycode_accepted_036e",
+ "kind": "blossom.blob_url.parse.valid",
+ "input": {
+ "url": "https://xn--mdia-9oa.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "scheme": "https",
+ "host": "xn--mdia-9oa.example",
+ "port": null,
+ "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ "extension": "png"
+ }
+ },
+ {
+ "id": "blossom_blob_url_format_character_rejected_036f",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media\u200b.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_implicit_idna_rejected_036g",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://m\u00e9dia.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_underscore_label_rejected_036h",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media_store.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_edge_hyphen_rejected_036i",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://-media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_empty_label_rejected_036j",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media..example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_oversized_label_rejected_036k",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_maximum_dns_host_accepted_036l",
+ "kind": "blossom.blob_url.parse.valid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "scheme": "https",
+ "host": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "port": null,
+ "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ "extension": "png"
+ }
+ },
+ {
+ "id": "blossom_blob_url_oversized_dns_host_rejected_036m",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
"id": "blossom_reference_https_public_approved_037",
"kind": "blossom.reference_policy.valid",
"input": {
@@ -552,13 +650,13 @@
}
},
{
- "id": "blossom_reference_http_absolute_localhost_name_rejected_048",
- "kind": "blossom.reference_policy.invalid",
+ "id": "blossom_blob_url_absolute_localhost_name_rejected_048",
+ "kind": "blossom.blob_url.parse.invalid",
"input": {
"url": "http://localhost./ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
},
"expected": {
- "error": "insecure_blob_url"
+ "error": "invalid_blob_url"
}
},
{
diff --git a/contracts/events/blossom-media.md b/contracts/events/blossom-media.md
@@ -66,6 +66,9 @@ A `RadrootsBlossomBlobUrl` is an absolute structural BUD-01 URL with these invar
- the scheme is `http` or `https`, compared case-insensitively and exposed canonically in lowercase
- the authority contains one nonempty DNS hostname, canonical dotted-decimal IPv4 address, or
bracketed IPv6 address and may contain a valid decimal port
+- raw DNS hostnames are ASCII, at most 253 bytes, and consist of dot-separated 1-through-63-byte
+ labels containing only ASCII letters, digits, or interior hyphens; URL-parser-valid explicit
+ ASCII punycode is accepted, while implicit IDNA conversion of a raw Unicode hostname is rejected
- user information is forbidden
- the path is exactly one root hash path
- query and fragment components are forbidden
@@ -73,11 +76,14 @@ A `RadrootsBlossomBlobUrl` is an absolute structural BUD-01 URL with these invar
- an optional safe extension is parsed independently from the digest
Scheme and DNS host case are canonicalized to lowercase by the URL parser. Extension case is
-preserved. Noncanonical, shortened, octal, hexadecimal, or otherwise ambiguous IPv4 spellings are
-rejected before a typed URL is returned; this prevents approval behavior from changing after a
-serialization round trip. Raw user-information delimiters, whitespace, control characters,
-percent-encoded path data, malformed or zero ports, unbracketed IPv6 addresses, and additional path
-segments are also rejected before URL-parser normalization can discard or reinterpret them.
+preserved. Before parsing, the complete raw URL rejects whitespace plus Unicode general categories
+`Cc` and `Cf`. After structural parsing, the preserved raw authority is checked before a typed URL
+is returned: DNS labels must begin and end with an ASCII alphanumeric character, and noncanonical,
+shortened, octal, hexadecimal, or otherwise ambiguous IPv4 spellings are rejected. These checks
+prevent approval behavior from changing after a serialization round trip. Raw user-information
+delimiters, invalid or implicit-IDNA DNS names, percent-encoded path data, malformed or zero ports,
+unbracketed IPv6 addresses, and additional path segments are also rejected before parser
+normalization can enter a typed value.
Structural validity proves only that the URL has a BUD-01-compatible shape. It does not approve the
transport scheme, perform DNS resolution, follow a redirect, issue `HEAD` or `GET`, or establish
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -136,3 +136,12 @@ semver_impacts = [
"change_exported_algorithm_behavior",
]
summary = "Separate the standard NIP-99 Classified Listing kind and coordinate authority from the richer Radroots Operational Listing profile, operations, codecs, and conformance namespace."
+
+[[changes]]
+id = "blossom-raw-authority-validation"
+classification = "breaking"
+semver_impacts = [
+ "add_conformance_vector",
+ "change_exported_algorithm_behavior",
+]
+summary = "Reject raw Unicode normalization, implicit IDNA, and invalid ASCII DNS labels before constructing Blossom blob URL, approval, or byte-verification typestates."
diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml
@@ -21,6 +21,7 @@ std = ["serde?/std", "sha2/std", "url_nostd/std"]
mediatype = { workspace = true }
serde = { workspace = true, optional = true }
sha2 = { workspace = true }
+unicode-general-category = { workspace = true }
url_nostd = { workspace = true }
[dev-dependencies]
diff --git a/crates/blossom/src/url.rs b/crates/blossom/src/url.rs
@@ -2,6 +2,7 @@
use alloc::string::String;
use alloc::string::ToString;
use core::{fmt, str::FromStr};
+use unicode_general_category::{GeneralCategory, get_general_category};
use url_nostd::{Host, Url};
use crate::{RadrootsBlossomError, RadrootsBlossomHashPath};
@@ -14,11 +15,7 @@ pub struct RadrootsBlossomBlobUrl {
impl RadrootsBlossomBlobUrl {
pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> {
- if !value.contains("://")
- || value
- .chars()
- .any(|character| character.is_whitespace() || character.is_control())
- {
+ if !value.contains("://") || !raw_url_text_is_valid(value) {
return Err(RadrootsBlossomError::InvalidBlobUrl);
}
let url = Url::parse(value).map_err(|_| RadrootsBlossomError::InvalidBlobUrl)?;
@@ -163,14 +160,47 @@ fn validate_authority(value: &str, url: &Url) -> Result<(), RadrootsBlossomError
Ok(0) | Err(_) => return Err(RadrootsBlossomError::InvalidBlobUrl),
}
}
- if let Some(Host::Ipv4(address)) = url.host()
- && raw_host != address.to_string()
- {
- return Err(RadrootsBlossomError::InvalidBlobUrl);
+ match url.host() {
+ Some(Host::Domain(_)) if !raw_dns_host_is_valid(raw_host) => {
+ return Err(RadrootsBlossomError::InvalidBlobUrl);
+ }
+ Some(Host::Ipv4(address)) if raw_host != address.to_string() => {
+ return Err(RadrootsBlossomError::InvalidBlobUrl);
+ }
+ Some(_) => {}
+ None => return Err(RadrootsBlossomError::InvalidBlobUrl),
}
Ok(())
}
+fn raw_url_text_is_valid(value: &str) -> bool {
+ !value.chars().any(|character| {
+ character.is_whitespace()
+ || matches!(
+ get_general_category(character),
+ GeneralCategory::Control | GeneralCategory::Format
+ )
+ })
+}
+
+fn raw_dns_host_is_valid(host: &str) -> bool {
+ !host.is_empty()
+ && host.is_ascii()
+ && host.len() <= 253
+ && host.split('.').all(raw_dns_label_is_valid)
+}
+
+fn raw_dns_label_is_valid(label: &str) -> bool {
+ let bytes = label.as_bytes();
+ !bytes.is_empty()
+ && bytes.len() <= 63
+ && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
+ && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
+ && bytes
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
+}
+
fn raw_authority(value: &str) -> &str {
let (_, remainder) = value
.split_once("://")
@@ -206,7 +236,10 @@ fn raw_authority_port(value: &str) -> Option<&str> {
#[cfg(test)]
mod tests {
use super::*;
- use alloc::{format, string::ToString};
+ use alloc::{
+ format,
+ string::{String, ToString},
+ };
const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
@@ -256,8 +289,6 @@ mod tests {
"http://192.168.1.2",
"http://10.0.0.2",
"http://[::]",
- "http://.localhost",
- "http://a..localhost",
] {
let parsed = RadrootsBlossomBlobUrl::parse(&url(origin)).unwrap();
assert!(!parsed.is_https());
@@ -357,6 +388,8 @@ mod tests {
format!("https://cdn.example.com/{HASH}.txt\n"),
format!("https://cdn.example.com/{HASH}.txt\t"),
format!("https://cdn.example.com/{HASH}.txt\u{7f}"),
+ format!("https://media\u{200b}.example/{HASH}.txt"),
+ format!("https://media\u{2060}.example/{HASH}.txt"),
] {
assert_eq!(
RadrootsBlossomBlobUrl::parse(&value),
@@ -377,4 +410,52 @@ mod tests {
);
}
}
+
+ #[test]
+ fn raw_dns_authority_is_validated_from_preserved_input() {
+ for origin in [
+ "https://média.example",
+ "https://foo_bar.example",
+ "https://-foo.example",
+ "https://foo-.example",
+ "https://foo..example",
+ "https://.example",
+ "https://example.",
+ ] {
+ assert_eq!(
+ RadrootsBlossomBlobUrl::parse(&url(origin)),
+ Err(RadrootsBlossomError::InvalidBlobUrl),
+ "{origin}"
+ );
+ }
+
+ let label_too_long = "a".repeat(64);
+ assert_eq!(
+ RadrootsBlossomBlobUrl::parse(&url(&format!("https://{label_too_long}.example"))),
+ Err(RadrootsBlossomError::InvalidBlobUrl)
+ );
+ let host_too_long = format!(
+ "{}.{}.{}.{}",
+ "a".repeat(63),
+ "b".repeat(63),
+ "c".repeat(63),
+ "d".repeat(62)
+ );
+ assert!(host_too_long.len() > 253);
+ assert_eq!(
+ RadrootsBlossomBlobUrl::parse(&url(&format!("https://{host_too_long}"))),
+ Err(RadrootsBlossomError::InvalidBlobUrl)
+ );
+
+ let maximum_host = format!(
+ "{}.{}.{}.{}",
+ "a".repeat(63),
+ "b".repeat(63),
+ "c".repeat(63),
+ "d".repeat(61)
+ );
+ assert_eq!(maximum_host.len(), 253);
+ assert!(RadrootsBlossomBlobUrl::parse(&url(&format!("https://{maximum_host}"))).is_ok());
+ assert!(RadrootsBlossomBlobUrl::parse(&url("https://xn--mdia-9oa.example")).is_ok());
+ }
}
diff --git a/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json b/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json
@@ -433,6 +433,104 @@
}
},
{
+ "id": "blossom_blob_url_explicit_punycode_accepted_036e",
+ "kind": "blossom.blob_url.parse.valid",
+ "input": {
+ "url": "https://xn--mdia-9oa.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "scheme": "https",
+ "host": "xn--mdia-9oa.example",
+ "port": null,
+ "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ "extension": "png"
+ }
+ },
+ {
+ "id": "blossom_blob_url_format_character_rejected_036f",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media\u200b.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_implicit_idna_rejected_036g",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://m\u00e9dia.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_underscore_label_rejected_036h",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media_store.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_edge_hyphen_rejected_036i",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://-media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_empty_label_rejected_036j",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://media..example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_oversized_label_rejected_036k",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
+ "id": "blossom_blob_url_maximum_dns_host_accepted_036l",
+ "kind": "blossom.blob_url.parse.valid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "scheme": "https",
+ "host": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "port": null,
+ "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ "extension": "png"
+ }
+ },
+ {
+ "id": "blossom_blob_url_oversized_dns_host_rejected_036m",
+ "kind": "blossom.blob_url.parse.invalid",
+ "input": {
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
+ },
+ "expected": {
+ "error": "invalid_blob_url"
+ }
+ },
+ {
"id": "blossom_reference_https_public_approved_037",
"kind": "blossom.reference_policy.valid",
"input": {
@@ -552,13 +650,13 @@
}
},
{
- "id": "blossom_reference_http_absolute_localhost_name_rejected_048",
- "kind": "blossom.reference_policy.invalid",
+ "id": "blossom_blob_url_absolute_localhost_name_rejected_048",
+ "kind": "blossom.blob_url.parse.invalid",
"input": {
"url": "http://localhost./ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png"
},
"expected": {
- "error": "insecure_blob_url"
+ "error": "invalid_blob_url"
}
},
{