diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-04-09 15:24:17 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-04-09 15:24:17 +0000 |
| commit | 2d74b9ca69b3a1e0b9a2359c12cc2d1979fc6130 (patch) | |
| tree | 61180841310feaca54c1661552d88347a0bebd72 /src/nostr | |
| parent | 28168a7701c897a5b6af13bc472d6f5902e0a96d (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.rs | 117 |
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. | ||
| 377 | pub 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 | } |