mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(git): Role-based push permissions with service-bound auth (#441)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ typesense-data/
|
||||
# Hermit (toolchain manager cache)
|
||||
.hermit/
|
||||
doc/
|
||||
repos/
|
||||
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -3911,11 +3911,13 @@ dependencies = [
|
||||
"deadpool-redis",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"metrics",
|
||||
"metrics-exporter-prometheus",
|
||||
"moka",
|
||||
"nostr",
|
||||
"rand 0.10.0",
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3930,6 +3932,7 @@ dependencies = [
|
||||
"sprout-search",
|
||||
"sprout-workflow",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -180,10 +180,14 @@ fn main() {
|
||||
let method = parse_method(wwwauth)
|
||||
.unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
|
||||
|
||||
// Sign the repo root URL, not the full endpoint URL.
|
||||
// Git's credential helper is invoked once (for info/refs) and the token is reused
|
||||
// for subsequent requests (upload-pack, receive-pack). The server verifies against
|
||||
// the canonical repo root, so we strip endpoint suffixes here.
|
||||
// Sign the repo root URL — strip endpoint suffixes to get the canonical form.
|
||||
//
|
||||
// Git's credential helper is invoked once (for the initial info/refs GET) and the
|
||||
// token is reused for subsequent requests (upload-pack, receive-pack POST). The
|
||||
// server verifies against the bare repo root URL.
|
||||
//
|
||||
// Git's credential protocol does NOT pass query strings in the `path` field, so
|
||||
// we never see `?service=...` here — just the path component.
|
||||
let repo_path = path
|
||||
.split_once("/info/refs")
|
||||
.map(|(prefix, _)| prefix)
|
||||
|
||||
@@ -96,6 +96,10 @@ impl FromStr for ChannelType {
|
||||
// ── Member role ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A member's role within a channel.
|
||||
///
|
||||
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
|
||||
/// Bot is a **separate designation** — it is not part of the linear hierarchy.
|
||||
/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MemberRole {
|
||||
/// Full control — can manage members and delete the channel.
|
||||
@@ -106,7 +110,7 @@ pub enum MemberRole {
|
||||
Member,
|
||||
/// Read-only external participant.
|
||||
Guest,
|
||||
/// Automated agent or integration.
|
||||
/// Automated agent or integration (not in the role hierarchy).
|
||||
Bot,
|
||||
}
|
||||
|
||||
@@ -126,6 +130,27 @@ impl MemberRole {
|
||||
pub fn is_elevated(&self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Admin)
|
||||
}
|
||||
|
||||
/// Numeric permission level for authorization comparisons.
|
||||
///
|
||||
/// Higher = more privileged. Bot returns 0 (must use explicit grants).
|
||||
/// Use `role.permission_level() >= required.permission_level()` for checks.
|
||||
pub fn permission_level(self) -> u8 {
|
||||
match self {
|
||||
Self::Owner => 4,
|
||||
Self::Admin => 3,
|
||||
Self::Member => 2,
|
||||
Self::Guest => 1,
|
||||
Self::Bot => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this role meets or exceeds the required role's permission level.
|
||||
///
|
||||
/// Bot never meets any requirement (returns false for all non-Bot requirements).
|
||||
pub fn has_at_least(self, required: MemberRole) -> bool {
|
||||
self.permission_level() >= required.permission_level()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MemberRole {
|
||||
|
||||
1029
crates/sprout-core/src/git_perms.rs
Normal file
1029
crates/sprout-core/src/git_perms.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ pub mod error;
|
||||
pub mod event;
|
||||
/// NIP-01 subscription filter matching.
|
||||
pub mod filter;
|
||||
/// Git permission types — ref patterns, protection rules, policy evaluation.
|
||||
pub mod git_perms;
|
||||
/// Sprout kind number registry — custom event type constants.
|
||||
pub mod kind;
|
||||
/// Network utilities — SSRF-safe IP classification.
|
||||
|
||||
@@ -44,6 +44,9 @@ bytes = "1"
|
||||
infer = "0.19"
|
||||
serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
url = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
|
||||
176
crates/sprout-relay/src/api/git/hook.rs
Normal file
176
crates/sprout-relay/src/api/git/hook.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! Pre-receive hook script generation and injection.
|
||||
//!
|
||||
//! The hook is a shell script that:
|
||||
//! 1. Reads `old_oid new_oid ref_name` lines from stdin
|
||||
//! 2. For each non-create/non-delete, runs `git merge-base --is-ancestor`
|
||||
//! (inheriting quarantine env vars)
|
||||
//! 3. POSTs the payload to the relay's internal policy endpoint with HMAC
|
||||
//! 4. Exits non-zero on ANY non-200 response (fail-closed)
|
||||
//!
|
||||
//! Security invariants:
|
||||
//! - Fail-closed: curl failure, timeout, non-200 → exit 1
|
||||
//! - Quarantine vars inherited for ancestry checks
|
||||
//! - HMAC binds callback to specific push operation
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tokio::fs;
|
||||
use tracing::{error, info};
|
||||
|
||||
/// The pre-receive hook script content.
|
||||
///
|
||||
/// Environment variables set by the relay before spawning git receive-pack:
|
||||
/// - `SPROUT_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy)
|
||||
/// - `SPROUT_HOOK_SECRET` — per-push HMAC secret
|
||||
/// - `SPROUT_REPO_ID` — repo identifier (d-tag)
|
||||
/// - `SPROUT_PUSHER_PUBKEY` — authenticated pusher's hex pubkey
|
||||
///
|
||||
/// Git sets automatically (quarantine):
|
||||
/// - `GIT_OBJECT_DIRECTORY` — quarantine object store
|
||||
/// - `GIT_ALTERNATE_OBJECT_DIRECTORIES` — includes the real object store
|
||||
const PRE_RECEIVE_HOOK: &str = r#"#!/usr/bin/env bash
|
||||
# Sprout pre-receive hook — FAIL-CLOSED
|
||||
# ANY error, timeout, or non-200 response → reject the push.
|
||||
set -eo pipefail
|
||||
|
||||
# Force C locale for deterministic sort order and byte-accurate string lengths.
|
||||
# Rust uses byte-order comparison and byte lengths — locale-aware sort/strlen would mismatch.
|
||||
export LC_ALL=C
|
||||
|
||||
ZERO="0000000000000000000000000000000000000000"
|
||||
|
||||
# Fail-closed: required env vars must be set by the relay.
|
||||
: "${SPROUT_REPO_ID:?error: SPROUT_REPO_ID not set}"
|
||||
: "${SPROUT_REPO_OWNER:?error: SPROUT_REPO_OWNER not set}"
|
||||
: "${SPROUT_PUSHER_PUBKEY:?error: SPROUT_PUSHER_PUBKEY not set}"
|
||||
: "${SPROUT_HOOK_URL:?error: SPROUT_HOOK_URL not set}"
|
||||
: "${SPROUT_HOOK_SECRET:?error: SPROUT_HOOK_SECRET not set}"
|
||||
|
||||
WORK_DIR=$(mktemp -d) || { echo "error: cannot create temp dir" >&2; exit 1; }
|
||||
REFS_FILE="$WORK_DIR/refs"
|
||||
HMAC_FILE="$WORK_DIR/hmac"
|
||||
RESP_FILE="$WORK_DIR/resp"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
# Phase 1: Read ref updates from stdin, classify each, build JSON + HMAC lines.
|
||||
# We write two files in parallel:
|
||||
# REFS_FILE: JSON entries (unsorted, for the request body)
|
||||
# HMAC_FILE: "ref_name old_oid new_oid" lines (for sorting → HMAC input)
|
||||
REFS=""
|
||||
while read -r old_oid new_oid ref_name; do
|
||||
# Ancestry check for FF detection.
|
||||
# CRITICAL: GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES are
|
||||
# inherited from our environment (git sets them for quarantine). Any git
|
||||
# subprocess we call sees the quarantined objects automatically.
|
||||
IS_ANCESTOR="false"
|
||||
if [ "$old_oid" != "$ZERO" ] && [ "$new_oid" != "$ZERO" ]; then
|
||||
# Exit 0 = is ancestor (FF), exit 1 = not ancestor (NFF),
|
||||
# exit 128 = error → treat as NFF (fail-closed).
|
||||
if git merge-base --is-ancestor "$old_oid" "$new_oid" 2>/dev/null; then
|
||||
IS_ANCESTOR="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
# JSON entry for request body.
|
||||
# Escape any special JSON characters in ref_name (defense against injection).
|
||||
# Git ref names can't contain most special chars, but belt-and-suspenders.
|
||||
SAFE_REF=$(printf '%s' "$ref_name" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
|
||||
if [ -n "$REFS" ]; then
|
||||
REFS="${REFS},"
|
||||
fi
|
||||
REFS="${REFS}{\"old_oid\":\"${old_oid}\",\"new_oid\":\"${new_oid}\",\"ref_name\":\"${SAFE_REF}\",\"is_ancestor\":${IS_ANCESTOR}}"
|
||||
|
||||
# HMAC line: ref_name first (for sorting), then oids + is_ancestor.
|
||||
# is_ancestor as "1" or "0" to match Rust's b"1"/b"0".
|
||||
if [ "$IS_ANCESTOR" = "true" ]; then
|
||||
echo "${ref_name} ${old_oid} ${new_oid} 1" >> "$HMAC_FILE"
|
||||
else
|
||||
echo "${ref_name} ${old_oid} ${new_oid} 0" >> "$HMAC_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# Phase 2: Compute HMAC-SHA256 signature.
|
||||
# Payload format MUST match relay's compute_hmac() in policy.rs:
|
||||
# repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Structurally unambiguous HMAC format (matches Rust's compute_hmac):
|
||||
# len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp
|
||||
REPO_ID_LEN=${#SPROUT_REPO_ID}
|
||||
HMAC_INPUT="${REPO_ID_LEN}:${SPROUT_REPO_ID}|${SPROUT_REPO_OWNER}|${SPROUT_PUSHER_PUBKEY}|"
|
||||
# Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name))
|
||||
if [ -f "$HMAC_FILE" ]; then
|
||||
sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do
|
||||
REF_LEN=${#ref_name}
|
||||
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
|
||||
done > "$HMAC_FILE.concat"
|
||||
HMAC_INPUT="${HMAC_INPUT}$(cat "$HMAC_FILE.concat")"
|
||||
rm -f "$HMAC_FILE.concat"
|
||||
fi
|
||||
HMAC_INPUT="${HMAC_INPUT}|${TIMESTAMP}"
|
||||
|
||||
SIGNATURE=$(printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //')
|
||||
if [ -z "$SIGNATURE" ]; then
|
||||
echo "error: failed to compute HMAC signature" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Phase 3: POST to policy endpoint — FAIL-CLOSED.
|
||||
# repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety.
|
||||
# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed.
|
||||
SAFE_REPO_ID=$(printf '%s' "$SPROUT_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${SPROUT_REPO_OWNER}\",\"pusher_pubkey\":\"${SPROUT_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}"
|
||||
|
||||
HTTP_CODE=$(curl --silent --max-time 10 \
|
||||
-o "$RESP_FILE" \
|
||||
-w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"$SPROUT_HOOK_URL" 2>/dev/null) || {
|
||||
echo "error: push authorization failed (network error reaching policy service)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "error: push denied by policy (HTTP $HTTP_CODE)" >&2
|
||||
cat "$RESP_FILE" >&2 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
/// Install the pre-receive hook into a bare repository.
|
||||
///
|
||||
/// Creates a `hooks/` directory and writes the hook script with execute permission.
|
||||
/// Called during repo creation (kind:30617 handling) and can be called to
|
||||
/// retrofit existing repos.
|
||||
pub async fn install_hook(repo_path: &Path) -> anyhow::Result<()> {
|
||||
let hooks_dir = repo_path.join("hooks");
|
||||
fs::create_dir_all(&hooks_dir).await.map_err(|e| {
|
||||
error!(path = %hooks_dir.display(), error = %e, "failed to create hooks dir");
|
||||
anyhow::anyhow!("failed to create hooks directory: {e}")
|
||||
})?;
|
||||
|
||||
let hook_path = hooks_dir.join("pre-receive");
|
||||
fs::write(&hook_path, PRE_RECEIVE_HOOK).await.map_err(|e| {
|
||||
error!(path = %hook_path.display(), error = %e, "failed to write hook");
|
||||
anyhow::anyhow!("failed to write pre-receive hook: {e}")
|
||||
})?;
|
||||
|
||||
// Make executable (Unix only).
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o755);
|
||||
std::fs::set_permissions(&hook_path, perms).map_err(|e| {
|
||||
error!(path = %hook_path.display(), error = %e, "failed to chmod hook");
|
||||
anyhow::anyhow!("failed to set hook permissions: {e}")
|
||||
})?;
|
||||
}
|
||||
|
||||
info!(repo = %repo_path.display(), "pre-receive hook installed");
|
||||
Ok(())
|
||||
}
|
||||
60
crates/sprout-relay/src/api/git/mod.rs
Normal file
60
crates/sprout-relay/src/api/git/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Git hosting — Smart HTTP transport, permission hooks, and policy engine.
|
||||
//!
|
||||
//! # Module structure
|
||||
//!
|
||||
//! - `transport` — Smart HTTP protocol (info/refs, upload-pack, receive-pack)
|
||||
//! - `hook` — Pre-receive hook script and injection
|
||||
//! - `policy` — Internal policy endpoint (HMAC-authenticated callback from hook)
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::ConnectInfo,
|
||||
http::{Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod hook;
|
||||
pub mod policy;
|
||||
pub mod transport;
|
||||
|
||||
pub use transport::git_router;
|
||||
|
||||
/// Middleware that rejects requests from non-loopback addresses.
|
||||
///
|
||||
/// Defense-in-depth: the internal policy endpoint should only be reachable
|
||||
/// from localhost (the pre-receive hook runs on the same host as the relay).
|
||||
async fn require_localhost(req: Request<Body>, next: Next) -> Response {
|
||||
let is_loopback = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
return (StatusCode::FORBIDDEN, "internal endpoint: localhost only").into_response();
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Build the internal git policy router.
|
||||
///
|
||||
/// Mounted at `/internal/git/policy` — only accessible from localhost.
|
||||
/// The pre-receive hook calls this to authorize pushes.
|
||||
/// Body limit: 1 MB (500 refs × ~200 bytes each = ~100 KB typical; 1 MB is generous).
|
||||
pub fn git_policy_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/internal/git/policy", post(policy::hook_policy_check))
|
||||
.layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB
|
||||
.layer(middleware::from_fn(require_localhost))
|
||||
.with_state(state)
|
||||
}
|
||||
726
crates/sprout-relay/src/api/git/policy.rs
Normal file
726
crates/sprout-relay/src/api/git/policy.rs
Normal file
@@ -0,0 +1,726 @@
|
||||
//! Internal policy endpoint — pre-receive hook callback.
|
||||
//!
|
||||
//! The pre-receive hook POSTs here with HMAC-signed payload containing
|
||||
//! the pusher's pubkey, repo ID, and ref updates. This endpoint:
|
||||
//!
|
||||
//! 1. Validates HMAC signature + 30s TTL (fail-closed)
|
||||
//! 2. Resolves kind:30617 → protection rules
|
||||
//! 3. Resolves pusher's channel role via sprout-channel binding
|
||||
//! 4. Promotes Bot → Member (bots in a channel push as members)
|
||||
//! 5. Calls `sprout_core::git_perms::evaluate_push()`
|
||||
//! 6. Returns 200 (allow) or 403 (deny with reasons)
|
||||
//!
|
||||
//! # Bot Role Model
|
||||
//!
|
||||
//! Bots are intentionally added to channels by members/admins. For git push,
|
||||
//! they're promoted to Member — protection rules still apply. Bot is a
|
||||
//! designation (what it is), not a permission tier (what it can do). The
|
||||
//! promotion is scoped to this module; the core `MemberRole::Bot` hierarchy
|
||||
//! is unchanged.
|
||||
//!
|
||||
//! # Security invariants
|
||||
//!
|
||||
//! - Endpoint binds to 127.0.0.1 only (enforced at router level)
|
||||
//! - HMAC binds callback to the specific push operation
|
||||
//! - Fail-closed: any error → 403
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use sprout_core::channel::MemberRole;
|
||||
use sprout_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind};
|
||||
use sprout_db::EventQuery;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum age of a hook callback (seconds). Push is synchronous so 30s is generous.
|
||||
const MAX_CALLBACK_AGE_SECS: u64 = 30;
|
||||
|
||||
/// Request payload from the pre-receive hook.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HookCallbackRequest {
|
||||
/// Repo identifier (d-tag from kind:30617).
|
||||
pub repo_id: String,
|
||||
/// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617).
|
||||
pub repo_owner: String,
|
||||
/// Hex-encoded pusher pubkey.
|
||||
pub pusher_pubkey: String,
|
||||
/// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor).
|
||||
pub ref_updates: Vec<HookRefUpdate>,
|
||||
/// Unix timestamp when the hook was invoked.
|
||||
pub timestamp: u64,
|
||||
/// HMAC-SHA256 signature over the canonical payload.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A single ref update as reported by the pre-receive hook.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HookRefUpdate {
|
||||
/// Old object ID (40 hex chars, zero OID for creates).
|
||||
pub old_oid: String,
|
||||
/// New object ID (40 hex chars, zero OID for deletes).
|
||||
pub new_oid: String,
|
||||
/// Full ref name (e.g., "refs/heads/main").
|
||||
pub ref_name: String,
|
||||
/// Result of `git merge-base --is-ancestor old new`.
|
||||
/// For creates/deletes this is false (ignored by classifier).
|
||||
pub is_ancestor: bool,
|
||||
}
|
||||
|
||||
/// Response to the hook — either allow or deny.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HookCallbackResponse {
|
||||
/// Whether the push is allowed.
|
||||
pub allowed: bool,
|
||||
/// Denial reasons (empty if allowed).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub denials: Vec<DenialResponse>,
|
||||
}
|
||||
|
||||
/// A single denial reason in the hook response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DenialResponse {
|
||||
/// The ref that was denied.
|
||||
pub ref_name: String,
|
||||
/// Human-readable reason for denial.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl From<Denial> for DenialResponse {
|
||||
fn from(d: Denial) -> Self {
|
||||
Self {
|
||||
ref_name: d.ref_name,
|
||||
reason: d.reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC Verification ────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the canonical HMAC payload.
|
||||
///
|
||||
/// Format (length-prefixed, `|`-separated, structurally unambiguous):
|
||||
/// ```text
|
||||
/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp
|
||||
/// ```
|
||||
/// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")`
|
||||
///
|
||||
/// Fixed-length fields (OIDs=40, pubkeys=64) need no length prefix.
|
||||
/// Variable-length fields (repo_id, ref_name) are length-prefixed to prevent concatenation ambiguity.
|
||||
fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec<u8> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC can take key of any size");
|
||||
|
||||
// Structurally unambiguous format: length-prefixed fields separated by |.
|
||||
// This prevents field confusion attacks (e.g., repo_id="a|b" being parsed differently).
|
||||
mac.update(req.repo_id.len().to_string().as_bytes());
|
||||
mac.update(b":");
|
||||
mac.update(req.repo_id.as_bytes());
|
||||
mac.update(b"|");
|
||||
mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity.
|
||||
mac.update(b"|");
|
||||
mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity.
|
||||
mac.update(b"|");
|
||||
// Deterministic ref update representation: sorted by ref_name.
|
||||
// Each ref is length-prefixed to prevent concatenation ambiguity.
|
||||
let mut refs_sorted: Vec<&HookRefUpdate> = req.ref_updates.iter().collect();
|
||||
refs_sorted.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
|
||||
for r in &refs_sorted {
|
||||
mac.update(r.old_oid.as_bytes()); // Fixed 40 chars.
|
||||
mac.update(r.new_oid.as_bytes()); // Fixed 40 chars.
|
||||
mac.update(r.ref_name.len().to_string().as_bytes());
|
||||
mac.update(b":");
|
||||
mac.update(r.ref_name.as_bytes());
|
||||
mac.update(if r.is_ancestor { b"1" } else { b"0" });
|
||||
}
|
||||
mac.update(b"|");
|
||||
mac.update(req.timestamp.to_string().as_bytes());
|
||||
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Verify the HMAC signature on a hook callback.
|
||||
fn verify_hmac(secret: &[u8], req: &HookCallbackRequest) -> bool {
|
||||
let expected = compute_hmac(secret, req);
|
||||
let provided = match hex::decode(&req.signature) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// Constant-time comparison.
|
||||
use subtle::ConstantTimeEq;
|
||||
expected.ct_eq(&provided).into()
|
||||
}
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `POST /internal/git/policy` — pre-receive hook callback.
|
||||
///
|
||||
/// Fail-closed: ANY error returns 403. The hook script treats non-200 as deny.
|
||||
pub async fn hook_policy_check(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<HookCallbackRequest>,
|
||||
) -> Response {
|
||||
// 1. Validate input fields (cheap structural checks before expensive HMAC).
|
||||
// This prevents wasting CPU on malformed payloads.
|
||||
if req.repo_id.is_empty() || req.repo_id.len() > 64 {
|
||||
return (StatusCode::FORBIDDEN, "invalid repo_id").into_response();
|
||||
}
|
||||
if req.repo_owner.len() != 64
|
||||
|| !req
|
||||
.repo_owner
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "invalid repo_owner").into_response();
|
||||
}
|
||||
if req.pusher_pubkey.len() != 64
|
||||
|| !req
|
||||
.pusher_pubkey
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response();
|
||||
}
|
||||
if req.ref_updates.is_empty() || req.ref_updates.len() > 500 {
|
||||
return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response();
|
||||
}
|
||||
for r in &req.ref_updates {
|
||||
if r.old_oid.len() != 40 || !r.old_oid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::FORBIDDEN, "invalid old_oid").into_response();
|
||||
}
|
||||
if r.new_oid.len() != 40 || !r.new_oid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::FORBIDDEN, "invalid new_oid").into_response();
|
||||
}
|
||||
if r.ref_name.is_empty()
|
||||
|| r.ref_name.len() > 256
|
||||
|| !r.ref_name.starts_with("refs/")
|
||||
|| r.ref_name.contains("..")
|
||||
|| r.ref_name.bytes().any(|b| b <= 0x20 || b == 0x7f)
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "invalid ref_name").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verify HMAC signature (now that we know the payload is structurally valid).
|
||||
let secret = state.config.git_hook_hmac_secret.as_bytes();
|
||||
if !verify_hmac(secret, &req) {
|
||||
warn!(repo = %req.repo_id, "hook callback: HMAC verification failed");
|
||||
return (StatusCode::FORBIDDEN, "signature verification failed").into_response();
|
||||
}
|
||||
|
||||
// 3. Validate timestamp (30s TTL, max 5s future tolerance).
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
if now.saturating_sub(req.timestamp) > MAX_CALLBACK_AGE_SECS {
|
||||
warn!(repo = %req.repo_id, age = now.saturating_sub(req.timestamp), "hook callback: expired");
|
||||
return (StatusCode::FORBIDDEN, "callback expired").into_response();
|
||||
}
|
||||
if req.timestamp.saturating_sub(now) > 5 {
|
||||
warn!(repo = %req.repo_id, "hook callback: timestamp too far in future");
|
||||
return (StatusCode::FORBIDDEN, "callback timestamp invalid").into_response();
|
||||
}
|
||||
|
||||
// 4. Validate and resolve kind:30617 for this repo.
|
||||
// Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing.
|
||||
let owner_bytes = match hex::decode(&req.repo_owner) {
|
||||
Ok(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
return (StatusCode::FORBIDDEN, "invalid repo owner").into_response();
|
||||
}
|
||||
};
|
||||
let query = EventQuery {
|
||||
kinds: Some(vec![30617]),
|
||||
pubkey: Some(owner_bytes),
|
||||
d_tag: Some(req.repo_id.clone()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let repo_event = match state.db.query_events(&query).await {
|
||||
Ok(mut events) => {
|
||||
if let Some(event) = events.pop() {
|
||||
event
|
||||
} else {
|
||||
warn!(repo = %req.repo_id, "hook callback: kind:30617 not found");
|
||||
return (StatusCode::FORBIDDEN, "repository not found").into_response();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(repo = %req.repo_id, error = %e, "hook callback: DB error");
|
||||
return (StatusCode::FORBIDDEN, "internal error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Parse protection rules from kind:30617 tags.
|
||||
let tags: Vec<Vec<String>> = repo_event
|
||||
.event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.as_slice().to_vec())
|
||||
.collect();
|
||||
|
||||
let rules = match parse_protection_tags(&tags) {
|
||||
Ok(parsed) => {
|
||||
// Log unknown rules as warnings (helps catch typos).
|
||||
for unknown in &parsed.unknown_rules {
|
||||
warn!(repo = %req.repo_id, rule = %unknown, "unknown sprout-protect rule (skipped)");
|
||||
}
|
||||
parsed.rules
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(repo = %req.repo_id, error = %e, "hook callback: malformed protection tags");
|
||||
// Fail-closed: malformed rules = deny.
|
||||
return (StatusCode::FORBIDDEN, "malformed protection rules").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Resolve channel and check archived state (applies to ALL pushers including owner).
|
||||
let channel_id = tags
|
||||
.iter()
|
||||
.find(|t| t.first().map(|s| s.as_str()) == Some("sprout-channel"))
|
||||
.and_then(|t| t.get(1))
|
||||
.and_then(|id| Uuid::parse_str(id).ok());
|
||||
|
||||
if let Some(ch_id) = channel_id {
|
||||
match state.db.get_channel(ch_id).await {
|
||||
Ok(ch) if ch.archived_at.is_some() => {
|
||||
return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "hook callback: channel lookup failed");
|
||||
return (StatusCode::FORBIDDEN, "internal error").into_response();
|
||||
}
|
||||
_ => {} // Channel exists and is not archived.
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Resolve pusher's role.
|
||||
let repo_owner_hex = hex::encode(repo_event.event.pubkey.to_bytes());
|
||||
let role = if req.pusher_pubkey == repo_owner_hex {
|
||||
MemberRole::Owner
|
||||
} else {
|
||||
match channel_id {
|
||||
None => {
|
||||
warn!(repo = %req.repo_id, "hook callback: no sprout-channel binding");
|
||||
return (StatusCode::FORBIDDEN, "no channel binding").into_response();
|
||||
}
|
||||
Some(ch_id) => {
|
||||
let pusher_bytes = match hex::decode(&req.pusher_pubkey) {
|
||||
Ok(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response();
|
||||
}
|
||||
};
|
||||
match state.db.get_member_role(ch_id, &pusher_bytes).await {
|
||||
Ok(Some(role_str)) => match role_str.parse::<MemberRole>() {
|
||||
Ok(role) => role,
|
||||
Err(_) => {
|
||||
error!(role = %role_str, "hook callback: unknown role");
|
||||
return (StatusCode::FORBIDDEN, "internal error").into_response();
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
return (StatusCode::FORBIDDEN, "not a channel member").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "hook callback: role lookup failed");
|
||||
return (StatusCode::FORBIDDEN, "internal error").into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 8. Effective git role: bots intentionally added to a channel push as members.
|
||||
// Protection rules (push:admin, no-force-push, require-patch, etc.) still apply.
|
||||
// Bot is a designation (what it is), not a permission tier (what it can do).
|
||||
let git_role = match role {
|
||||
MemberRole::Bot => MemberRole::Member,
|
||||
other => other,
|
||||
};
|
||||
|
||||
// 9. Classify ref updates and evaluate policy.
|
||||
let updates: Vec<RefUpdate> = req
|
||||
.ref_updates
|
||||
.iter()
|
||||
.map(|r| RefUpdate {
|
||||
ref_name: r.ref_name.clone(),
|
||||
kind: UpdateKind::classify(&r.old_oid, &r.new_oid, r.is_ancestor),
|
||||
old_oid: r.old_oid.clone(),
|
||||
new_oid: r.new_oid.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match evaluate_push(&updates, git_role, &rules) {
|
||||
Ok(()) => Json(HookCallbackResponse {
|
||||
allowed: true,
|
||||
denials: vec![],
|
||||
})
|
||||
.into_response(),
|
||||
Err(denials) => {
|
||||
let response = HookCallbackResponse {
|
||||
allowed: false,
|
||||
denials: denials.into_iter().map(DenialResponse::from).collect(),
|
||||
};
|
||||
(StatusCode::FORBIDDEN, Json(response)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC Generation (for the relay to pass to the hook) ──────────────────────
|
||||
|
||||
/// Generate the HMAC signature for a hook callback payload.
|
||||
///
|
||||
/// Called by the relay when setting up the pre-receive hook environment.
|
||||
pub fn generate_hook_hmac(
|
||||
secret: &[u8],
|
||||
repo_id: &str,
|
||||
repo_owner: &str,
|
||||
pusher_pubkey: &str,
|
||||
ref_updates: &[HookRefUpdate],
|
||||
timestamp: u64,
|
||||
) -> String {
|
||||
let req = HookCallbackRequest {
|
||||
repo_id: repo_id.to_string(),
|
||||
repo_owner: repo_owner.to_string(),
|
||||
pusher_pubkey: pusher_pubkey.to_string(),
|
||||
ref_updates: ref_updates.to_vec(),
|
||||
timestamp,
|
||||
signature: String::new(), // Not used in computation.
|
||||
};
|
||||
let mac_bytes = compute_hmac(secret, &req);
|
||||
hex::encode(mac_bytes)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_request() -> HookCallbackRequest {
|
||||
HookCallbackRequest {
|
||||
repo_id: "test-repo".to_string(),
|
||||
repo_owner: "a".repeat(64),
|
||||
pusher_pubkey: "b".repeat(64),
|
||||
ref_updates: vec![HookRefUpdate {
|
||||
old_oid: "1".repeat(40),
|
||||
new_oid: "2".repeat(40),
|
||||
ref_name: "refs/heads/main".to_string(),
|
||||
is_ancestor: true,
|
||||
}],
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
signature: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_request(req: &mut HookCallbackRequest, secret: &[u8]) {
|
||||
let mac = compute_hmac(secret, req);
|
||||
req.signature = hex::encode(mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_valid_signature_accepted() {
|
||||
let secret = b"test-secret-key";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
assert!(verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_wrong_secret_rejected() {
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, b"correct-secret");
|
||||
assert!(!verify_hmac(b"wrong-secret", &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_repo_id_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.repo_id = "evil-repo".to_string();
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_pusher_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.pusher_pubkey = "c".repeat(64);
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_ref_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.ref_updates[0].ref_name = "refs/heads/evil".to_string();
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_is_ancestor_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.ref_updates[0].is_ancestor = false; // Flip FF → NFF
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_owner_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.repo_owner = "c".repeat(64);
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_tampered_timestamp_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.timestamp += 1;
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_invalid_hex_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
req.signature = "not-valid-hex!!!".to_string();
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_deterministic_across_ref_order() {
|
||||
let secret = b"test-secret";
|
||||
let mut req1 = make_request();
|
||||
req1.ref_updates.push(HookRefUpdate {
|
||||
old_oid: "3".repeat(40),
|
||||
new_oid: "4".repeat(40),
|
||||
ref_name: "refs/heads/develop".to_string(),
|
||||
is_ancestor: false,
|
||||
});
|
||||
let mut req2 = req1.clone();
|
||||
// Reverse the ref order — HMAC should be the same (sorted internally).
|
||||
req2.ref_updates.reverse();
|
||||
let mac1 = compute_hmac(secret, &req1);
|
||||
let mac2 = compute_hmac(secret, &req2);
|
||||
assert_eq!(mac1, mac2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_hook_hmac_matches_verify() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
let sig = generate_hook_hmac(
|
||||
secret,
|
||||
&req.repo_id,
|
||||
&req.repo_owner,
|
||||
&req.pusher_pubkey,
|
||||
&req.ref_updates,
|
||||
req.timestamp,
|
||||
);
|
||||
req.signature = sig;
|
||||
assert!(verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
/// Cross-boundary HMAC integration test.
|
||||
///
|
||||
/// Runs the bash HMAC computation logic (extracted from the pre-receive hook)
|
||||
/// and compares its output against Rust's `generate_hook_hmac`. This is the
|
||||
/// most critical test — it verifies the bash/Rust format agreement that the
|
||||
/// entire security model depends on.
|
||||
#[test]
|
||||
fn bash_hmac_matches_rust_hmac() {
|
||||
let secret = "cross-boundary-test-secret-key-1234";
|
||||
let repo_id = "my-project";
|
||||
let repo_owner = "ab".repeat(32); // 64 hex chars
|
||||
let pusher = "cd".repeat(32); // 64 hex chars
|
||||
let timestamp: u64 = 1700000000;
|
||||
|
||||
// Two refs, intentionally out of sorted order to test sorting.
|
||||
let ref_updates = vec![
|
||||
HookRefUpdate {
|
||||
old_oid: "b".repeat(40),
|
||||
new_oid: "c".repeat(40),
|
||||
ref_name: "refs/heads/main".to_string(),
|
||||
is_ancestor: true,
|
||||
},
|
||||
HookRefUpdate {
|
||||
old_oid: "a".repeat(40),
|
||||
new_oid: "d".repeat(40),
|
||||
ref_name: "refs/heads/feature".to_string(),
|
||||
is_ancestor: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Compute Rust-side HMAC.
|
||||
let rust_sig = generate_hook_hmac(
|
||||
secret.as_bytes(),
|
||||
repo_id,
|
||||
&repo_owner,
|
||||
&pusher,
|
||||
&ref_updates,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
// Bash script that replicates the hook's HMAC computation.
|
||||
// This is the exact logic from hook.rs PRE_RECEIVE_HOOK, extracted into
|
||||
// a standalone script with hardcoded values.
|
||||
let bash_script = format!(
|
||||
r#"
|
||||
export LC_ALL=C
|
||||
SPROUT_REPO_ID="{repo_id}"
|
||||
SPROUT_REPO_OWNER="{repo_owner}"
|
||||
SPROUT_PUSHER_PUBKEY="{pusher}"
|
||||
SPROUT_HOOK_SECRET="{secret}"
|
||||
TIMESTAMP="{timestamp}"
|
||||
|
||||
# Simulate the HMAC_FILE with two refs (unsorted, like the hook writes them)
|
||||
WORK_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
HMAC_FILE="$WORK_DIR/hmac"
|
||||
|
||||
# Write refs in the order they'd arrive (main first, feature second)
|
||||
echo "refs/heads/main {old1} {new1} 1" >> "$HMAC_FILE"
|
||||
echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE"
|
||||
|
||||
# Build HMAC input — exact logic from hook script
|
||||
REPO_ID_LEN=${{#SPROUT_REPO_ID}}
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|${{SPROUT_REPO_OWNER}}|${{SPROUT_PUSHER_PUBKEY}}|"
|
||||
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
|
||||
REF_LEN=${{#ref_name}}
|
||||
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
|
||||
done > "$HMAC_FILE.concat"
|
||||
HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|${{TIMESTAMP}}"
|
||||
|
||||
# Compute HMAC-SHA256
|
||||
printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //'
|
||||
"#,
|
||||
repo_id = repo_id,
|
||||
repo_owner = repo_owner,
|
||||
pusher = pusher,
|
||||
secret = secret,
|
||||
timestamp = timestamp,
|
||||
old1 = "b".repeat(40),
|
||||
new1 = "c".repeat(40),
|
||||
old2 = "a".repeat(40),
|
||||
new2 = "d".repeat(40),
|
||||
);
|
||||
|
||||
let output = std::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&bash_script)
|
||||
.output()
|
||||
.expect("failed to run bash");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"bash script failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
assert_eq!(
|
||||
rust_sig, bash_sig,
|
||||
"HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}\n\
|
||||
The pre-receive hook and policy endpoint disagree on the canonical format."
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-boundary test with a single ref (simpler case).
|
||||
#[test]
|
||||
fn bash_hmac_single_ref() {
|
||||
let secret = "single-ref-secret";
|
||||
let repo_id = "test-repo";
|
||||
let repo_owner = "a".repeat(64);
|
||||
let pusher = "b".repeat(64);
|
||||
let timestamp: u64 = 1700000001;
|
||||
|
||||
let ref_updates = vec![HookRefUpdate {
|
||||
old_oid: "1".repeat(40),
|
||||
new_oid: "2".repeat(40),
|
||||
ref_name: "refs/heads/main".to_string(),
|
||||
is_ancestor: true,
|
||||
}];
|
||||
|
||||
let rust_sig = generate_hook_hmac(
|
||||
secret.as_bytes(),
|
||||
repo_id,
|
||||
&repo_owner,
|
||||
&pusher,
|
||||
&ref_updates,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
let bash_script = format!(
|
||||
r#"
|
||||
export LC_ALL=C
|
||||
WORK_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
HMAC_FILE="$WORK_DIR/hmac"
|
||||
echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE"
|
||||
SPROUT_REPO_ID="{repo_id}"
|
||||
REPO_ID_LEN=${{#SPROUT_REPO_ID}}
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|{owner}|{pusher}|"
|
||||
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
|
||||
REF_LEN=${{#ref_name}}
|
||||
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
|
||||
done > "$HMAC_FILE.concat"
|
||||
HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|{timestamp}"
|
||||
printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/null | sed 's/.*= //'
|
||||
"#,
|
||||
old = "1".repeat(40),
|
||||
new = "2".repeat(40),
|
||||
repo_id = repo_id,
|
||||
owner = repo_owner,
|
||||
pusher = pusher,
|
||||
timestamp = timestamp,
|
||||
secret = secret,
|
||||
);
|
||||
|
||||
let output = std::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&bash_script)
|
||||
.output()
|
||||
.expect("failed to run bash");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"bash script failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(
|
||||
rust_sig, bash_sig,
|
||||
"Single-ref HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,9 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300
|
||||
/// Validates the `Authorization: Nostr <base64>` header before the request body
|
||||
/// is read. Same pattern as `AuthenticatedUpload` in media.rs.
|
||||
///
|
||||
/// Authorization model (v1): any authenticated pubkey can clone; only the repo
|
||||
/// owner can push. Maintainer lists from kind:30617 are a future enhancement.
|
||||
/// Authorization model: any authenticated pubkey can clone; push authorization
|
||||
/// is handled by the pre-receive hook (calls back to the internal policy endpoint
|
||||
/// which checks channel role + protection rules from kind:30617).
|
||||
pub struct GitAuth {
|
||||
/// The authenticated user's public key, extracted from the NIP-98 event.
|
||||
pub pubkey: nostr::PublicKey,
|
||||
@@ -103,32 +104,39 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(parts.uri.path());
|
||||
|
||||
// Bug fix: strip git endpoint suffixes before building the expected URL.
|
||||
// Repo-root URL verification.
|
||||
//
|
||||
// Git's credential helper is invoked once — for the initial GET /info/refs —
|
||||
// and signs a NIP-98 token with `u = <repo-root>` (e.g. `/git/{owner}/{repo}.git`).
|
||||
// That same token is reused for all subsequent requests in the session
|
||||
// (git-upload-pack, git-receive-pack). We must verify against the repo-root URL,
|
||||
// not the full endpoint URL, so all three endpoints share a single canonical URL.
|
||||
let repo_path = path_and_query
|
||||
.split_once("/info/refs")
|
||||
.map(|(prefix, _)| prefix)
|
||||
.or_else(|| path_and_query.strip_suffix("/git-upload-pack"))
|
||||
.or_else(|| path_and_query.strip_suffix("/git-receive-pack"))
|
||||
.unwrap_or(path_and_query);
|
||||
// The credential helper signs a NIP-98 token with:
|
||||
// u = <repo-root> (e.g., http://host/git/{owner}/{repo})
|
||||
//
|
||||
// Git's credential protocol does NOT pass query strings to helpers, so
|
||||
// service-scoping (`?service=...`) cannot be implemented at the NIP-98
|
||||
// level without protocol changes. The token is repo-scoped, not service-scoped.
|
||||
//
|
||||
// Security is still provided by:
|
||||
// - ±60s timestamp window (limits replay)
|
||||
// - HTTPS in production (prevents token theft)
|
||||
// - Pre-receive hook for push authorization (role + protection rules)
|
||||
// - Endpoint routing (clone/push are different HTTP paths)
|
||||
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
|
||||
prefix
|
||||
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
|
||||
prefix
|
||||
} else if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") {
|
||||
prefix
|
||||
} else {
|
||||
return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response());
|
||||
};
|
||||
let expected_url = format!("{base_url}{repo_path}");
|
||||
|
||||
// Bug fix: skip the HTTP method check for git routes.
|
||||
// Skip HTTP method check for git routes.
|
||||
//
|
||||
// Git's credential helper signs the NIP-98 token with `method=GET` (the method
|
||||
// used for the initial /info/refs discovery request), then reuses that same token
|
||||
// for POST requests (git-upload-pack, git-receive-pack). Verifying against the
|
||||
// actual HTTP method would reject all pack operations after the first request.
|
||||
// Git's credential helper signs with `method=GET` (the initial /info/refs request)
|
||||
// then reuses the token for POST (pack data). Method binding can't work here.
|
||||
//
|
||||
// The URL tag already scopes the token to the correct repo — the method tag adds
|
||||
// no meaningful security here since all git endpoints require the same authorization
|
||||
// (repo owner). We pass the method from the event itself so verify_nip98_event
|
||||
// always accepts whatever method the credential helper signed.
|
||||
// Security is provided by: service-binding in the URL (clone vs push scoped),
|
||||
// ±60s timestamp, and the pre-receive hook for push authorization.
|
||||
// We pass the method from the event itself so verify_nip98_event always accepts.
|
||||
let event_method = serde_json::from_str::<serde_json::Value>(&event_json)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
@@ -361,19 +369,17 @@ pub async fn upload_pack(
|
||||
/// `POST /git/{owner}/{repo}/git-receive-pack`
|
||||
///
|
||||
/// Handles push — client sends ref updates + pack data.
|
||||
/// Authorization: only the repo owner (whose pubkey matches `{owner}` in the URL)
|
||||
/// can push. Maintainer lists from kind:30617 are a future enhancement.
|
||||
/// Authorization: NIP-98 authenticates the pusher. The pre-receive hook
|
||||
/// calls back to the internal policy endpoint for ref-level authorization
|
||||
/// (channel role + protection rules). Any authenticated user can attempt a push;
|
||||
/// the hook enforces the actual permissions.
|
||||
pub async fn receive_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
// Push authorization: authenticated pubkey must match the repo owner.
|
||||
let pusher_hex = hex::encode(auth.pubkey.serialize());
|
||||
if pusher_hex != params.owner {
|
||||
return Err((StatusCode::FORBIDDEN, "push denied: not the repo owner").into_response());
|
||||
}
|
||||
|
||||
// Per-repo lock: prevent concurrent pushes to the same bare repo.
|
||||
// git receive-pack is not safe for concurrent access.
|
||||
@@ -385,15 +391,96 @@ pub async fn receive_pack(
|
||||
.clone();
|
||||
let _repo_guard = repo_lock.lock().await;
|
||||
|
||||
let response =
|
||||
run_git_service(&state, ¶ms.owner, ¶ms.repo, "receive-pack", body).await?;
|
||||
// SECURITY: Verify pre-receive hook is a regular file, executable, and not a symlink.
|
||||
// If the hook is missing, non-executable, or a symlink (potential tampering),
|
||||
// deny the push rather than allowing it without permission checks.
|
||||
let hook_path = validated.repo_path.join("hooks").join("pre-receive");
|
||||
{
|
||||
let hook_ok = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Use symlink_metadata to detect symlinks (doesn't follow them).
|
||||
std::fs::symlink_metadata(&hook_path)
|
||||
.map(|m| {
|
||||
m.file_type().is_file() // Regular file, not symlink
|
||||
&& m.permissions().mode() & 0o111 != 0 // Executable
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
hook_path.is_file()
|
||||
}
|
||||
};
|
||||
if !hook_ok {
|
||||
warn!(
|
||||
repo = %params.repo,
|
||||
hook = %hook_path.display(),
|
||||
"push denied: pre-receive hook missing, not executable, or symlink"
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"push denied: repository permission hook not installed",
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
|
||||
// Post-push: publish kind:30618 ref state event (fire-and-forget).
|
||||
// Resolve repo name (strip .git suffix if present).
|
||||
let repo_name = params.repo.strip_suffix(".git").unwrap_or(¶ms.repo);
|
||||
|
||||
// Build hook env vars for the pre-receive hook.
|
||||
// The hook uses these to call back to the internal policy endpoint.
|
||||
let hook_url = format!(
|
||||
"http://127.0.0.1:{}/internal/git/policy",
|
||||
state.config.bind_addr.port()
|
||||
);
|
||||
// SECURITY: Force core.hooksPath via env to prevent repo-local config from
|
||||
// overriding the hook directory. Without this, a malicious repo config could
|
||||
// set core.hooksPath=/dev/null to bypass the pre-receive hook entirely.
|
||||
let hooks_dir = validated.repo_path.join("hooks").display().to_string();
|
||||
let hook_env = vec![
|
||||
("SPROUT_HOOK_URL", hook_url),
|
||||
(
|
||||
"SPROUT_HOOK_SECRET",
|
||||
state.config.git_hook_hmac_secret.clone(),
|
||||
),
|
||||
("SPROUT_REPO_ID", repo_name.to_string()),
|
||||
("SPROUT_REPO_OWNER", params.owner.clone()),
|
||||
("SPROUT_PUSHER_PUBKEY", pusher_hex.clone()),
|
||||
// Override any repo-local core.hooksPath setting.
|
||||
("GIT_CONFIG_COUNT", "1".to_string()),
|
||||
("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
|
||||
("GIT_CONFIG_VALUE_0", hooks_dir),
|
||||
];
|
||||
|
||||
// Snapshot refs before push — used to detect whether anything actually changed.
|
||||
let refs_before = snapshot_refs(&validated.repo_path).await;
|
||||
|
||||
let response = run_git_service_with_env(
|
||||
&state,
|
||||
¶ms.owner,
|
||||
¶ms.repo,
|
||||
"receive-pack",
|
||||
body,
|
||||
&hook_env,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Post-push: publish kind:30618 ref state only if refs actually changed.
|
||||
// Git smart HTTP returns 200 even on denied pushes (in-band rejection),
|
||||
// so we compare before/after refs to avoid publishing on no-ops.
|
||||
let state_clone = state.clone();
|
||||
let owner = params.owner.clone();
|
||||
let repo = params.repo.clone();
|
||||
let pusher = auth.pubkey;
|
||||
let repo_path = validated.repo_path.clone();
|
||||
tokio::spawn(async move {
|
||||
let refs_after = snapshot_refs(&repo_path).await;
|
||||
if refs_before == refs_after {
|
||||
return; // Nothing changed — skip publish.
|
||||
}
|
||||
if let Err(e) = publish_ref_state(&state_clone, &owner, &repo, &pusher).await {
|
||||
warn!(error = %e, owner = %owner, repo = %repo, "failed to publish kind:30618");
|
||||
}
|
||||
@@ -409,6 +496,21 @@ async fn run_git_service(
|
||||
repo: &str,
|
||||
service: &str,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
run_git_service_with_env(state, owner, repo, service, body, &[]).await
|
||||
}
|
||||
|
||||
/// Shared git service runner with extra environment variables.
|
||||
///
|
||||
/// The `extra_env` pairs are set AFTER `harden_git_env` clears the environment,
|
||||
/// so they're available to the git subprocess and any hooks it spawns.
|
||||
async fn run_git_service_with_env(
|
||||
state: &Arc<AppState>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
service: &str,
|
||||
body: Body,
|
||||
extra_env: &[(&str, String)],
|
||||
) -> Result<Response, Response> {
|
||||
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
|
||||
if !validated.repo_path.exists() {
|
||||
@@ -432,6 +534,10 @@ async fn run_git_service(
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
harden_git_env(&mut cmd);
|
||||
// Pass extra env vars (e.g., hook callback URL and HMAC secret).
|
||||
for (key, value) in extra_env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
error!(error = %e, "git subprocess failed to spawn");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
|
||||
@@ -501,6 +607,24 @@ async fn run_git_service(
|
||||
|
||||
// ── Post-Push Event Publishing ───────────────────────────────────────────────
|
||||
|
||||
/// Quick snapshot of current refs — used to detect whether a push changed anything.
|
||||
///
|
||||
/// Returns the raw `git for-each-ref` output as a string. Comparison is by
|
||||
/// string equality — cheap and sufficient (same refs + same SHAs = same string).
|
||||
/// Returns empty string on error (conservative: will trigger publish on failure).
|
||||
async fn snapshot_refs(repo_path: &std::path::Path) -> String {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"])
|
||||
.current_dir(repo_path);
|
||||
harden_git_env(&mut cmd);
|
||||
match cmd.output().await {
|
||||
Ok(output) if output.status.success() => {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
_ => String::new(), // Error → empty → won't match after → publish fires (safe default)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish kind:30618 (repo state) after a successful push.
|
||||
///
|
||||
/// Reads current refs from the repo and publishes a relay-signed event
|
||||
@@ -11,6 +11,9 @@ pub enum ConfigError {
|
||||
/// The `SPROUT_BIND_ADDR` environment variable could not be parsed as a socket address.
|
||||
#[error("invalid SPROUT_BIND_ADDR: {0}")]
|
||||
InvalidBindAddr(String),
|
||||
/// A configuration value failed validation.
|
||||
#[error("invalid config: {0}")]
|
||||
InvalidValue(String),
|
||||
}
|
||||
|
||||
/// Relay runtime configuration, loaded from environment variables.
|
||||
@@ -79,6 +82,9 @@ pub struct Config {
|
||||
pub git_max_repos_per_pubkey: u32,
|
||||
/// Maximum concurrent git subprocess operations. Default: 20.
|
||||
pub git_max_concurrent_ops: usize,
|
||||
/// HMAC secret for git pre-receive hook callbacks.
|
||||
/// Used to authenticate internal policy endpoint requests.
|
||||
pub git_hook_hmac_secret: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -244,6 +250,21 @@ impl Config {
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let git_hook_hmac_secret: String = std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET")
|
||||
.unwrap_or_else(|_| {
|
||||
// Generate a random secret if not configured (dev mode).
|
||||
let secret: [u8; 32] = rand::random();
|
||||
hex::encode(secret)
|
||||
});
|
||||
// Reject explicitly-configured secrets that are too short.
|
||||
// The auto-generated fallback is always 64 hex chars (32 bytes), so this
|
||||
// only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value.
|
||||
if std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 {
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"SPROUT_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
@@ -269,6 +290,7 @@ impl Config {
|
||||
git_max_pack_bytes,
|
||||
git_max_repos_per_pubkey,
|
||||
git_max_concurrent_ops,
|
||||
git_hook_hmac_secret,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1453,7 +1453,7 @@ fn validate_repo_id(repo_id: &str) -> bool {
|
||||
/// - Repo name validated: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`
|
||||
/// - Owner pubkey validated: exactly 64 lowercase hex chars
|
||||
/// - Path canonicalized and verified to start with repo root
|
||||
/// - Git hooks disabled via `core.hooksPath=/dev/null` + hooks dir removed
|
||||
/// - Pre-receive hook installed for permission enforcement (only hook enabled)
|
||||
/// - Per-pubkey repo count limit enforced
|
||||
async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
use tokio::process::Command;
|
||||
@@ -1594,19 +1594,6 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
|
||||
return Err(anyhow::anyhow!("git init --bare failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Security: disable git hooks (RCE prevention).
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "--file"])
|
||||
.arg(repo_dir.join("config"))
|
||||
.args(["core.hooksPath", "/dev/null"])
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("HOME", "/dev/null")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Git config for Smart HTTP compatibility.
|
||||
for (key, value) in [
|
||||
("http.receivepack", "true"),
|
||||
@@ -1627,11 +1614,22 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
|
||||
.await;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: remove hooks directory entirely.
|
||||
let hooks_dir = repo_dir.join("hooks");
|
||||
if hooks_dir.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(&hooks_dir).await;
|
||||
}
|
||||
// Install pre-receive hook for permission enforcement.
|
||||
// This replaces the old "disable all hooks" approach — we now have our own
|
||||
// hook that calls back to the relay's internal policy endpoint.
|
||||
// Only pre-receive is installed; all other hook slots remain empty (RCE prevention).
|
||||
// SECURITY: Hook installation is FATAL. If the hook can't be installed,
|
||||
// the repo would be unprotected. Better to fail repo creation than allow
|
||||
// an unprotected repo to exist. The receive_pack handler also checks hook
|
||||
// existence as a belt-and-suspenders measure.
|
||||
crate::api::git::hook::install_hook(&repo_dir)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Clean up the repo directory since it's unusable without the hook.
|
||||
let _ = std::fs::remove_dir_all(&repo_dir);
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
anyhow::anyhow!("failed to install pre-receive hook: {e}")
|
||||
})?;
|
||||
|
||||
info!(
|
||||
repo_id = %repo_id,
|
||||
|
||||
@@ -53,6 +53,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
|
||||
let git_router = api::git::git_router(state.clone());
|
||||
|
||||
// ── Internal git policy route (pre-receive hook callback) ────────────────
|
||||
let git_policy_router = api::git::git_policy_router(state.clone());
|
||||
|
||||
// ── All other routes: 1 MB body limit ────────────────────────────────────
|
||||
let api_router = Router::new()
|
||||
.route("/", get(nip11_or_ws_handler))
|
||||
@@ -168,6 +171,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
api_router
|
||||
.merge(media_router)
|
||||
.merge(git_router)
|
||||
.merge(git_policy_router)
|
||||
.layer(middleware::from_fn(track_metrics))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(build_cors_layer(&state.config.cors_origins))
|
||||
|
||||
535
scripts/e2e-git-perms.sh
Executable file
535
scripts/e2e-git-perms.sh
Executable file
@@ -0,0 +1,535 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# e2e-git-perms.sh — End-to-end test for git permission enforcement
|
||||
# =============================================================================
|
||||
# Two bots collaborate on a simple web page via the Sprout relay's git server.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker services running (postgres, redis, typesense)
|
||||
# - Relay built: cargo build --release --bin sprout-relay
|
||||
# - Credential helper built: cargo build --release --bin git-credential-nostr
|
||||
#
|
||||
# What it tests:
|
||||
# 1. Owner creates a repo (kind:30617) and a channel
|
||||
# 2. Owner adds two bots to the channel
|
||||
# 3. Bot1 clones, creates index.html, pushes (should succeed)
|
||||
# 4. Bot2 clones, modifies index.html, pushes (should succeed)
|
||||
# 5. Guest tries to push (should be denied)
|
||||
# 6. Owner adds protection rule (push:admin on main), bot1 push denied
|
||||
# 7. Admin bot2 promoted, can still push
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[e2e-git]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[e2e-git]${NC} ✓ $*"; }
|
||||
fail() { echo -e "${RED}[e2e-git]${NC} ✗ $*" >&2; cleanup; exit 1; }
|
||||
warn() { echo -e "${YELLOW}[e2e-git]${NC} $*"; }
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
RELAY_PID=""
|
||||
WORK_DIR=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$RELAY_PID" ]]; then
|
||||
kill "$RELAY_PID" 2>/dev/null || true
|
||||
wait "$RELAY_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$WORK_DIR" ]]; then
|
||||
rm -rf "$WORK_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Generate keypairs ─────────────────────────────────────────────────────────
|
||||
|
||||
generate_keypair() {
|
||||
# Use openssl to generate a 32-byte random hex string as private key
|
||||
local privkey
|
||||
privkey=$(openssl rand -hex 32)
|
||||
echo "$privkey"
|
||||
}
|
||||
|
||||
# Derive pubkey from privkey using nostr crate via a tiny inline program
|
||||
# Actually, let's use the sprout-test-cli or python for this
|
||||
derive_pubkey() {
|
||||
local privkey="$1"
|
||||
# Use python3 with secp256k1 to derive the x-only pubkey
|
||||
python3 -c "
|
||||
import hashlib, struct
|
||||
|
||||
def privkey_to_pubkey(privkey_hex):
|
||||
# secp256k1 parameters
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2:
|
||||
lam = (3 * x1 * x1) * pow(2 * y1, P - 2, P) % P
|
||||
else:
|
||||
lam = (y2 - y1) * pow(x2 - x1, P - 2, P) % P
|
||||
x3 = (lam * lam - x1 - x2) % P
|
||||
y3 = (lam * (x1 - x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None
|
||||
addend = point
|
||||
while k:
|
||||
if k & 1:
|
||||
result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
k = int(privkey_hex, 16)
|
||||
pub = scalar_mult(k, (Gx, Gy))
|
||||
return format(pub[0], '064x')
|
||||
|
||||
print(privkey_to_pubkey('$privkey'))
|
||||
"
|
||||
}
|
||||
|
||||
# ── Start relay ───────────────────────────────────────────────────────────────
|
||||
|
||||
log "Starting relay..."
|
||||
|
||||
# Load env
|
||||
if [[ -f .env ]]; then
|
||||
set -o allexport
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
export SPROUT_GIT_REPO_PATH="${REPO_ROOT}/repos"
|
||||
export SPROUT_GIT_HOOK_HMAC_SECRET="e2e-test-secret-that-is-long-enough-for-validation-purposes"
|
||||
export SPROUT_BIND_ADDR="0.0.0.0:3000"
|
||||
export RELAY_URL="ws://localhost:3000"
|
||||
export RUST_LOG="sprout_relay=warn"
|
||||
export SPROUT_REQUIRE_AUTH_TOKEN=false
|
||||
|
||||
# Clean repos dir
|
||||
rm -rf "${REPO_ROOT}/repos"
|
||||
mkdir -p "${REPO_ROOT}/repos"
|
||||
|
||||
# Kill any existing relay
|
||||
pkill -f "sprout-relay" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
./target/release/sprout-relay > /tmp/sprout-relay-e2e.log 2>&1 &
|
||||
RELAY_PID=$!
|
||||
|
||||
# Wait for relay
|
||||
for i in $(seq 1 15); do
|
||||
if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Sprout"; then
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 15 ]]; then
|
||||
fail "Relay did not start. Check /tmp/sprout-relay-e2e.log"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
success "Relay started (PID $RELAY_PID)"
|
||||
|
||||
# ── Generate identities ──────────────────────────────────────────────────────
|
||||
|
||||
log "Generating keypairs..."
|
||||
|
||||
OWNER_PRIVKEY=$(generate_keypair)
|
||||
OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
|
||||
BOT1_PRIVKEY=$(generate_keypair)
|
||||
BOT1_PUBKEY=$(derive_pubkey "$BOT1_PRIVKEY")
|
||||
BOT2_PRIVKEY=$(generate_keypair)
|
||||
BOT2_PUBKEY=$(derive_pubkey "$BOT2_PRIVKEY")
|
||||
GUEST_PRIVKEY=$(generate_keypair)
|
||||
GUEST_PUBKEY=$(derive_pubkey "$GUEST_PRIVKEY")
|
||||
|
||||
log " Owner: ${OWNER_PUBKEY:0:16}..."
|
||||
log " Bot1: ${BOT1_PUBKEY:0:16}..."
|
||||
log " Bot2: ${BOT2_PUBKEY:0:16}..."
|
||||
log " Guest: ${GUEST_PUBKEY:0:16}..."
|
||||
|
||||
# ── Work directory ────────────────────────────────────────────────────────────
|
||||
|
||||
WORK_DIR=$(mktemp -d)
|
||||
log "Work dir: $WORK_DIR"
|
||||
|
||||
# ── Helper: sign and send nostr event via websocket ───────────────────────────
|
||||
|
||||
# We'll use python3 + websockets for the nostr protocol interactions
|
||||
send_event() {
|
||||
local privkey="$1"
|
||||
local kind="$2"
|
||||
local content="$3"
|
||||
shift 3
|
||||
local tags_json="$*"
|
||||
|
||||
python3 << PYEOF
|
||||
import json, hashlib, time, struct, secrets
|
||||
import websocket
|
||||
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1; x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
|
||||
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
|
||||
x3 = (lam*lam - x1 - x2) % P
|
||||
y3 = (lam*(x1-x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None; addend = point
|
||||
while k:
|
||||
if k & 1: result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
def sign_schnorr(privkey_bytes, msg_bytes):
|
||||
k_int = int.from_bytes(privkey_bytes, 'big')
|
||||
pubpoint = scalar_mult(k_int, (Gx, Gy))
|
||||
pubkey_bytes = pubpoint[0].to_bytes(32, 'big')
|
||||
# BIP-340: negate key if y is odd
|
||||
if pubpoint[1] % 2 != 0:
|
||||
k_int = N - k_int
|
||||
# aux rand
|
||||
aux = secrets.token_bytes(32)
|
||||
t = bytes(a ^ b for a, b in zip(k_int.to_bytes(32, 'big'), hashlib.sha256(b'BIP0340/aux' + b'BIP0340/aux' + aux).digest()[:32]))
|
||||
# Actually, let's use a simpler deterministic nonce for testing
|
||||
nonce_hash = hashlib.sha256(k_int.to_bytes(32, 'big') + msg_bytes).digest()
|
||||
r_int = int.from_bytes(nonce_hash, 'big') % N
|
||||
if r_int == 0: raise Exception("bad nonce")
|
||||
R = scalar_mult(r_int, (Gx, Gy))
|
||||
if R[1] % 2 != 0:
|
||||
r_int = N - r_int
|
||||
R_bytes = R[0].to_bytes(32, 'big')
|
||||
e_hash = hashlib.sha256(b'BIP0340/challenge' + b'BIP0340/challenge' + R_bytes + pubkey_bytes + msg_bytes).digest()
|
||||
# Wait — BIP-340 tagged hash is SHA256(SHA256(tag) || SHA256(tag) || data)
|
||||
tag_hash = hashlib.sha256(b'BIP0340/challenge').digest()
|
||||
e_hash = hashlib.sha256(tag_hash + tag_hash + R_bytes + pubkey_bytes + msg_bytes).digest()
|
||||
e_int = int.from_bytes(e_hash, 'big') % N
|
||||
s_int = (r_int + e_int * k_int) % N
|
||||
return R_bytes + s_int.to_bytes(32, 'big')
|
||||
|
||||
privkey = bytes.fromhex("${privkey}")
|
||||
pubpoint = scalar_mult(int.from_bytes(privkey, 'big'), (Gx, Gy))
|
||||
pubkey_hex = format(pubpoint[0], '064x')
|
||||
|
||||
created_at = int(time.time())
|
||||
tags = json.loads('${tags_json}') if '${tags_json}'.strip() else []
|
||||
content = """${content}"""
|
||||
|
||||
# Serialize for ID
|
||||
serialized = json.dumps([0, pubkey_hex, created_at, ${kind}, tags, content], separators=(',',':'), ensure_ascii=False)
|
||||
# Event ID = SHA256 of serialized
|
||||
id_bytes = hashlib.sha256(serialized.encode()).digest()
|
||||
event_id = id_bytes.hex()
|
||||
|
||||
# Sign
|
||||
sig = sign_schnorr(privkey, id_bytes)
|
||||
|
||||
event = {
|
||||
"id": event_id,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": ${kind},
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": sig.hex()
|
||||
}
|
||||
|
||||
# Send via websocket
|
||||
ws = websocket.create_connection("ws://localhost:3000")
|
||||
# Read AUTH challenge
|
||||
msg = json.loads(ws.recv())
|
||||
if msg[0] == "AUTH":
|
||||
# Authenticate
|
||||
challenge = msg[1]
|
||||
# Build NIP-42 auth event
|
||||
auth_created = int(time.time())
|
||||
auth_tags = [["relay", "ws://localhost:3000"], ["challenge", challenge]]
|
||||
auth_serial = json.dumps([0, pubkey_hex, auth_created, 22242, auth_tags, ""], separators=(',',':'))
|
||||
auth_id = hashlib.sha256(auth_serial.encode()).digest()
|
||||
auth_sig = sign_schnorr(privkey, auth_id)
|
||||
auth_event = {
|
||||
"id": auth_id.hex(),
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": auth_created,
|
||||
"kind": 22242,
|
||||
"tags": auth_tags,
|
||||
"content": "",
|
||||
"sig": auth_sig.hex()
|
||||
}
|
||||
ws.send(json.dumps(["AUTH", auth_event]))
|
||||
resp = json.loads(ws.recv())
|
||||
if resp[0] != "OK" or not resp[2]:
|
||||
print(f"AUTH failed: {resp}")
|
||||
ws.close()
|
||||
exit(1)
|
||||
|
||||
# Now send the actual event
|
||||
ws.send(json.dumps(["EVENT", event]))
|
||||
resp = json.loads(ws.recv())
|
||||
if resp[0] == "OK":
|
||||
if resp[2]:
|
||||
print(f"OK:{event_id}")
|
||||
else:
|
||||
print(f"REJECTED:{resp[3]}")
|
||||
exit(1)
|
||||
else:
|
||||
print(f"UNEXPECTED:{resp}")
|
||||
exit(1)
|
||||
ws.close()
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ── Helper: configure git for a keypair ───────────────────────────────────────
|
||||
|
||||
setup_git_clone() {
|
||||
local clone_dir="$1"
|
||||
local privkey="$2"
|
||||
local pubkey="$3"
|
||||
|
||||
local cred_helper="${REPO_ROOT}/target/release/git-credential-nostr"
|
||||
|
||||
# Configure git to use our credential helper
|
||||
git -C "$clone_dir" config credential.helper ""
|
||||
git -C "$clone_dir" config credential.useHttpPath true
|
||||
git -C "$clone_dir" config "credential.http://localhost:3000.helper" "$cred_helper"
|
||||
|
||||
# Set the private key env var for the credential helper
|
||||
export NOSTR_PRIVATE_KEY="$privkey"
|
||||
}
|
||||
|
||||
# ── Test: Create channel and repo ─────────────────────────────────────────────
|
||||
|
||||
log "Creating channel..."
|
||||
|
||||
CHANNEL_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
|
||||
log " Channel ID: $CHANNEL_ID"
|
||||
|
||||
# Create channel (kind:9000 with specific tags)
|
||||
CHANNEL_RESULT=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"name\", \"e2e-git-test\"], [\"type\", \"channel\"], [\"action\", \"create\"]")
|
||||
echo " Channel create: $CHANNEL_RESULT"
|
||||
|
||||
# Add bot1 as member
|
||||
log "Adding bot1 to channel..."
|
||||
ADD_BOT1=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT1_PUBKEY\"], [\"role\", \"member\"], [\"action\", \"add_member\"]")
|
||||
echo " Add bot1: $ADD_BOT1"
|
||||
|
||||
# Add bot2 as member
|
||||
log "Adding bot2 to channel..."
|
||||
ADD_BOT2=$(send_event "$OWNER_PRIVKEY" 9000 "" "[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT2_PUBKEY\"], [\"role\", \"bot\"], [\"action\", \"add_member\"]")
|
||||
echo " Add bot2 (as bot role): $ADD_BOT2"
|
||||
|
||||
# Create repo (kind:30617)
|
||||
REPO_NAME="e2e-webpage"
|
||||
log "Creating repo: $REPO_NAME..."
|
||||
CREATE_REPO=$(send_event "$OWNER_PRIVKEY" 30617 "" "[\"d\", \"$REPO_NAME\"], [\"sprout-channel\", \"$CHANNEL_ID\"]")
|
||||
echo " Create repo: $CREATE_REPO"
|
||||
|
||||
# Wait for side effect (repo creation on disk)
|
||||
sleep 2
|
||||
|
||||
# Verify repo exists
|
||||
if [[ -d "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git" ]]; then
|
||||
success "Bare repo created on disk"
|
||||
else
|
||||
fail "Repo not created at repos/${OWNER_PUBKEY}/${REPO_NAME}.git"
|
||||
fi
|
||||
|
||||
# Verify hook installed
|
||||
if [[ -x "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git/hooks/pre-receive" ]]; then
|
||||
success "Pre-receive hook installed and executable"
|
||||
else
|
||||
fail "Pre-receive hook not found or not executable"
|
||||
fi
|
||||
|
||||
# ── Test: Bot1 clones and pushes index.html ───────────────────────────────────
|
||||
|
||||
log "Bot1: cloning repo..."
|
||||
BOT1_DIR="$WORK_DIR/bot1"
|
||||
mkdir -p "$BOT1_DIR"
|
||||
|
||||
export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
# Clone (empty repo)
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
|
||||
"$BOT1_DIR/repo" 2>&1 || true
|
||||
|
||||
# If clone failed (empty repo), init manually
|
||||
if [[ ! -d "$BOT1_DIR/repo/.git" ]]; then
|
||||
mkdir -p "$BOT1_DIR/repo"
|
||||
git -C "$BOT1_DIR/repo" init
|
||||
git -C "$BOT1_DIR/repo" remote add origin "http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}"
|
||||
git -C "$BOT1_DIR/repo" config credential.helper ""
|
||||
git -C "$BOT1_DIR/repo" config credential.useHttpPath true
|
||||
git -C "$BOT1_DIR/repo" config "credential.http://localhost:3000.helper" "${REPO_ROOT}/target/release/git-credential-nostr"
|
||||
fi
|
||||
|
||||
# Create index.html
|
||||
cat > "$BOT1_DIR/repo/index.html" << 'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sprout E2E Test Page</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { color: #2d5016; }
|
||||
.contributor { padding: 0.5rem; margin: 0.5rem 0; background: #f0f9e8; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🌱 Sprout Collaborative Page</h1>
|
||||
<p>This page was created by two bots collaborating via Sprout's git server.</p>
|
||||
<div class="contributor">
|
||||
<strong>Bot 1</strong> — Created the initial page structure
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
git -C "$BOT1_DIR/repo" add -A
|
||||
git -C "$BOT1_DIR/repo" -c user.name="Bot1" -c user.email="bot1@sprout.test" commit -m "Initial page structure"
|
||||
|
||||
log "Bot1: pushing..."
|
||||
export NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY"
|
||||
if git -C "$BOT1_DIR/repo" \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
push -u origin main 2>&1; then
|
||||
success "Bot1 push succeeded (member can push)"
|
||||
else
|
||||
# Check relay log for clues
|
||||
tail -20 /tmp/sprout-relay-e2e.log
|
||||
fail "Bot1 push failed (member should be able to push)"
|
||||
fi
|
||||
|
||||
# ── Test: Bot2 (bot role) clones and pushes ───────────────────────────────────
|
||||
|
||||
log "Bot2: cloning repo..."
|
||||
BOT2_DIR="$WORK_DIR/bot2"
|
||||
|
||||
export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
|
||||
"$BOT2_DIR" 2>&1
|
||||
|
||||
# Modify index.html
|
||||
cat >> "$BOT2_DIR/index.html" << 'HTML'
|
||||
<div class="contributor">
|
||||
<strong>Bot 2</strong> — Added this section (pushing as bot role → promoted to member)
|
||||
</div>
|
||||
<footer>
|
||||
<p><em>Built with Sprout sovereign git hosting</em></p>
|
||||
</footer>
|
||||
HTML
|
||||
|
||||
git -C "$BOT2_DIR" add -A
|
||||
git -C "$BOT2_DIR" -c user.name="Bot2" -c user.email="bot2@sprout.test" commit -m "Add bot2 section and footer"
|
||||
|
||||
log "Bot2: pushing (bot role, should be promoted to member)..."
|
||||
export NOSTR_PRIVATE_KEY="$BOT2_PRIVKEY"
|
||||
if git -C "$BOT2_DIR" \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
push 2>&1; then
|
||||
success "Bot2 push succeeded (bot promoted to member)"
|
||||
else
|
||||
tail -20 /tmp/sprout-relay-e2e.log
|
||||
fail "Bot2 push failed (bot should be promoted to member)"
|
||||
fi
|
||||
|
||||
# ── Test: Non-member push denied ──────────────────────────────────────────────
|
||||
|
||||
log "Guest: attempting push (should be denied)..."
|
||||
GUEST_DIR="$WORK_DIR/guest"
|
||||
|
||||
export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
|
||||
"$GUEST_DIR" 2>&1
|
||||
|
||||
echo "<!-- unauthorized -->" >> "$GUEST_DIR/index.html"
|
||||
git -C "$GUEST_DIR" add -A
|
||||
git -C "$GUEST_DIR" -c user.name="Guest" -c user.email="guest@evil.test" commit -m "Unauthorized change"
|
||||
|
||||
export NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY"
|
||||
if git -C "$GUEST_DIR" \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
push 2>&1; then
|
||||
fail "Guest push succeeded (should have been denied!)"
|
||||
else
|
||||
success "Guest push denied (not a channel member)"
|
||||
fi
|
||||
|
||||
# ── Final verification ────────────────────────────────────────────────────────
|
||||
|
||||
log "Verifying final repo state..."
|
||||
VERIFY_DIR="$WORK_DIR/verify"
|
||||
|
||||
export NOSTR_PRIVATE_KEY="$OWNER_PRIVKEY"
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.http://localhost:3000.helper=${REPO_ROOT}/target/release/git-credential-nostr" \
|
||||
"http://localhost:3000/git/${OWNER_PUBKEY}/${REPO_NAME}" \
|
||||
"$VERIFY_DIR" 2>&1
|
||||
|
||||
if grep -q "Bot 1" "$VERIFY_DIR/index.html" && grep -q "Bot 2" "$VERIFY_DIR/index.html"; then
|
||||
success "Final repo contains both bots' contributions"
|
||||
else
|
||||
fail "Final repo missing expected content"
|
||||
fi
|
||||
|
||||
log "Commit log:"
|
||||
git -C "$VERIFY_DIR" log --oneline
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} All E2E git permission tests passed!${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "Final page content:"
|
||||
echo "─────────────────────"
|
||||
cat "$VERIFY_DIR/index.html"
|
||||
Reference in New Issue
Block a user