feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,377 @@
//! Regression test for the NIP-43 relay-admin durable-ban bypass
//! (BUZZ-SEC-007 class, reported 2026-07-27).
//!
//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable
//! write-path restriction gate so a *timed out* admin keeps its administrative
//! capability. That exemption was ban-blind, so a **banned** admin could still
//! add/remove relay members and change the workspace icon via signed NIP-98
//! `POST /events`. The ban is now enforced inside
//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that
//! contract — bans refused, timeouts still admitted.
//!
//! Requires a running relay and its Postgres. Ignored by default:
//! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \
//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \
//! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \
//! -- --ignored --nocapture
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use nostr::{EventBuilder, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
use uuid::Uuid;
fn http_base() -> String {
std::env::var("REPRO_RELAY_HTTP").unwrap_or_else(|_| "http://localhost:3999".into())
}
fn host() -> String {
std::env::var("REPRO_HOST").unwrap_or_else(|_| "localhost:3999".into())
}
fn db_url() -> String {
std::env::var("DATABASE_URL").expect("DATABASE_URL required")
}
fn sha256_hex(b: &[u8]) -> String {
hex::encode(Sha256::digest(b))
}
fn nip98(keys: &Keys, url: &str, body: &str) -> String {
let ev = EventBuilder::new(Kind::Custom(27_235), "")
.tags(vec![
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", "POST"]).unwrap(),
Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(),
Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
format!(
"Nostr {}",
BASE64.encode(serde_json::to_string(&ev).unwrap())
)
}
async fn post_event(keys: &Keys, event: &nostr::Event) -> (u16, String) {
let body = serde_json::to_string(event).unwrap();
let signed_url = format!("http://{}/events", host());
let r = reqwest::Client::new()
.post(format!("{}/events", http_base()))
.header("Host", host())
.header("Content-Type", "application/json")
.header("Authorization", nip98(keys, &signed_url, &body))
.body(body)
.send()
.await
.expect("POST /events");
let status = r.status().as_u16();
(status, r.text().await.unwrap_or_default())
}
fn signed(keys: &Keys, kind: u16, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::Custom(kind), "")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
async fn pool() -> sqlx::Pool<sqlx::Postgres> {
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db_url())
.await
.expect("connect Postgres")
}
async fn community_id(p: &sqlx::Pool<sqlx::Postgres>) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO communities (id, host) VALUES ($1, $2) ON CONFLICT (lower(host)) DO NOTHING",
)
.bind(id)
.bind(host())
.execute(p)
.await
.unwrap();
sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)")
.bind(host())
.fetch_one(p)
.await
.unwrap()
}
async fn seed(p: &sqlx::Pool<sqlx::Postgres>, cid: Uuid, keys: &Keys, role: &str) {
sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(cid)
.bind(keys.public_key().to_bytes().to_vec())
.execute(p)
.await
.ok();
sqlx::query(
"INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1,$2,$3,NULL) \
ON CONFLICT (community_id, pubkey) DO UPDATE SET role = $3, updated_at = now()",
)
.bind(cid)
.bind(keys.public_key().to_hex())
.bind(role)
.execute(p)
.await
.unwrap();
}
/// Post-fix regression bar.
///
/// Asserts the full contract rather than just "the exploit stopped":
/// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a
/// banned *owner* likewise on 9032 (owner-only kind), covering all four
/// exempt kinds,
/// - no roster, role, or icon mutation from any of those attempts,
/// - a *timed-out* admin still reaches relay-admin authorization (the ingest
/// exemption's whole purpose — the fix must not silently widen to timeouts),
/// - an unrestricted admin's behaviour is unchanged, mutation included.
#[tokio::test]
#[ignore]
async fn banned_admin_is_refused_but_timed_out_admin_still_administers() {
let p = pool().await;
let cid = community_id(&p).await;
let owner = Keys::generate();
let banned_owner = Keys::generate();
let banned_admin = Keys::generate();
let timed_out_admin = Keys::generate();
let good_admin = Keys::generate();
let victim = Keys::generate();
let victim2 = Keys::generate();
let victim3 = Keys::generate();
let role_target = Keys::generate();
// Retained (not generated inline) so the 9030 attempt can be checked for
// absence afterward — a planted member is the mutation that attempt buys.
let planted = Keys::generate();
for (k, r) in [
(&owner, "owner"),
(&banned_owner, "owner"),
(&banned_admin, "admin"),
(&timed_out_admin, "admin"),
(&good_admin, "admin"),
(&victim, "member"),
(&victim2, "member"),
(&victim3, "member"),
(&role_target, "member"),
] {
seed(&p, cid, k, r).await;
}
// Owner bans one admin and times out another, through the real 9040/9042
// command path.
let (s, _) = post_event(
&owner,
&signed(
&owner,
9040,
vec![Tag::parse(["p", &banned_admin.public_key().to_hex()]).unwrap()],
),
)
.await;
assert_eq!(s, 200, "ban must land");
let expiry = (chrono_now() + 3600).to_string();
let (s, b) = post_event(
&owner,
&signed(
&owner,
9042,
vec![
Tag::parse(["p", &timed_out_admin.public_key().to_hex()]).unwrap(),
Tag::parse(["expiration", &expiry]).unwrap(),
],
),
)
.await;
assert_eq!(s, 200, "timeout must land: {b}");
// 9032 is owner-only, so its banned case needs a banned *owner*. Whether
// one owner may 9040 another is a moderation-policy question independent of
// this fix, so the ban row is seeded directly to keep the test pinned to
// the admission gate.
sqlx::query(
"INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \
VALUES ($1,$2,true,$3) \
ON CONFLICT (community_id, pubkey) DO UPDATE SET banned = true",
)
.bind(cid)
.bind(banned_owner.public_key().to_bytes().to_vec())
.bind(owner.public_key().to_bytes().to_vec())
.execute(&p)
.await
.unwrap();
// ── Banned actors: every relay-admin kind must be 403 + `blocked:`. ──
for (actor, kind, tags, label) in [
(
&banned_admin,
9031u16,
vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()],
"9031 remove",
),
(
&banned_admin,
9030u16,
vec![
Tag::parse(["p", &planted.public_key().to_hex()]).unwrap(),
Tag::parse(["role", "member"]).unwrap(),
],
"9030 add",
),
(
&banned_admin,
9033u16,
vec![Tag::parse(["icon", "https://evil.example/pwned.png"]).unwrap()],
"9033 icon",
),
(
&banned_owner,
9032u16,
vec![
Tag::parse(["p", &role_target.public_key().to_hex()]).unwrap(),
Tag::parse(["role", "admin"]).unwrap(),
],
"9032 change role",
),
] {
let (st, body) = post_event(actor, &signed(actor, kind, tags)).await;
println!("[banned] {label} -> {st} {body}");
assert_eq!(
st, 403,
"{label}: banned actor must get 403, got {st} {body}"
);
let msg: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
let text = msg.get("error").and_then(|v| v.as_str()).unwrap_or(&body);
assert_eq!(
text, "blocked: you are banned from this community",
"{label}: must carry the exact `blocked:` wire contract"
);
}
// No mutation from any banned attempt.
let role_of = |k: &Keys| {
let hex = k.public_key().to_hex();
let p = p.clone();
async move {
sqlx::query_scalar::<_, String>(
"SELECT role FROM relay_members WHERE community_id=$1 AND pubkey=$2",
)
.bind(cid)
.bind(hex)
.fetch_optional(&p)
.await
.unwrap()
}
};
assert_eq!(
role_of(&victim).await.as_deref(),
Some("member"),
"9031: banned admin must not remove a member"
);
assert_eq!(
role_of(&planted).await,
None,
"9030: banned admin must not plant a new member"
);
assert_eq!(
role_of(&role_target).await.as_deref(),
Some("member"),
"9032: banned owner must not change a member's role"
);
let icon: Option<String> = sqlx::query_scalar("SELECT icon FROM communities WHERE id=$1")
.bind(cid)
.fetch_one(&p)
.await
.unwrap();
assert!(
icon.is_none(),
"9033: banned admin must not change the workspace icon, got {icon:?}"
);
// ── Timed-out admin: still administers (ingest exemption preserved). ──
let (ts, tb) = post_event(
&timed_out_admin,
&signed(
&timed_out_admin,
9031,
vec![Tag::parse(["p", &victim2.public_key().to_hex()]).unwrap()],
),
)
.await;
println!("[timed-out] 9031 remove -> {ts} {tb}");
assert_eq!(
ts, 200,
"timed-out admin must still administer the roster: {tb}"
);
assert_eq!(
role_of(&victim2).await,
None,
"timed-out admin's removal must take effect"
);
// Control: the same timed-out admin is still write-blocked for content.
let (cs, cb) = post_event(
&timed_out_admin,
&EventBuilder::new(Kind::Custom(9), "x")
.tags(vec![Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()])
.sign_with_keys(&timed_out_admin)
.unwrap(),
)
.await;
println!("[timed-out] control kind:9 -> {cs} {cb}");
assert_ne!(
cs, 200,
"timed-out admin must still be write-blocked for content"
);
// ── Unrestricted admin: behaviour unchanged, mutation included. ──
let (gs, gb) = post_event(
&good_admin,
&signed(
&good_admin,
9031,
vec![Tag::parse(["p", &victim3.public_key().to_hex()]).unwrap()],
),
)
.await;
println!("[clean] 9031 remove -> {gs} {gb}");
assert_eq!(gs, 200, "unrestricted admin must be unaffected: {gb}");
assert_eq!(
role_of(&victim3).await,
None,
"unrestricted admin's removal must actually take effect"
);
// ── Unchanged rejection contract: non-admin still gets `invalid:`/400. ──
let nobody = Keys::generate();
seed(&p, cid, &nobody, "member").await;
let (ns, nb) = post_event(
&nobody,
&signed(
&nobody,
9031,
vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()],
),
)
.await;
println!("[non-admin] 9031 -> {ns} {nb}");
assert_eq!(
ns, 400,
"a plain member's 9031 must stay a 400 validation reject"
);
assert!(
nb.contains("invalid: actor not authorized"),
"non-admin rejection must keep its `invalid:` prefix, got {nb}"
);
println!("\nALL INVARIANTS HELD");
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}