lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
MCHANGELOG.md | 6++++++
MCargo.lock | 7+++++++
MCargo.toml | 1+
Mcontracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/events/blossom-media.md | 16+++++++++++-----
Mcontracts/releases/1.0.0-alpha.1.toml | 9+++++++++
Mcrates/blossom/Cargo.toml | 1+
Mcrates/blossom/src/url.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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" } }, {