fix(grasp06): reject /prs/ push when event lacks clone tag naming this relay

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/<author>/<id>.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.
This commit is contained in:
DanConwayDev
2026-05-16 08:41:56 +00:00
parent 0c7938f830
commit d4ff5a3ba9
4 changed files with 51 additions and 12 deletions
+37 -2
View File
@@ -1163,9 +1163,13 @@ pub fn extract_commit_tag(event: &Event) -> Option<String> {
///
/// 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:<hex>:<d>` where `d ==
/// identifier`.
/// identifier`, AND
/// - at least one `clone` tag naming this relay's
/// `/prs/<submitter-npub>/<identifier>.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 `/<npub>/<id>.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<String> {
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
+1 -1
View File
@@ -112,7 +112,7 @@ fn collect_a_tag_d_values(event: &Event) -> Vec<String> {
/// - `<npub-segment>` decodes via [`PublicKey::from_bech32`] to `signer`,
/// - the percent-decoded part of `<repo-segment>` 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,
+11 -9
View File
@@ -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<Response<Full<Bytes>>, 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",
+2
View File
@@ -211,6 +211,7 @@ impl Service<Request<Incoming>> 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<Request<Incoming>> for HttpService {
&git_data_path,
git_protocol.as_deref(),
repo_init_locks.clone(),
&config_clone.domain,
)
.await;
if let Some(ref m) = metrics_clone {