From d4ff5a3ba923e28471c694fb5a9c8ec20b5b3080 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 16 May 2026 08:02:55 +0000 Subject: [PATCH] fix(grasp06): reject /prs/ push when event lacks clone tag naming this relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last refactor introduced PrsUrlConstraints to share pre-validation between the standard and /prs/ endpoints, but omitted the clone-tag check from describe_known_event_mismatch. This meant a push to /prs//.git for an event already in the DB would be accepted even if the event's clone tag did not name this relay's /prs/ endpoint — defeating the opt-in guard that prevents every GRASP-06 relay from becoming an unsolicited mirror for every PR event on the network. Fix: add domain to PrsUrlConstraints and check the event's clone tag in describe_known_event_mismatch using the existing clone_url_names_relays_prs_endpoint helper (made pub). Thread domain through handle_prs_receive_pack and post_push_validate. --- src/git/authorization.rs | 39 +++++++++++++++++++++++++++++++++++++-- src/grasp06/policy.rs | 2 +- src/grasp06/receive.rs | 20 +++++++++++--------- src/http/mod.rs | 2 ++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 90b8761..1a27bab 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -1163,9 +1163,13 @@ pub fn extract_commit_tag(event: &Event) -> Option { /// /// When present, a known event found in the DB or purgatory MUST have: /// -/// - `event.pubkey == submitter`, and +/// - `event.pubkey == submitter`, /// - at least one `a`-tag of the form `30617::` where `d == -/// identifier`. +/// identifier`, AND +/// - at least one `clone` tag naming this relay's +/// `/prs//.git` endpoint (the opt-in +/// signal that prevents every GRASP-06 relay from accepting every PR +/// event that happens to match its URL shape). /// /// Standard `//.git` pushes pass `None` here — they rely on the /// surrounding `authorize_push` flow to gate by the maintainer set @@ -1174,6 +1178,8 @@ pub fn extract_commit_tag(event: &Event) -> Option { pub struct PrsUrlConstraints<'a> { pub submitter: &'a PublicKey, pub identifier: &'a str, + /// The relay's own domain (host[:port]) used to verify the `clone` tag. + pub domain: &'a str, } /// Outcome of [`pre_validate_refs_nostr_push`] for one ref. @@ -1360,6 +1366,35 @@ fn describe_known_event_mismatch( } None => return Some("has no parsable a-tag identifier".to_string()), } + // The event must explicitly opt in to this relay's /prs/ endpoint via + // a `clone` tag. Without this check any PR event whose signer and + // identifier happen to match the URL could be pushed here, turning + // every GRASP-06 relay into an unsolicited mirror for every PR event + // on the network. + let d_tags = vec![prs.identifier.to_string()]; + let has_clone_tag = event.tags.iter().any(|tag| { + let parts = tag.clone().to_vec(); + if parts.first().map(String::as_str) != Some("clone") { + return false; + } + parts.iter().skip(1).any(|url| { + crate::grasp06::policy::clone_url_names_relays_prs_endpoint( + url, + prs.domain, + prs.submitter, + &d_tags, + ) + }) + }); + if !has_clone_tag { + return Some(format!( + "has no `clone` tag naming this relay's /prs/{}/{}.git endpoint", + prs.submitter + .to_bech32() + .unwrap_or_else(|_| prs.submitter.to_hex()), + prs.identifier, + )); + } } None diff --git a/src/grasp06/policy.rs b/src/grasp06/policy.rs index 9dfc4bf..17d0f66 100644 --- a/src/grasp06/policy.rs +++ b/src/grasp06/policy.rs @@ -112,7 +112,7 @@ fn collect_a_tag_d_values(event: &Event) -> Vec { /// - `` decodes via [`PublicKey::from_bech32`] to `signer`, /// - the percent-decoded part of `` before `.git` matches one /// of `d_tags`. -fn clone_url_names_relays_prs_endpoint( +pub fn clone_url_names_relays_prs_endpoint( url: &str, domain: &str, signer: &PublicKey, diff --git a/src/grasp06/receive.rs b/src/grasp06/receive.rs index fcc5d3c..2c06d1a 100644 --- a/src/grasp06/receive.rs +++ b/src/grasp06/receive.rs @@ -152,6 +152,7 @@ pub async fn handle_prs_receive_pack( git_data_path: &str, git_protocol: Option<&str>, repo_init_locks: RepoInitLocks, + domain: &str, ) -> Result>, GitError> { // 1. Pre-scan refs and reject the whole push if any ref name is not // `refs/nostr/<64-lowercase-hex>`. We use the same parser as the @@ -196,6 +197,7 @@ pub async fn handle_prs_receive_pack( let prs_constraints = PrsUrlConstraints { submitter: &prs.submitter, identifier: &prs.identifier, + domain, }; for (_, new_oid, ref_name) in &pushed_refs { match pre_validate_refs_nostr_push( @@ -274,6 +276,11 @@ pub async fn handle_prs_receive_pack( // event yet — and creates a scoped placeholder so the // 30-minute purgatory sweep can clean it up if the event // never arrives. + let post_push_constraints = PrsUrlConstraints { + submitter: &prs.submitter, + identifier: &prs.identifier, + domain, + }; for (_, new_oid, ref_name) in &pushed_refs { let event_id_hex = ref_name .strip_prefix("refs/nostr/") @@ -282,7 +289,7 @@ pub async fn handle_prs_receive_pack( &database, &purgatory, &repo_path, - prs, + post_push_constraints, event_id_hex, new_oid, ref_name, @@ -518,16 +525,11 @@ async fn post_push_validate( database: &SharedDatabase, purgatory: &Purgatory, repo_path: &Path, - prs: &PrsUrl, + prs_constraints: PrsUrlConstraints<'_>, event_id_hex: &str, pushed_commit: &str, ref_name: &str, ) { - let prs_constraints = PrsUrlConstraints { - submitter: &prs.submitter, - identifier: &prs.identifier, - }; - match pre_validate_refs_nostr_push( database, purgatory, @@ -567,8 +569,8 @@ async fn post_push_validate( purgatory.add_prs_pr_placeholder( event_id_hex.to_string(), pushed_commit.to_string(), - prs.submitter, - prs.identifier.clone(), + *prs_constraints.submitter, + prs_constraints.identifier.to_string(), ); debug!( "/prs/ post-push: added scoped PR placeholder for {} awaiting matching event", diff --git a/src/http/mod.rs b/src/http/mod.rs index 15a5a60..8218f12 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -211,6 +211,7 @@ impl Service> for HttpService { let method_clone = method.clone(); let metrics_clone = self.metrics.clone(); let relay_clone = self.relay.clone(); + let config_clone = self.config.clone(); return Box::pin(async move { // Collect (and gunzip if needed) the request body just like @@ -300,6 +301,7 @@ impl Service> for HttpService { &git_data_path, git_protocol.as_deref(), repo_init_locks.clone(), + &config_clone.domain, ) .await; if let Some(ref m) = metrics_clone {