upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/nostr
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-04-09 15:24:17 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-04-09 15:24:17 +0000
commit2d74b9ca69b3a1e0b9a2359c12cc2d1979fc6130 (patch)
tree61180841310feaca54c1661552d88347a0bebd72 /src/nostr
parent28168a7701c897a5b6af13bc472d6f5902e0a96d (diff)
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.
Diffstat (limited to 'src/nostr')
-rw-r--r--src/nostr/events.rs117
1 files changed, 117 insertions, 0 deletions
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 {
361 } 361 }
362} 362}
363 363
364/// Validate that a repository identifier is safe for use as a filesystem path component
365/// and as a URL path segment without percent-encoding.
366///
367/// Rejects identifiers that:
368/// - Are empty
369/// - Contain path separators (`/`, `\`)
370/// - Contain null bytes
371/// - Contain whitespace (spaces, tabs, newlines, etc.) — these require percent-encoding
372/// in URLs and cause a mismatch between the stored path and the URL-decoded request
373/// - Are `.` or `..` (directory traversal)
374///
375/// NIP-34 recommends kebab-case identifiers; this function enforces the minimum
376/// safety constraints needed for correct filesystem and HTTP serving behaviour.
377pub fn validate_identifier(identifier: &str) -> Result<(), String> {
378 if identifier.is_empty() {
379 return Err("identifier must not be empty".to_string());
380 }
381 if identifier == "." || identifier == ".." {
382 return Err(format!(
383 "identifier '{}' is a reserved path component",
384 identifier
385 ));
386 }
387 for ch in identifier.chars() {
388 if ch == '/' || ch == '\\' {
389 return Err(format!(
390 "identifier '{}' contains path separator '{}'",
391 identifier, ch
392 ));
393 }
394 if ch == '\0' {
395 return Err(format!(
396 "identifier '{}' contains a null byte",
397 identifier
398 ));
399 }
400 if ch.is_whitespace() {
401 return Err(format!(
402 "identifier '{}' contains whitespace — use hyphens instead (e.g. 'my-repo')",
403 identifier
404 ));
405 }
406 }
407 Ok(())
408}
409
364/// Validate a repository announcement according to GRASP-01 and GRASP-05 410/// Validate a repository announcement according to GRASP-01 and GRASP-05
365/// 411///
366/// Returns: 412/// Returns:
@@ -405,6 +451,11 @@ pub fn validate_announcement(
405 Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)), 451 Err(e) => return AnnouncementResult::Reject(format!("Invalid announcement: {}", e)),
406 }; 452 };
407 453
454 // Validate identifier is safe for filesystem and URL use
455 if let Err(reason) = validate_identifier(&announcement.identifier) {
456 return AnnouncementResult::Reject(format!("Invalid identifier: {}", reason));
457 }
458
408 // Get validated configs (config.validate() must be called at startup) 459 // Get validated configs (config.validate() must be called at startup)
409 let archive_config = config.archive_config(); 460 let archive_config = config.archive_config();
410 let repository_config = config.repository_config(); 461 let repository_config = config.repository_config();
@@ -1511,4 +1562,70 @@ mod tests {
1511 let result = validate_announcement(&event, &config); 1562 let result = validate_announcement(&event, &config);
1512 assert!(matches!(result, AnnouncementResult::Accept)); 1563 assert!(matches!(result, AnnouncementResult::Accept));
1513 } 1564 }
1565
1566 // -------------------------------------------------------------------------
1567 // validate_identifier tests
1568 // -------------------------------------------------------------------------
1569
1570 #[test]
1571 fn test_validate_identifier_valid() {
1572 assert!(validate_identifier("my-repo").is_ok());
1573 assert!(validate_identifier("my_repo").is_ok());
1574 assert!(validate_identifier("repo123").is_ok());
1575 assert!(validate_identifier("kuboslopp").is_ok());
1576 }
1577
1578 #[test]
1579 fn test_validate_identifier_rejects_empty() {
1580 assert!(validate_identifier("").is_err());
1581 }
1582
1583 #[test]
1584 fn test_validate_identifier_rejects_dot_components() {
1585 assert!(validate_identifier(".").is_err());
1586 assert!(validate_identifier("..").is_err());
1587 }
1588
1589 #[test]
1590 fn test_validate_identifier_rejects_path_separators() {
1591 assert!(validate_identifier("foo/bar").is_err());
1592 assert!(validate_identifier("foo\\bar").is_err());
1593 }
1594
1595 #[test]
1596 fn test_validate_identifier_rejects_whitespace() {
1597 assert!(validate_identifier("kuboslopp by Shakespeare").is_err());
1598 assert!(validate_identifier("my\trepo").is_err());
1599 assert!(validate_identifier("my\nrepo").is_err());
1600 }
1601
1602 #[test]
1603 fn test_validate_announcement_rejects_identifier_with_spaces() {
1604 use crate::config::Config;
1605 use crate::nostr::policy::AnnouncementResult;
1606
1607 let keys = create_test_keys();
1608 // Identifier contains spaces — should be rejected regardless of clone/relay tags
1609 let event = create_announcement_event(
1610 &keys,
1611 "kuboslopp by Shakespeare",
1612 vec!["https://gitnostr.com/alice/kuboslopp%20by%20Shakespeare.git"],
1613 vec!["wss://gitnostr.com"],
1614 );
1615
1616 let config = Config {
1617 domain: "gitnostr.com".to_string(),
1618 ..Config::for_testing()
1619 };
1620 let result = validate_announcement(&event, &config);
1621 if let AnnouncementResult::Reject(reason) = result {
1622 assert!(
1623 reason.contains("whitespace") || reason.contains("identifier"),
1624 "unexpected rejection reason: {}",
1625 reason
1626 );
1627 } else {
1628 panic!("Expected Reject for identifier with spaces, got {:?}", result);
1629 }
1630 }
1514} 1631}