From 44a16da91f24f793b7ebc1ffdd46df8b6addfa75 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 18:54:25 +0000 Subject: [PATCH] fix(security): recognize exact owner clone endpoints Motivation: The authorization-integrity pass treated PR refs hosted in a submitter owner view as unexplained whenever the PR targeted a different repository owner. NIP-34 clone tags can legitimately name that submitter-hosted standard endpoint, producing false manual-inspection findings and obscuring actual unexplained refs. Approach: Authorize an owner-view PR ref when either the existing target-maintainer relationship applies or an HTTP(S) clone tag exactly names the canonical service mount, owner npub, and identifier. Include those source identifiers in the lease-time event refresh and reject foreign authorities, credentials, query/fragment suffixes, extra paths, different coordinates, and /prs/ endpoints. Correctness assumptions: Accepted PR and PR Update events remain the event authority. Exact clone URL ownership is an alternative location signal only for the named owner view; it does not weaken base-repository maintainer authorization or GRASP-06 /prs/ scoping. Excluded scope: This does not delete unresolved refs, repair malformed State HEAD tags, change storage migration, tag v3, or alter deployment configuration. Validation: cargo fmt --check; git diff --check. Compilation and production behavior will be validated through the required remote Nix deployments. --- src/git/authorization_integrity.rs | 166 ++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/src/git/authorization_integrity.rs b/src/git/authorization_integrity.rs index 6e67fb9..526b72d 100644 --- a/src/git/authorization_integrity.rs +++ b/src/git/authorization_integrity.rs @@ -10,7 +10,8 @@ use std::path::Path; use std::process::Command; use anyhow::{anyhow, Context, Result}; -use nostr_sdk::prelude::{Event, EventId, FromBech32, PublicKey, ToBech32}; +use nostr::types::url::Url; +use nostr_sdk::prelude::{Event, EventId, FromBech32, Kind, PublicKey, ToBech32}; use tracing::{error, info, warn}; use super::authorization::{compute_membership, extract_commit_tag, RepositoryData}; @@ -705,12 +706,18 @@ fn event_applies_to_view( service_address: Option<&str>, ) -> bool { match identity { - ViewIdentity::Owner { .. } => { + ViewIdentity::Owner { pubkey } => { let tagged = tagged_owners(event, identifier); maintainers.is_some_and(|maintainers| { maintainers .iter() .any(|maintainer| tagged.contains(maintainer)) + }) || service_address.is_some_and(|service| { + standard_clone_coordinates(event, service).iter().any( + |(clone_owner, clone_identifier)| { + clone_owner == pubkey && clone_identifier == identifier + }, + ) }) } ViewIdentity::Prs { submitter } => { @@ -758,9 +765,73 @@ fn event_relevant_to_identifier( crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(event, domain) .iter() .any(|candidate| candidate == identifier) + || standard_clone_coordinates(event, domain) + .iter() + .any(|(_, candidate)| candidate == identifier) }) } +/// Return exact owner-view coordinates named by a PR event's standard clone URLs. +/// +/// The configured service may include a mount path. URLs must use HTTP(S), +/// match that authority and mount exactly, contain no credentials/query/fragment, +/// and end at the canonical `//.git` repository root. +fn standard_clone_coordinates(event: &Event, service: &str) -> Vec<(PublicKey, String)> { + if !matches!( + event.kind, + Kind::GitPullRequest | Kind::GitPullRequestUpdate + ) { + return Vec::new(); + } + + let mut coordinates = Vec::new(); + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(String::as_str) != Some("clone") { + continue; + } + for raw_url in values.iter().skip(1) { + if let Some(coordinate) = standard_clone_coordinate(raw_url, service) { + if !coordinates.contains(&coordinate) { + coordinates.push(coordinate); + } + } + } + } + coordinates +} + +fn standard_clone_coordinate(raw_url: &str, service: &str) -> Option<(PublicKey, String)> { + let url = Url::parse(raw_url).ok()?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !crate::outbound::url_matches_service_domain(raw_url, service) + { + return None; + } + + let configured = service.trim().trim_end_matches('/'); + let configured = if configured.contains("://") { + configured.to_owned() + } else { + format!("http://{configured}") + }; + let service_url = Url::parse(&configured).ok()?; + let service_path = service_url.path().trim_end_matches('/'); + let repository_path = if service_path.is_empty() { + url.path() + } else { + url.path() + .strip_prefix(service_path) + .filter(|suffix| suffix.is_empty() || suffix.starts_with('/'))? + }; + let (npub, identifier) = super::parse_repository_root_url(repository_path)?; + Some((PublicKey::from_bech32(&npub).ok()?, identifier)) +} + fn placeholder_applies_to_view( entry: &PrPurgatoryEntry, identity: &ViewIdentity, @@ -1502,4 +1573,95 @@ mod tests { ); assert_eq!(expectation.pr_refs.len(), 1); } + + #[test] + fn owner_view_accepts_only_its_exact_standard_clone_endpoint() { + let source_owner = Keys::generate(); + let target_owner = Keys::generate(); + let other_owner = Keys::generate(); + let source_npub = source_owner.public_key().to_bech32().unwrap(); + let other_npub = other_owner.public_key().to_bech32().unwrap(); + let identity = ViewIdentity::Owner { + pubkey: source_owner.public_key(), + }; + let maintainers = vec![source_owner.public_key().to_hex()]; + let target = format!( + "30617:{}:upstream-project", + target_owner.public_key().to_hex() + ); + let matching_url = format!("https://relay.example/grasp/{source_npub}/source-project.git"); + let event_with_clone = |url: String| { + EventBuilder::new(Kind::GitPullRequest, "") + .tags([ + Tag::custom("a", [target.clone()]), + Tag::custom("c", ["1".repeat(40)]), + Tag::custom("clone", [url]), + ]) + .finalize(&source_owner) + .unwrap() + }; + + let matching = event_with_clone(matching_url.clone()); + assert!(event_applies_to_view( + &matching, + &identity, + "source-project", + Some(&maintainers), + Some("relay.example/grasp"), + )); + assert!(event_relevant_to_identifier( + &matching, + "source-project", + Some("relay.example/grasp"), + )); + + let rejected_urls = [ + format!("https://relay.example/grasp/{other_npub}/source-project.git"), + format!("https://relay.example/grasp/{source_npub}/other-project.git"), + format!("https://other.example/grasp/{source_npub}/source-project.git"), + format!("{matching_url}?download=1"), + format!("{matching_url}#fragment"), + format!("{matching_url}/info/refs"), + format!("https://relay.example/grasp/prs/{source_npub}/source-project.git"), + ]; + for url in rejected_urls { + let event = event_with_clone(url.clone()); + assert!( + !event_applies_to_view( + &event, + &identity, + "source-project", + Some(&maintainers), + Some("relay.example/grasp"), + ), + "unexpectedly accepted {url}" + ); + } + } + + #[test] + fn owner_view_retains_base_repository_a_tag_authorization() { + let owner = Keys::generate(); + let submitter = Keys::generate(); + let event = EventBuilder::new(Kind::GitPullRequestUpdate, "") + .tags([ + Tag::custom( + "a", + [format!("30617:{}:project", owner.public_key().to_hex())], + ), + Tag::custom("c", ["2".repeat(40)]), + ]) + .finalize(&submitter) + .unwrap(); + + assert!(event_applies_to_view( + &event, + &ViewIdentity::Owner { + pubkey: owner.public_key(), + }, + "project", + Some(&[owner.public_key().to_hex()]), + Some("relay.example"), + )); + } }