From 2d74b9ca69b3a1e0b9a2359c12cc2d1979fc6130 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 9 Apr 2026 15:24:17 +0000 Subject: fix: reject identifiers with whitespace and URL-decode path components Two bugs allowed a repository announcement with a space-containing identifier ('kuboslopp by Shakespeare') to enter purgatory and create a bare repo on disk, but then fail to serve git data over HTTP. Bug 1 (serving): parse_git_url and parse_repo_url did not percent-decode the URL path before resolving the filesystem path. A client requesting /npub.../kuboslopp%20by%20Shakespeare.git/info/refs had the identifier extracted as 'kuboslopp%20by%20Shakespeare' (literal %20), which did not match the on-disk directory 'kuboslopp by Shakespeare.git'. Fix: add percent_decode() in src/git/mod.rs and apply it to the repo component in both parse_git_url and parse_repo_url. Bug 2 (validation): validate_announcement did not check that the identifier is safe as a filesystem path component and URL segment. Identifiers containing whitespace, path separators, null bytes, or reserved names (. / ..) should be rejected at acceptance time. Fix: add validate_identifier() in src/nostr/events.rs and call it from validate_announcement before any other policy checks. --- src/nostr/events.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) (limited to 'src/nostr') diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 00e4486..88ed6ae 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -361,6 +361,52 @@ impl RepositoryState { } } +/// Validate that a repository identifier is safe for use as a filesystem path component +/// and as a URL path segment without percent-encoding. +/// +/// Rejects identifiers that: +/// - Are empty +/// - Contain path separators (`/`, `\`) +/// - Contain null bytes +/// - Contain whitespace (spaces, tabs, newlines, etc.) — these require percent-encoding +/// in URLs and cause a mismatch between the stored path and the URL-decoded request +/// - Are `.` or `..` (directory traversal) +/// +/// NIP-34 recommends kebab-case identifiers; this function enforces the minimum +/// safety constraints needed for correct filesystem and HTTP serving behaviour. +pub fn validate_identifier(identifier: &str) -> Result<(), String> { + if identifier.is_empty() { + return Err("identifier must not be empty".to_string()); + } + if identifier == "." || identifier == ".." { + return Err(format!( + "identifier '{}' is a reserved path component", + identifier + )); + } + for ch in identifier.chars() { + if ch == '/' || ch == '\\' { + return Err(format!( + "identifier '{}' contains path separator '{}'", + identifier, ch + )); + } + if ch == '\0' { + return Err(format!( + "identifier '{}' contains a null byte", + identifier + )); + } + if ch.is_whitespace() { + return Err(format!( + "identifier '{}' contains whitespace — use hyphens instead (e.g. 'my-repo')", + identifier + )); + } + } + Ok(()) +} + /// Validate a repository announcement according to GRASP-01 and GRASP-05 /// /// Returns: @@ -405,6 +451,11 @@ pub fn validate_announcement( Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), }; + // Validate identifier is safe for filesystem and URL use + if let Err(reason) = validate_identifier(&announcement.identifier) { + return AnnouncementResult::Reject(format!("Invalid identifier: {}", reason)); + } + // Get validated configs (config.validate() must be called at startup) let archive_config = config.archive_config(); let repository_config = config.repository_config(); @@ -1511,4 +1562,70 @@ mod tests { let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Accept)); } + + // ------------------------------------------------------------------------- + // validate_identifier tests + // ------------------------------------------------------------------------- + + #[test] + fn test_validate_identifier_valid() { + assert!(validate_identifier("my-repo").is_ok()); + assert!(validate_identifier("my_repo").is_ok()); + assert!(validate_identifier("repo123").is_ok()); + assert!(validate_identifier("kuboslopp").is_ok()); + } + + #[test] + fn test_validate_identifier_rejects_empty() { + assert!(validate_identifier("").is_err()); + } + + #[test] + fn test_validate_identifier_rejects_dot_components() { + assert!(validate_identifier(".").is_err()); + assert!(validate_identifier("..").is_err()); + } + + #[test] + fn test_validate_identifier_rejects_path_separators() { + assert!(validate_identifier("foo/bar").is_err()); + assert!(validate_identifier("foo\\bar").is_err()); + } + + #[test] + fn test_validate_identifier_rejects_whitespace() { + assert!(validate_identifier("kuboslopp by Shakespeare").is_err()); + assert!(validate_identifier("my\trepo").is_err()); + assert!(validate_identifier("my\nrepo").is_err()); + } + + #[test] + fn test_validate_announcement_rejects_identifier_with_spaces() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + // Identifier contains spaces — should be rejected regardless of clone/relay tags + let event = create_announcement_event( + &keys, + "kuboslopp by Shakespeare", + vec!["https://gitnostr.com/alice/kuboslopp%20by%20Shakespeare.git"], + vec!["wss://gitnostr.com"], + ); + + let config = Config { + domain: "gitnostr.com".to_string(), + ..Config::for_testing() + }; + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!( + reason.contains("whitespace") || reason.contains("identifier"), + "unexpected rejection reason: {}", + reason + ); + } else { + panic!("Expected Reject for identifier with spaces, got {:?}", result); + } + } } -- cgit v1.2.3