diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index e77df7312cd..4c5e19fe9c3 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -349,6 +349,8 @@ jobs: ON CONFLICT (lower(host)) DO NOTHING ;" - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -547,6 +549,8 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/workflow_sink/)' - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -618,12 +622,16 @@ jobs: merge-multiple: true path: target/ci - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture + cargo test -p buzz-test-client --test regression_relay_admin_ban_gate -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture @@ -636,6 +644,10 @@ jobs: cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + REPRO_RELAY_HTTP: http://localhost:3000 + REPRO_HOST: localhost:3000 + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - name: Media read-auth e2e # Reads require kind:24242 `t=get` auth, so these binaries are the only diff --git a/Justfile b/Justfile index 37cac13a78f..1369299cedf 100644 --- a/Justfile +++ b/Justfile @@ -397,6 +397,8 @@ test-unit: #!/usr/bin/env bash set -euo pipefail ./scripts/test-ensure-local-relay-key.sh + python3 scripts/check-community-ban-route-inventory.py + python3 scripts/check-community-ban-route-inventory.test.py # Shared Huddle transport, codec, and loopback authentication regressions. cargo test -p buzz-audio-client if command -v cargo-nextest &>/dev/null; then diff --git a/TESTING.md b/TESTING.md index fa3a9a3a82d..7e558076730 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,6 +31,94 @@ combination space (PR #6807). Scope Playwright locators — unscoped `getByText` in a required smoke test is a strict-mode flake (PR #6980). (PRs #6807, #6980, #6996, #7013) +### Community-ban route coverage + +`crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv` +classifies the relay's production Axum routes by listener, method, path, +authentication plane, membership behavior, and community-restriction behavior. +The inventory guard finds direct `.route(...)` calls in production functions +named `router` or `*_router`, resolves literal or module-qualified `&str` +constant paths, and applies `.nest(...)` prefixes from `router::build_router`. +It fails when one of those registrations is missing from the table or a stale +row remains, and on any shape it cannot classify: `.route_service`, +`.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed +registrations, each pinned to its builder and exact argument and allowed once), +`.nest` outside `build_router`, a nest target that does not resolve to exactly +one router builder, or a builder nested more than once. New or repeated +`.merge(...)` registrations also fail; existing root aliases and the Git +settings merge are pinned to their exact builder and argument. `scripts/check-community-ban-route-inventory.test.py` +pins each of those shapes with a fixture. Run both directly with: + +```bash +python3 scripts/check-community-ban-route-inventory.py +python3 scripts/check-community-ban-route-inventory.test.py +``` + +`just test-unit` and `just test` run the guard; CI also runs the ignored HTTP +and root-WebSocket behavior matrix in the Relay E2E lane: + +```bash +cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture +``` + +Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that +command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable +PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the +accessory-route cases exercise their production handlers. The matrix creates +unique tenant hosts and identities. Unset `BUZZ_KLIPY_API_KEY`: GIF controls +assert the exact provider-disabled response without contacting a provider. +The malformed JPEG and invalid Git-push repository-name controls assert their +validation responses; Git reads, workflow and media-read controls assert exact +missing-resource responses. Successful reads +and writes must return their expected JSON shape, not merely avoid a ban. +Ordinary-member routes also run a clear delegated-agent control. +Do not point these tests at a shared, development, or hosted database or relay. + +The inventory keeps membership separate from restriction policy. Public NIP-11, +NIP-05, policy documents, and health probes have no authenticated community +principal. Anonymous policy-receipt acceptance is pre-membership and has no +principal to restrict. Invite claim is also pre-membership, but it carries a +verified NIP-98 principal and is classified as restriction-enforced in the +inventory. The integrated invitation regressions cover restricted nonmembers, +verified owners and v1/v2 lifecycle behavior; see +[Invitation compatibility and revocation](#invitation-compatibility-and-revocation) +for the authoritative contract. Relay-operator APIs, deployment-admin APIs, +NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use +separate auth planes; each exemption is recorded in its route row. +Secret-authenticated workflow webhooks recheck the saved workflow owner's +community ban through the workflow engine. Their PostgreSQL regression covers +owner bans, tenant scope and failed lookups. No sibling coverage remains pending. + +Root and huddle admission, final-admission races, fail-closed reads, owner-to- +agent restriction, timeout behavior, and tenant-scoped eviction point to their +existing regression tests in `community-ban-regressions.tsv`. The local socket +eviction tests establish pod-local closure. Delivery to sessions on other pods +depends on the Redis pub/sub path and is not inferred from those local tests. +The moderation-read matrix also checks a moderator-privileged agent against a +fresh signed owner proof and a stored owner link; the bridge harness verifies +that the ban denial uses the NIP-98 signed creation time, with a clear-owner +control that reaches the queue. + +This scanner is a source-level guard, not a Rust router interpreter. It reads +direct `.route(...)` calls inside production functions named `router` or ending +in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a +`.nest(...)` prefix only when the relay's `build_router` passes a literal +or a resolved string constant and a direct call to another recognized router function. It does not +expand macros or interpret dynamic paths, prefixes or arbitrary router +composition. Service-style calls and unsupported nesting or merging are errors, +rather than silently omitted registrations. The existing root merge aliases and +Git settings merge are explicit, single-use exceptions; their builders' direct +routes remain inventoried. It also does not derive auth or +restriction policy from handler code, or prove that the named regression +function contains the expected assertions or is selected by CI. If production +routes use an unrecognized registration shape, extend the scanner and bind +that shape to the inventory before treating the table as complete. + +The method column records explicitly declared methods. Axum's `get(...)` helper +also serves `HEAD` through the GET handler unless an explicit `.head(...)` +handler is attached; the scanner does not add that implicit `HEAD` behavior as a +separate inventory row or exercise it separately. + --- ## Live Local Relay diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10c88e3697c..cecb6c772fd 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2971,13 +2971,13 @@ async fn authorize_moderation_read( None, state.config.require_auth_token || nip_fi_active, ) - .map(|auth| auth.proof(auth.event_id_bytes)) + .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) }) .await?; let pubkey = *admission.proven_pubkey(); let token_owner = admission.token_owner(); - let event_id_bytes = admission.into_extra(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); check_nip98_replay(state, &tenant, event_id_bytes) .await @@ -2990,7 +2990,7 @@ async fn authorize_moderation_read( &tenant, &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), - None, + signed_created_at, token_owner, ) .await @@ -3191,7 +3191,7 @@ mod typing_postgres_tests; #[cfg(test)] pub(crate) mod postgres_tests { use super::*; - use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; + use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag, Timestamp}; use std::sync::Mutex; fn redis_pool() -> deadpool_redis::Pool { @@ -5603,6 +5603,16 @@ pub(crate) mod postgres_tests { url: &str, method: &str, body: &[u8], + ) -> axum::http::HeaderMap { + make_nip98_headers_with_created_at(keys, url, method, body, Timestamp::now().as_secs()) + } + + fn make_nip98_headers_with_created_at( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + created_at: u64, ) -> axum::http::HeaderMap { use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; @@ -5614,6 +5624,7 @@ pub(crate) mod postgres_tests { ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) + .custom_created_at(Timestamp::from(created_at)) .sign_with_keys(keys) .expect("sign NIP-98 event"); let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); @@ -7596,6 +7607,174 @@ pub(crate) mod postgres_tests { ); } + /// A moderator-privileged agent remains subject to its verified NIP-OA + /// owner's ban on moderation reads. The first request has no stored owner + /// link and uses a time-bounded auth tag; the second uses the durable owner + /// link. A clear fresh-owner control reaches the queue. + /// Mutation: discard `signed_created_at` in `authorize_moderation_read` → + /// the fresh-owner ban is missed and the moderator agent receives 200 → RED. + #[test] + #[ignore = "requires Postgres and disposable Redis via REDIS_URL"] + fn moderation_read_honors_fresh_and_stored_banned_agent_owners() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(mut state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres and Redis must be available"); + }; + let mut config = state.config.as_ref().clone(); + config.require_relay_membership = true; + Arc::get_mut(&mut state) + .expect("test state has one owner") + .config = Arc::new(config); + + rt.block_on(async { + let issuer = Keys::generate(); + for scenario in ["fresh-banned-owner", "stored-banned-owner", "fresh-clear-owner"] { + let host = format!( + "moderation-owner-{scenario}-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + let agent = Keys::generate(); + for keys in [&owner, &agent] { + state + .db + .ensure_user(community, keys.public_key().as_bytes()) + .await + .expect("ensure owner and moderator-agent users"); + } + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner as a community member"); + state + .db + .add_relay_member( + community, + &agent.public_key().to_hex(), + "admin", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed agent's own moderator role"); + + let stored_owner = scenario == "stored-banned-owner"; + if stored_owner { + assert!(state + .db + .set_agent_owner_for_authorization( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("store agent owner link")); + } + let linked_owner = state + .db + .get_agent_channel_policy(community, agent.public_key().as_bytes()) + .await + .expect("read agent owner link") + .and_then(|(_, owner)| owner); + assert_eq!( + linked_owner, + stored_owner.then(|| owner.public_key().to_bytes().to_vec()), + "fresh and stored owner variants must be distinct" + ); + + let banned_owner = scenario != "fresh-clear-owner"; + if banned_owner { + state + .db + .ban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + } + + // The signed event is older than the NIP-OA upper bound while + // remaining fresh for NIP-98. Passing `Utc::now()` or dropping + // the signed time would therefore fail to resolve this owner. + let auth_created_at = Timestamp::now().as_secs().saturating_sub(5); + let owner_tag = buzz_sdk::nip_oa::compute_auth_tag( + &owner, + &agent.public_key(), + &format!("created_at<{}", auth_created_at + 1), + ) + .expect("sign time-bounded owner proof"); + let url = format!("https://{host}/moderation/reports"); + let mut headers = make_nip98_headers_with_created_at( + &agent, + &url, + "GET", + b"", + auth_created_at, + ); + if !stored_owner { + headers.insert( + "x-auth-tag", + owner_tag.parse().expect("valid NIP-OA auth header"), + ); + } + + let (status, _, body) = oneshot_request_full( + Arc::clone(&state), + "GET", + "/moderation/reports", + &host, + headers, + b"", + ) + .await; + if banned_owner { + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "{scenario}: an agent with its own admin role must still inherit its owner's ban; body: {body:?}" + ); + let body: serde_json::Value = + serde_json::from_slice(&body).expect("ban response JSON"); + assert_eq!( + body["error"], + "blocked: you are banned from this community", + "{scenario}: denial must come from the ban gate, not the moderator role gate" + ); + } else { + assert_eq!( + status, + axum::http::StatusCode::OK, + "a moderator agent with an unbanned owner must reach the queue" + ); + assert_eq!(body.as_ref(), b"[]", "fresh clear-owner control"); + } + } + }); + } + // ── T2-seam: admitted malformed query through real handler → 400 ───────── // // Thufir's required seam test: one admitted malformed-query request through diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index e4ce733ffde..bd5088c1dda 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15422,6 +15422,136 @@ mod tests { ); } + /// A relay and channel administrator is still subject to a community + /// ban at huddle admission. The member control proves the fixture has + /// working channel admission before the banned administrator is tried. + /// Mutation: bypass the huddle ban gate for admins → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_admin() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, admin) = seed_audio_fixture(state.db.pool()).await; + sqlx::query( + "UPDATE channel_members SET role = 'admin' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(admin.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("promote channel administrator"); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay administrator"); + + let control = nostr::Keys::generate(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) \ + VALUES ($1, $2, $3, 'member')", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(control.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("seed unbanned huddle control"); + let (mut control_socket, control_server) = + open_admitted_audio_socket(&state, tenant.clone(), channel_id, &control, None) + .await; + control_socket + .close(None) + .await + .expect("close allowed huddle control"); + control_server.abort(); + + state + .db + .ban_community_member( + tenant.community(), + &admin.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed administrator ban"); + + let (frames, _) = + run_audio_auth_in(state, tenant, channel_id, None, &admin, true).await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + + /// A delegated agent is refused at huddle admission when its proven + /// NIP-OA owner is community-banned, even though the agent has no + /// direct ban row. The owner is also a channel member so the denial + /// reaches the ban gate rather than failing channel authorization. + /// Mutation: omit the proven owner from the huddle restriction lookup + /// → the agent is admitted → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_owner_agent() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, owner) = seed_audio_fixture(state.db.pool()).await; + let agent = nostr::Keys::generate(); + state + .db + .ban_community_member( + tenant.community(), + &owner.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("sign owner proof"); + let auth_tag: Vec = + serde_json::from_str(&auth_tag).expect("decode auth tag JSON"); + + let (frames, _) = run_audio_auth_tagged( + state, + tenant, + channel_id, + None, + &agent, + true, + Some(auth_tag), + ) + .await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + /// A ban closes a live audio socket: admission binds the socket to its /// pubkey, and the ban's pod-local disconnect /// (`AppState::disconnect_pubkey_local`) closes it with a policy close. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0e44eb8c1e5..ae18c8afc7f 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2734,10 +2734,10 @@ async fn ingest_event_inner( // Community moderation commands (9040–9044) are direct, community-global // mutations. They are never stored or fanned out as ordinary events; the // handler writes the durable audit/enforcement rows after its own capability - // authorization. These commands are intentionally routed before the - // timeout/write-block gate below so a timed-out admin can lift a timeout. - // The handler independently checks the durable ban state before executing - // any command, which also covers NIP-98 and missed live disconnects. + // authorization. The write-restriction gate above permits only unban and + // untimeout commands during an active timeout. The handler independently + // checks durable ban state before executing any command, which also covers + // NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { super::moderation_commands::handle_moderation_command(tenant, state, &event).await?; return Ok(IngestResult { @@ -2939,9 +2939,8 @@ async fn ingest_event_inner( } // Handled directly — these mutate relay_members and do NOT get stored. - // The handler enforces the durable community ban itself: the write-path - // gate above exempts relay-admin kinds so timed-out admins keep their - // administrative capability, which leaves bans to the handler. + // The write-restriction gate above rejects banned and timed-out actors + // before this handler; the handler also checks durable community-ban state. if is_relay_admin_kind(event.kind.as_u16() as u32) { crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) .await diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 16648595f8b..c9db171131f 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -147,12 +147,12 @@ pub(super) enum RelayAdminError { Internal(String), } -/// Decide whether a durable restriction state admits a relay-admin command. +/// Apply the relay-admin handler's local ban check. /// -/// Ban only, deliberately: a timeout is a write-block on *content*, and -/// `ingest_event` exempts relay-admin kinds from its durable write-path gate -/// precisely so restricted-but-not-banned admins retain their administrative -/// capability. Mirrors `moderation_commands::ensure_actor_not_banned`. +/// `ingest_event` runs the shared write-restriction gate before relay-admin +/// dispatch, where active timeouts are rejected. This check independently +/// preserves the handler's durable-ban invariant; passing it does not mean a +/// timed-out command passed the earlier gate. /// /// Split out as a pure function so the admission rule itself is unit-testable /// without a live relay: the end-to-end HTTP test proves the transport, this @@ -178,17 +178,14 @@ pub(super) async fn handle_relay_admin_event( state: &Arc, event: &Event, ) -> Result<(), RelayAdminError> { - // A ban is an admission boundary, not only a WebSocket-auth check. HTTP - // NIP-98 requests and already-authenticated sockets reach this handler - // without passing through a fresh NIP-42 challenge, and `ingest_event` - // exempts relay-admin kinds from its durable write-path gate so a *timed - // out* admin can still administer the roster. That exemption is ban-blind, - // so the ban must be enforced here or not at all: a banned admin otherwise - // keeps mutating `relay_members` — the very table `moderation_authz` - // derives moderator capability from — until someone manually deletes the - // row. Mirrors `moderation_commands.rs`, which defends the same boundary - // for 9040–9044, and holds that file's stated invariant that a direct - // command handler rejects a banned actor on every transport. + // HTTP NIP-98 requests and already-authenticated sockets reach this handler + // without a fresh NIP-42 challenge. The shared write-restriction gate in + // `ingest_event` rejects bans and active timeouts before kind dispatch; this + // handler repeats the durable-ban check as defense in depth before any + // roster mutation. A banned admin must not change `relay_members` — the + // table `moderation_authz` derives moderator capability from. This mirrors + // `moderation_commands.rs`, which applies the same handler-level ban check + // for kinds 9040–9044. // // This gate wraps execution rather than opening it so no future early // return inside the command body can precede it. @@ -518,11 +515,10 @@ mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - /// The vulnerability this file's ban gate closes: `ingest_event` exempts - /// relay-admin kinds 9030–9033 from its durable write-path restriction - /// gate, so a **banned** admin could add/remove relay members and change - /// the workspace icon over signed NIP-98 `POST /events`. Deleting the - /// admission check must fail here, in the default (non-ignored) suite. + /// The handler-level ban check protects relay-admin commands that reach it + /// after authenticated admission. The shared `ingest_event` restriction + /// gate rejects active timeouts before dispatch; this default (non-ignored) + /// unit test pins the handler's independent durable-ban check. #[test] fn banned_actor_is_not_admitted_to_a_relay_admin_command() { let banned = buzz_db::moderation::RestrictionState { @@ -536,18 +532,17 @@ mod postgres_tests { ); } - /// The counter-invariant, and the reason the ingest exemption exists at - /// all: a timeout restricts *content* writes, not administrative - /// capability. Widening this gate to timeouts would silently change policy. + /// A timeout is not a ban at this handler-local check. The shared ingest + /// gate rejects timed-out relay-admin requests before this check runs. #[test] - fn timed_out_actor_is_still_admitted() { + fn timed_out_actor_passes_local_ban_check() { let timed_out = buzz_db::moderation::RestrictionState { banned: false, muted_until: Some(chrono::Utc::now() + chrono::Duration::minutes(5)), }; assert!( admits_relay_admin_command(&timed_out).is_ok(), - "a timed-out admin must still administer the roster" + "the local ban check must not classify a timeout as a ban" ); } diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs new file mode 100644 index 00000000000..38325ab05ea --- /dev/null +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -0,0 +1,795 @@ +//! Community-ban coverage for the relay's registered HTTP and root WebSocket routes. +//! +//! The route inventory is shared with `scripts/check-community-ban-route-inventory.py`. +//! This ignored test uses a disposable local relay/PostgreSQL/MinIO stack and +//! fresh tenant hosts and identities. Run without BUZZ_KLIPY_API_KEY so GIF +//! controls assert the disabled-provider response. Git reads and workflows use +//! absent resources; Git push uses an invalid name. All stop before subprocess +//! or durable work. +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh +//! RELAY_URL=ws://localhost:3000 RELAY_HTTP_URL=http://localhost:3000 \ +//! cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture +//! ``` + +use std::{collections::BTreeSet, time::Duration}; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use buzz_test_client::{BuzzTestClient, TestClientError}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use reqwest::{Client, Method, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; + +const INVENTORY: &str = include_str!("fixtures/community-ban-route-inventory.tsv"); +const BANNED_MESSAGE: &str = "blocked: you are banned from this community"; + +const COMMUNITY_BAN_MATRIX_CASES: &[&str] = &[ + "events", + "buzz_sidebar", + "buzz_read_state", + "moderation_command", + "query", + "count", + "gif_search", + "gif_share", + "profile_search", + "workflow_runs", + "workflow_approvals", + "invite_mint", + "moderation_reports", + "moderation_owner_ban", + "moderation_audit", + "moderation_restricted", + "media_upload", + "media_get", + "media_head", + "git_info_refs", + "git_upload_pack", + "git_receive_pack", + "git_default_get", + "git_default_post", +]; + +#[derive(Clone, Debug)] +struct RouteRow { + id: String, + method: String, + path: String, + exercise: String, + control: String, +} + +#[derive(Clone)] +struct Principal { + name: &'static str, + keys: Keys, + nip_oa_tag: Option, +} + +fn relay_http_url() -> String { + std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + }) +} + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_authority() -> String { + let url = url::Url::parse(&relay_http_url()).expect("relay HTTP URL"); + url[url::Position::BeforeHost..url::Position::AfterPort].to_string() +} + +fn http_origin_for_host(host: &str) -> String { + let scheme = if relay_http_url().starts_with("https://") { + "https" + } else { + "http" + }; + format!("{scheme}://{host}") +} + +fn test_host() -> String { + format!( + "community-ban-{}.{}", + Uuid::new_v4().simple(), + relay_authority() + ) +} + +fn database_url() -> String { + std::env::var("DATABASE_URL") + .or_else(|_| std::env::var("BUZZ_TEST_DATABASE_URL")) + .expect("set DATABASE_URL or BUZZ_TEST_DATABASE_URL to a disposable relay-test database") +} + +async fn pool() -> Pool { + sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&database_url()) + .await + .expect("connect to disposable relay-test PostgreSQL") +} + +async fn ensure_community(pool: &Pool, host: &str) -> 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(pool) + .await + .unwrap_or_else(|error| panic!("seed disposable community {host}: {error}")); + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(host) + .fetch_one(pool) + .await + .unwrap_or_else(|error| panic!("lookup disposable community {host}: {error}")) +} + +async fn seed_member(pool: &Pool, community: Uuid, keys: &Keys, role: &str) { + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(community) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} user: {error}")); + 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 = EXCLUDED.role, updated_at = now()", + ) + .bind(community) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} relay member: {error}")); +} + +async fn ban_member(pool: &Pool, community: Uuid, actor: &Keys, target: &Keys) { + 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, actor_pubkey = EXCLUDED.actor_pubkey", + ) + .bind(community) + .bind(target.public_key().to_bytes().to_vec()) + .bind(actor.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed community ban: {error}")); +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).expect("NIP-98 URL tag"), + Tag::parse(["method", method]).expect("NIP-98 method tag"), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).expect("NIP-98 nonce tag"), + ]; + if !body.is_empty() { + tags.push(Tag::parse(["payload", &sha256_hex(body)]).expect("NIP-98 payload tag")); + } + let event = EventBuilder::new(Kind::Custom(27_235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!("Nostr {}", STANDARD.encode(event.as_json().as_bytes())) +} + +fn blossom_header(keys: &Keys, tag: &str, hash: &str, host: &str) -> String { + let expiration = (Timestamp::now().as_secs() + 55).to_string(); + let tags = vec![ + Tag::parse(["t", tag]).expect("Blossom operation tag"), + Tag::parse(["x", hash]).expect("Blossom hash tag"), + Tag::parse(["expiration", &expiration]).expect("Blossom expiration tag"), + Tag::parse(["server", host]).expect("Blossom server tag"), + ]; + let event = EventBuilder::new(Kind::Custom(24_242), "community-ban route test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign Blossom auth event"); + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) +} + +fn parse_inventory() -> Vec { + INVENTORY + .lines() + .skip(1) + .filter(|line| !line.is_empty()) + .map(|line| { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!( + columns.len(), + 9, + "malformed community-ban inventory row: {line}" + ); + RouteRow { + id: columns[0].to_string(), + method: columns[2].to_string(), + path: columns[3].to_string(), + exercise: columns[7].to_string(), + control: columns[8].to_string(), + } + }) + .collect() +} + +fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String { + let uuid = Uuid::new_v4(); + let repo_owner = actor.keys.public_key().to_hex(); + let missing_repo = format!("community-ban-{}", uuid.simple()); + let missing_blob = sha256_hex(format!("missing-{}", uuid.simple()).as_bytes()); + let mut path = row + .path + .replace("{workflow_id}", &uuid.to_string()) + .replace("{run_id}", &Uuid::new_v4().to_string()) + .replace("{sha256_ext}", &format!("{missing_blob}.jpg")) + .replace("{owner}", &repo_owner) + .replace("{repo}", &missing_repo); + // Push initializes absent repositories, so reject a deliberately invalid + // name after the shared admission gate and before storage/subprocess work. + if case == "git_receive_pack" { + path = path.replace(&missing_repo, "invalid..repo"); + } + if case == "git_info_refs" { + path.push_str("?service=git-upload-pack"); + } + path +} + +fn request_body(case: &str, actor: &Principal) -> Vec { + match case { + "events" => EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&actor.keys) + .expect("sign test message") + .as_json() + .into_bytes(), + "moderation_command" => { + let target = Keys::generate(); + EventBuilder::new(Kind::Custom(9_040), "") + .tags([Tag::parse(["p", &target.public_key().to_hex()]).expect("target tag")]) + .sign_with_keys(&actor.keys) + .expect("sign ban command") + .as_json() + .into_bytes() + } + "query" | "count" => serde_json::to_vec(&serde_json::json!([{ + "kinds": [1], + "limit": 1 + }])) + .expect("serialize Nostr filter"), + "gif_search" | "gif_share" | "profile_search" | "invite_mint" | "buzz_read_state" => { + b"{".to_vec() + } + "media_upload" => vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, + ], + "git_upload_pack" | "git_receive_pack" => Vec::new(), + "git_default_post" => serde_json::to_vec(&serde_json::json!({ + "branch": "main", + "expected_manifest": "missing" + })) + .expect("serialize default-branch request"), + _ => Vec::new(), + } +} + +fn git_signing_method<'a>(case: &str, request_method: &'a str) -> &'a str { + match case { + // Smart-HTTP clone and push intentionally reuse a GET-scoped NIP-98 + // credential for their POST pack requests. + "git_upload_pack" | "git_receive_pack" => "GET", + _ => request_method, + } +} + +fn nip98_url_for_case(origin: &str, path: &str, case: &str) -> String { + let repo_root = match case { + "git_info_refs" => path.split_once("/info/refs").map(|(prefix, _)| prefix), + "git_upload_pack" => path.strip_suffix("/git-upload-pack"), + "git_receive_pack" => path.strip_suffix("/git-receive-pack"), + _ => None, + }; + match repo_root { + Some(repo_root) => format!("{origin}{repo_root}"), + None => format!("{origin}{path}"), + } +} + +async fn send_route_request( + client: &Client, + row: &RouteRow, + case: &str, + actor: &Principal, + host: &str, +) -> Response { + let path = route_path(row, case, actor); + let url = format!("{}{}", relay_http_url().trim_end_matches('/'), path); + let signed_url = nip98_url_for_case(&http_origin_for_host(host), &path, case); + let body = request_body(case, actor); + let method = Method::from_bytes(row.method.as_bytes()).expect("inventory HTTP method"); + let mut request = client + .request(method.clone(), url) + .header(reqwest::header::HOST, host) + .header( + reqwest::header::CONTENT_TYPE, + if case == "media_upload" { + "image/jpeg" + } else { + "application/json" + }, + ); + + if case == "media_upload" || case == "media_get" || case == "media_head" { + let (operation, hash) = if case == "media_upload" { + ("upload", sha256_hex(&body)) + } else { + ( + "get", + path.split('/') + .next_back() + .unwrap_or_default() + .split('.') + .next() + .unwrap_or_default() + .to_string(), + ) + }; + let auth = blossom_header(&actor.keys, operation, &hash, host); + request = request.header(reqwest::header::AUTHORIZATION, auth); + if case == "media_upload" { + request = request.header("X-SHA-256", hash); + } + } else { + let auth_method = git_signing_method(case, row.method.as_str()); + let auth = nip98_header(&actor.keys, &signed_url, auth_method, &body); + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + + if let Some(tag) = &actor.nip_oa_tag { + request = request.header("x-auth-tag", tag); + } + if !body.is_empty() + || method == Method::POST + || method == Method::PUT + || method == Method::PATCH + { + request = request.body(body); + } + request + .timeout(Duration::from_secs(15)) + .send() + .await + .unwrap_or_else(|error| { + panic!("{} {} for {} failed: {error}", row.method, path, actor.name) + }) +} + +fn matrix_cases(row: &RouteRow) -> Vec<&str> { + row.exercise + .strip_prefix("matrix:") + .expect("route-matrix exercise") + .split('+') + .collect() +} + +fn assert_ban_denial( + response_status: StatusCode, + body: &str, + route: &RouteRow, + case: &str, + actor: &str, +) { + assert_eq!( + response_status, + StatusCode::FORBIDDEN, + "{} {} case {case}, {actor} must be denied by the ban gate; body: {body}", + route.method, + route.path + ); + if route.id.starts_with("git_default_branch") { + assert_eq!( + body, r#"{"error":"blocked: you are banned from this community"}"#, + "{} {} must preserve Git settings' ban denial response; body: {body}", + route.method, route.path + ); + } else if route.id.starts_with("git_") { + assert!( + body.contains("blocked: banned from this community"), + "{} {} must preserve Git's ban denial contract; body: {body}", + route.method, + route.path + ); + } else if route.id.starts_with("buzz_") { + assert_eq!( + serde_json::from_str::(body).expect("accessory denial JSON") + ["error"]["code"], + "forbidden" + ); + } else if route.method == "HEAD" { + assert!( + body.is_empty(), + "{} {} must suppress the denial body for HEAD; body: {body}", + route.method, + route.path + ); + } else if route.id.starts_with("media_") { + // Blossom deliberately maps both non-membership and ban refusals to + // the same policy response. These fixtures are direct relay members, + // and the paired clear member control proves this is the ban gate. + assert_eq!( + body, r#"{"error":"relay membership required"}"#, + "{} {} must preserve Blossom's policy-denial response; body: {body}", + route.method, route.path + ); + } else { + assert!( + body.contains(BANNED_MESSAGE), + "{} {} must preserve the community ban denial contract; body: {body}", + route.method, + route.path + ); + } +} + +fn assert_allowed_control(response_status: StatusCode, body: &str, route: &RouteRow, case: &str) { + let expected_status = match case { + "events" + | "moderation_command" + | "query" + | "count" + | "buzz_sidebar" + | "moderation_reports" + | "moderation_owner_ban" + | "moderation_audit" + | "moderation_restricted" => StatusCode::OK, + "profile_search" | "invite_mint" | "buzz_read_state" | "git_receive_pack" => { + StatusCode::BAD_REQUEST + } + // This isolated fixture runs without a GIF provider key. + "gif_search" | "gif_share" | "workflow_runs" | "workflow_approvals" | "media_get" + | "media_head" | "git_info_refs" | "git_upload_pack" | "git_default_get" + | "git_default_post" => StatusCode::NOT_FOUND, + "media_upload" => StatusCode::UNPROCESSABLE_ENTITY, + _ => panic!("no positive-control contract for {case}"), + }; + assert_eq!( + response_status, expected_status, + "{} {} case {case}: positive control reached an unexpected result: {body}", + route.method, route.path + ); + + if case == "media_head" { + assert!(body.is_empty(), "HEAD must suppress the missing-blob body"); + return; + } + if matches!( + case, + "git_info_refs" + | "git_upload_pack" + | "git_receive_pack" + | "git_default_get" + | "git_default_post" + ) { + assert_eq!( + body, + if case == "git_receive_pack" { + "invalid repo name" + } else { + "repository not found" + } + ); + return; + } + let value: serde_json::Value = serde_json::from_str(body) + .unwrap_or_else(|error| panic!("case {case}: expected JSON, got {body}: {error}")); + match case { + "events" | "moderation_command" => { + assert_eq!(value["accepted"], true, "case {case}: {body}"); + assert!(value["event_id"].as_str().is_some_and(|id| id.len() == 64)); + } + "query" + | "moderation_reports" + | "moderation_owner_ban" + | "moderation_audit" + | "moderation_restricted" => assert!(value.is_array(), "case {case}: {body}"), + "count" => assert!(value["count"].as_u64().is_some(), "count response: {body}"), + "buzz_sidebar" => { + assert!(value["channels"].is_array(), "sidebar response: {body}"); + assert!(value.get("next_cursor").is_some()); + } + "buzz_read_state" => { + assert_eq!(value["error"]["code"], "invalid_request"); + assert!(value["error"]["request_id"].as_str().is_some()); + } + _ => { + let expected_error = match case { + "profile_search" => "invalid profile search JSON", + "invite_mint" => { + "invalid invite JSON: EOF while parsing an object at line 1 column 1" + } + "gif_search" | "gif_share" => "GIF search is not configured", + "workflow_runs" | "workflow_approvals" => "workflow not found", + "media_get" => "not found", + "media_upload" => "invalid image data", + _ => unreachable!(), + }; + assert_eq!(value["error"], expected_error, "case {case}: {body}"); + } + } +} + +fn principal<'a>(name: &str, principals: &[&'a Principal]) -> &'a Principal { + principals + .iter() + .find(|principal| principal.name == name) + .unwrap_or_else(|| panic!("unknown route-matrix control principal {name}")) +} + +#[tokio::test] +#[ignore] +async fn community_http_ban_matrix() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host = test_host(); + let community = ensure_community(&db, &host).await; + let implemented_cases: BTreeSet<_> = COMMUNITY_BAN_MATRIX_CASES.iter().copied().collect(); + let inventory = parse_inventory(); + let inventory_cases: BTreeSet<_> = inventory + .iter() + .filter(|route| route.exercise.starts_with("matrix:")) + .flat_map(matrix_cases) + .collect(); + assert_eq!( + inventory_cases, implemented_cases, + "every route-matrix exercise must have a behavior implementation" + ); + + let banned_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_agent_keys = Keys::generate(); + let banned_agent = Principal { + name: "agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_agent_keys.public_key(), + "", + ) + .expect("sign delegated-agent owner proof"), + ), + keys: banned_agent_keys, + }; + let banned_moderator_agent_keys = Keys::generate(); + let banned_moderator_agent = Principal { + name: "moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_moderator_agent_keys.public_key(), + "", + ) + .expect("sign moderator-agent owner proof"), + ), + keys: banned_moderator_agent_keys, + }; + let clear_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_member_agent_keys = Keys::generate(); + let clear_member_agent = Principal { + name: "clear-member-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &clear_member.keys, + &clear_member_agent_keys.public_key(), + "", + ) + .expect("sign clear ordinary-owner agent proof"), + ), + keys: clear_member_agent_keys, + }; + let clear_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_owner = Principal { + name: "owner", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_moderator_agent_keys = Keys::generate(); + let clear_moderator_agent = Principal { + name: "clear-moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &clear_owner.keys, + &clear_moderator_agent_keys.public_key(), + "", + ) + .expect("sign clear moderator-agent owner proof"), + ), + keys: clear_moderator_agent_keys, + }; + seed_member(&db, community, &banned_member.keys, "member").await; + seed_member(&db, community, &banned_admin.keys, "admin").await; + seed_member(&db, community, &clear_member.keys, "member").await; + seed_member(&db, community, &clear_admin.keys, "admin").await; + seed_member(&db, community, &clear_owner.keys, "owner").await; + seed_member(&db, community, &banned_moderator_agent.keys, "admin").await; + seed_member(&db, community, &clear_moderator_agent.keys, "admin").await; + ban_member(&db, community, &clear_owner.keys, &banned_member.keys).await; + ban_member(&db, community, &clear_owner.keys, &banned_admin.keys).await; + + let banned = [&banned_member, &banned_admin, &banned_agent]; + let controls = [ + &clear_member, + &clear_member_agent, + &clear_admin, + &clear_owner, + &clear_moderator_agent, + ]; + for route in parse_inventory() + .into_iter() + .filter(|route| route.exercise.starts_with("matrix:")) + { + for case in matrix_cases(&route) { + let ban_actors: Vec<&Principal> = if case == "moderation_owner_ban" { + vec![&banned_moderator_agent] + } else { + banned.to_vec() + }; + for actor in ban_actors { + let response = send_route_request(&client, &route, case, actor, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_ban_denial(status, &body, &route, case, actor.name); + } + + let control_name = match case { + "moderation_command" => "owner", + "moderation_owner_ban" => "clear-moderator-agent", + _ => route.control.as_str(), + }; + let control_names = if control_name == "member" { + vec!["member", "clear-member-agent"] + } else { + vec![control_name] + }; + for name in control_names { + let control = principal(name, &controls); + let response = send_route_request(&client, &route, case, control, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_allowed_control(status, &body, &route, case); + } + } + } +} + +#[tokio::test] +#[ignore] +async fn ban_is_scoped_to_host_resolved_community() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host_a = test_host(); + let host_b = test_host(); + let community_a = ensure_community(&db, &host_a).await; + let community_b = ensure_community(&db, &host_b).await; + let actor = Principal { + name: "tenant-control", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let issuer = Keys::generate(); + seed_member(&db, community_a, &actor.keys, "member").await; + seed_member(&db, community_b, &actor.keys, "member").await; + ban_member(&db, community_a, &issuer, &actor.keys).await; + + let route = parse_inventory() + .into_iter() + .find(|route| route.id == "http_query") + .expect("query route is inventoried"); + let in_b = send_route_request(&client, &route, "query", &actor, &host_b).await; + let in_b_status = in_b.status(); + let in_b_body = in_b.text().await.unwrap_or_default(); + assert_eq!( + in_b_status, + StatusCode::OK, + "ban in A must not block B: {in_b_body}" + ); + + let in_a = send_route_request(&client, &route, "query", &actor, &host_a).await; + let in_a_status = in_a.status(); + let in_a_body = in_a.text().await.unwrap_or_default(); + assert_ban_denial(in_a_status, &in_a_body, &route, "query", actor.name); +} + +#[tokio::test] +#[ignore] +async fn banned_root_websocket_is_refused() { + let db = pool().await; + let host = relay_authority(); + let community = ensure_community(&db, &host).await; + let issuer = Keys::generate(); + let banned = Keys::generate(); + let clear = Keys::generate(); + let banned_owner = Keys::generate(); + let owner_agent = Keys::generate(); + ban_member(&db, community, &issuer, &banned).await; + ban_member(&db, community, &issuer, &banned_owner).await; + + match BuzzTestClient::connect(&relay_url(), &banned).await { + Err(error) => assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket rejection must identify the ban, got {error}" + ), + Ok(client) => { + let _ = client.disconnect().await; + panic!("a community-banned principal authenticated on the root WebSocket") + } + } + + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&banned_owner, &owner_agent.public_key(), "") + .expect("sign owner proof for WebSocket agent"); + let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("decode owner proof"); + let auth_tag = Tag::parse(auth_tag).expect("parse NIP-OA auth tag"); + let mut agent_socket = BuzzTestClient::connect_unauthenticated(&relay_url()) + .await + .expect("open root WebSocket for owner-banned agent"); + let error = agent_socket + .authenticate_with_nip_oa(&owner_agent, &auth_tag) + .await + .expect_err("a NIP-OA agent of a banned owner must be refused"); + assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket owner ban must identify the restriction, got {error}" + ); + drop(agent_socket); + + let control = BuzzTestClient::connect(&relay_url(), &clear) + .await + .unwrap_or_else(|error: TestClientError| { + panic!("unbanned root WebSocket control must authenticate: {error}") + }); + control + .disconnect() + .await + .expect("disconnect unbanned root WebSocket control"); +} diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv new file mode 100644 index 00000000000..5385e8ade75 --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv @@ -0,0 +1,24 @@ +id file test lane property +http_membership crates/buzz-relay/src/handlers/ingest.rs relay_membership_step_refuses_banned_member_on_http postgres-ci ban_and_unbanned_http_gate +root_admission crates/buzz-relay/src/handlers/auth.rs fi_ban_denial_emits_terminal_authorization_denied postgres-ci root_nip42_ban_denial +root_admission_race crates/buzz-relay/src/handlers/auth.rs ban_committed_during_auth_refuses_admission postgres-ci admission_ban_race_and_other_community_control +root_ban_eviction crates/buzz-relay/src/handlers/relay_admin.rs ban_closes_socket_even_when_audit_insert_fails postgres-ci root_ban_closes_local_socket_before_failed_audit +root_owner_eviction crates/buzz-relay/src/handlers/auth.rs root_auth_records_owner_for_lookup_free_revoke postgres-ci root_socket_owner_cascade_eviction +root_fail_closed crates/buzz-relay/src/handlers/auth.rs fi_ban_check_error_emits_terminal_authorization_unavailable unit root_restriction_lookup_unavailable +root_db_error crates/buzz-relay/src/handlers/auth.rs handler_accounts_ban_check_database_error unit root_restriction_lookup_unavailable_off_mode +audio_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_community_banned_member postgres-ci huddle_banned_member_admission +audio_admin_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_admin postgres-ci huddle_banned_admin_admission_and_unbanned_control +audio_owner_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_owner_agent postgres-ci huddle_agent_banned_through_owner +audio_local_eviction crates/buzz-relay/src/audio/handler.rs ban_disconnect_closes_live_audio_socket postgres-ci pod_local_huddle_socket_eviction +audio_admission_race crates/buzz-relay/src/audio/handler.rs nip_fi_audio_ban_during_final_admission_sends_canonical_denial postgres-ci huddle_admission_ban_race +tenant_local_eviction crates/buzz-relay/src/state.rs disconnect_pubkey_is_fenced_to_the_banning_community unit pod_local_tenant_fence +media_fail_closed crates/buzz-relay/src/api/media.rs failed_restriction_lookup_is_503_not_403 postgres-ci media_upload_get_head_fail_closed +git_fail_closed crates/buzz-relay/src/api/git/transport.rs ban_gate_fails_closed_with_503_when_the_store_is_unreachable postgres-ci git_restriction_lookup_fail_closed +ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every_agent_write_until_unban postgres-ci agent_owner_ban_write_gate +moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link +timeout_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial +http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation +invite_restricted_claim crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs signed_nonmember_claims_reject_restrictions_without_consuming_invite postgres-ci restricted_nonmember_claim_never_consumes_invite +workflow_owner_ban crates/buzz-relay/src/api/bridge/workflow_webhook_postgres_tests.rs webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure postgres-ci webhook_owner_ban_tenant_scope_and_fail_closed_lookup +durable_live_revalidation crates/buzz-relay/src/state.rs missed_member_owner_ban_and_first_owner_link_revoke_root_audio_sessions postgres-ci missed_fanout_member_and_owner_revocation +token_owner_revalidation crates/buzz-relay/src/state.rs token_owner_admission_survives_scan_with_nip_oa_disabled_until_owner_removal postgres-ci nip_fi_owner_admission_matches_live_scan diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv new file mode 100644 index 00000000000..93764a228e8 --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -0,0 +1,78 @@ +id listener method path auth_plane membership restriction exercise control +relay_root_ws relay GET / dual_nip11_nip42 conditional conditional_enforce e2e:banned_root_websocket_is_refused unbanned +relay_info relay GET /info public_metadata not_applicable not_applicable:public_metadata classified - +nip05 relay GET /.well-known/nostr.json public_metadata not_applicable not_applicable:public_metadata classified - +relay_health relay GET /health health_probe not_applicable not_applicable:health_probe classified - +relay_liveness relay GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +relay_readiness relay GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +http_events relay POST /events community_nip98 community enforce matrix:events+moderation_command member +http_query relay POST /query community_nip98 community enforce matrix:query member +http_count relay POST /count community_nip98 community enforce matrix:count member +gif_search relay POST /gifs/search community_nip98 community enforce matrix:gif_search member +gif_share relay POST /gifs/share community_nip98 community enforce matrix:gif_share member +profile_search relay POST /profiles/search community_nip98 community enforce matrix:profile_search member +workflow_runs relay GET /workflows/{workflow_id}/runs community_nip98 community enforce matrix:workflow_runs member +workflow_approvals relay GET /workflows/{workflow_id}/runs/{run_id}/approvals community_nip98 community enforce matrix:workflow_approvals member +operator_list_communities relay GET /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_provision_community relay POST /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_list_joined_communities relay GET /operator/communities/joined operator_control not_applicable not_applicable:relay_operator_control classified - +operator_register_listener_keys relay POST /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_remove_listener_keys relay DELETE /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_archive_community relay POST /operator/communities/archive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_unarchive_community relay POST /operator/communities/unarchive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_nip_fi_binding relay GET /operator/communities/nip-fi-binding operator_control not_applicable not_applicable:relay_operator_control classified - +operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified - +invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin +join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified - +invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified - +invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pg:signed_nonmember_claims_reject_restrictions_without_consuming_invite - +nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified - +moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin +moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin +moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin +workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pg:webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure - +mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified - +huddle_audio relay GET /huddle/{channel_id}/audio community_nip42 community enforce pg:audio_join_rejects_community_banned_member+audio_join_rejects_banned_admin+audio_join_rejects_banned_owner_agent - +media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member +media_upload_alias relay PUT /media/upload community_blossom community enforce matrix:media_upload member +media_read_get relay GET /media/{sha256_ext} community_blossom community enforce matrix:media_get member +media_read_head relay HEAD /media/{sha256_ext} community_blossom community enforce matrix:media_head member +git_info_refs relay GET /git/{owner}/{repo}/info/refs community_git_nip98 community enforce matrix:git_info_refs admin +git_upload_pack relay POST /git/{owner}/{repo}/git-upload-pack community_git_nip98 community enforce matrix:git_upload_pack admin +git_receive_pack relay POST /git/{owner}/{repo}/git-receive-pack community_git_nip98 community enforce matrix:git_receive_pack admin +git_default_branch_get relay GET /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_get admin +git_default_branch_post relay POST /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_post admin +git_policy_hook relay POST /internal/git/policy internal_git_hook internal not_applicable:internal_auth_plane classified - +admin_probe relay GET /api/admin/v1/probe operator_control not_applicable not_applicable:relay_admin_control classified - +admin_reports_list relay GET /api/admin/v1/reports operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_detail relay GET /api/admin/v1/reports/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_resolve relay POST /api/admin/v1/reports/{id}/resolve operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_reopen relay POST /api/admin/v1/reports/{id}/reopen operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_cancel relay POST /api/admin/v1/reports/{id}/cancel operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_list relay GET /api/admin/v1/feedback operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_get relay GET /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_patch relay PATCH /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_attachment relay GET /api/admin/v1/feedback/{id}/attachments/{sha256} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operators_list relay GET /api/admin/v1/operators operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_upsert relay PUT /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_delete relay DELETE /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_restrictions_list relay GET /api/admin/v1/members/restrictions operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_unban relay DELETE /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_untimeout relay DELETE /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_ban relay POST /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_timeout relay POST /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_delete_event relay POST /api/admin/v1/events/{id}/delete operator_control not_applicable not_applicable:relay_admin_control classified - +health_liveness health GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +health_readiness health GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +health_status health GET /_status health_probe not_applicable not_applicable:health_probe classified - +health_mesh health GET /_mesh health_probe not_applicable not_applicable:health_probe classified - +admin_communities relay GET /api/admin/v1/communities operator_control not_applicable not_applicable:relay_admin_control classified - +admin_event relay GET /api/admin/v1/events/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_search relay GET /api/admin/v1/members/search operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member relay GET /api/admin/v1/members/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +buzz_sidebar relay GET /buzz/v1/me/sidebar community_nip98 community enforce matrix:buzz_sidebar member +buzz_read_state relay POST /buzz/v1/me/read-state community_nip98 community enforce matrix:buzz_read_state member diff --git a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs index c37be8ca404..5d588461f33 100644 --- a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs +++ b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs @@ -1,17 +1,14 @@ //! 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. +//! `ingest_event` runs the durable write-restriction gate before relay-admin +//! kinds 9030-9033. Bans and active timeouts must reject those commands, while +//! an unrestricted admin can still mutate the roster and workspace icon via +//! signed NIP-98 `POST /events`. //! -//! Requires a running relay and its Postgres. Ignored by default: +//! Requires a running relay and its Postgres. Export `DATABASE_URL` for a +//! disposable local database, then run (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 @@ -123,14 +120,14 @@ async fn seed(p: &sqlx::Pool, cid: Uuid, keys: &Keys, role: &str /// 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, +/// relay-admin 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), +/// - a *timed-out* admin gets 403 + the `restricted:` timeout prefix on 9031, +/// and the target member remains in the roster, /// - an unrestricted admin's behaviour is unchanged, mutation included. #[tokio::test] #[ignore] -async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { +async fn banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer() { let p = pool().await; let cid = community_id(&p).await; @@ -290,7 +287,7 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { "9033: banned admin must not change the workspace icon, got {icon:?}" ); - // ── Timed-out admin: still administers (ingest exemption preserved). ── + // ── Timed-out admin: relay-admin and content writes are refused. ── let (ts, tb) = post_event( &timed_out_admin, &signed( @@ -302,13 +299,22 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { .await; println!("[timed-out] 9031 remove -> {ts} {tb}"); assert_eq!( - ts, 200, - "timed-out admin must still administer the roster: {tb}" + ts, 403, + "timed-out admin must be refused relay-admin commands: {tb}" + ); + let timeout_message: serde_json::Value = serde_json::from_str(&tb).unwrap_or_default(); + let timeout_text = timeout_message + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or(&tb); + assert!( + timeout_text.starts_with("restricted: you are timed out"), + "timed-out admin denial must use the timeout restriction prefix: {tb}" ); assert_eq!( - role_of(&victim2).await, - None, - "timed-out admin's removal must take effect" + role_of(&victim2).await.as_deref(), + Some("member"), + "timed-out admin must not remove a member" ); // Control: the same timed-out admin is still write-blocked for content. diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py new file mode 100644 index 00000000000..ff1f280841f --- /dev/null +++ b/scripts/check-community-ban-route-inventory.py @@ -0,0 +1,662 @@ +#!/usr/bin/env python3 +"""Keep the community-ban route inventory bound to production Axum routes.""" + +from __future__ import annotations + +import ast +import csv +import re +import sys +from collections import Counter +from functools import lru_cache +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RELAY_SRC = ROOT / "crates/buzz-relay/src" +ROUTE_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv" +) +REGRESSION_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv" +) +ROUTE_TEST = ROOT / "crates/buzz-test-client/tests/community_ban_routes.rs" +HTTP_METHODS = { + "get": "GET", + "head": "HEAD", + "post": "POST", + "put": "PUT", + "delete": "DELETE", + "patch": "PATCH", + "options": "OPTIONS", + "trace": "TRACE", + "connect": "CONNECT", + "any": "ANY", +} +AUTH_PLANES = { + "dual_nip11_nip42", + "public_metadata", + "health_probe", + "community_nip98", + "community_blossom", + "community_git_nip98", + "community_nip42", + "operator_control", + "pre_membership_anonymous", + "pre_membership_nip98", + "internal_nip_fi", + "workflow_webhook_secret", + "testbed_internal", + "internal_git_hook", +} +MEMBERSHIP_CLASSES = { + "community", + "conditional", + "exempt", + "internal", + "not_applicable", +} +RESTRICTION_CLASSES = {"enforce", "conditional_enforce"} +REQUIRED_REGRESSIONS = { + "http_membership", + "root_admission", + "root_admission_race", + "root_ban_eviction", + "root_owner_eviction", + "root_fail_closed", + "root_db_error", + "audio_admission", + "audio_admin_admission", + "audio_owner_admission", + "audio_local_eviction", + "audio_admission_race", + "tenant_local_eviction", + "media_fail_closed", + "git_fail_closed", + "ingest_owner_ban", + "moderation_owner_ban", + "timeout_admin_denial", + "http_tenant_isolation", +} +RAW_STRING_PREFIX = re.compile(r"(?:b)?r(#+)?\"") +CHAR_LITERAL = re.compile(r"'(?:\\(?:u\{[0-9a-fA-F_]+\}|.)|[^'\\])'") + + +def mask_comments_and_strings(source: str) -> str: + """Replace comments and string contents with spaces, preserving offsets.""" + out = list(source) + i = 0 + while i < len(source): + if source.startswith("//", i): + end = source.find("\n", i) + end = len(source) if end < 0 else end + for j in range(i, end): + out[j] = " " + i = end + continue + if source.startswith("/*", i): + depth = 1 + j = i + 2 + while j < len(source) and depth: + if source.startswith("/*", j): + depth += 1 + j += 2 + elif source.startswith("*/", j): + depth -= 1 + j += 2 + else: + j += 1 + for k in range(i, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + raw_prefix = RAW_STRING_PREFIX.match(source, i) + if raw_prefix: + hashes = raw_prefix.group(1) or "" + end_token = '"' + hashes + start = i + body_start = raw_prefix.end() + end = source.find(end_token, body_start) + end = len(source) if end < 0 else end + len(end_token) + for j in range(start, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + if source[i] == '"' or ( + source[i] == "b" and i + 1 < len(source) and source[i + 1] == '"' + ): + start = i + quote = i if source[i] == '"' else i + 1 + j = quote + 1 + while j < len(source): + if source[j] == "\\": + j += 2 + continue + if source[j] == '"': + j += 1 + break + j += 1 + for k in range(start, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + char_literal = CHAR_LITERAL.match(source, i) + if char_literal: + end = char_literal.end() + for j in range(i, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + i += 1 + return "".join(out) + + +def matching_delimiter(mask: str, start: int) -> int: + opening = mask[start] + pairs = {"(": ")", "[": "]", "{": "}"} + closing = pairs[opening] + depth = 0 + for index in range(start, len(mask)): + if mask[index] == opening: + depth += 1 + elif mask[index] == closing: + depth -= 1 + if depth == 0: + return index + raise ValueError(f"unclosed {opening!r} at byte {start}") + + +def test_module_ranges(mask: str) -> list[tuple[int, int]]: + ranges = [] + pattern = re.compile( + r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*" + r"(?:#\s*\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?" + r"mod\s+[A-Za-z_]\w*\s*\{" + ) + for match in pattern.finditer(mask): + brace = mask.find("{", match.start(), match.end()) + ranges.append((match.start(), matching_delimiter(mask, brace) + 1)) + return ranges + + +def in_ranges(position: int, ranges: list[tuple[int, int]]) -> bool: + return any(start <= position < end for start, end in ranges) + + +def module_path(path: Path) -> str: + module_parts = list(path.relative_to(RELAY_SRC).with_suffix("").parts) + if module_parts[-1] in {"mod", "lib", "main"}: + module_parts.pop() + return "::".join(module_parts) + + +def production_sources(): + for path in sorted(RELAY_SRC.rglob("*.rs")): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + yield path + + +def router_functions(path: Path, source: str, mask: str): + pattern = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*\(") + excluded = test_module_ranges(mask) + module = module_path(path) + + for match in pattern.finditer(mask): + name = match.group(1) + if name != "router" and not name.endswith("_router"): + continue + if in_ranges(match.start(), excluded): + continue + paren = mask.find("(", match.start(), match.end()) + close_paren = matching_delimiter(mask, paren) + brace = mask.find("{", close_paren + 1) + if brace < 0: + continue + end = matching_delimiter(mask, brace) + 1 + yield f"{module}::{name}", name, match.start(), brace, end + + +def split_first_top_level_comma(mask: str, start: int, end: int) -> int | None: + stack: list[str] = [] + pairs = {"(": ")", "[": "]", "{": "}"} + for index in range(start, end): + char = mask[index] + if char in pairs: + stack.append(pairs[char]) + elif stack and char == stack[-1]: + stack.pop() + elif char == "," and not stack: + return index + return None + + +def string_value(expression: str) -> str | None: + expression = expression.strip() + if expression.startswith('"'): + try: + value = ast.literal_eval(expression) + except (SyntaxError, ValueError): + return None + return value if isinstance(value, str) else None + raw = re.fullmatch(r'r(#+)?"(.*)"\1', expression, re.DOTALL) + return raw.group(2) if raw else None + + +def route_constants() -> dict[tuple[str, str], str]: + """Map `(module, NAME)` to each production `&str` constant's value. + + Keyed by module so same-named constants in different modules (for example + `api::gifs::SEARCH_PATH` and `api::profiles::SEARCH_PATH`) stay distinct. + """ + constants = {} + pattern = re.compile( + r"(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Za-z_]\w*)\s*:\s*&str\s*=\s*" + r'("(?:\\.|[^"\\])*"|r(?:#+)?".*?"(?:#+)?)\s*;', + re.DOTALL, + ) + for path in production_sources(): + module = module_path(path) + for match in pattern.finditer(path.read_text()): + value = string_value(match.group(2)) + if value is not None: + constants[(module, match.group(1))] = value + return constants + + +def route_path(expression: str, constants: dict[tuple[str, str], str], module: str) -> str: + literal = string_value(expression) + if literal is not None: + return literal + reference = re.fullmatch( + r"(?:(?:crate|self)::)?((?:[A-Za-z_]\w*::)*)([A-Za-z_]\w*)", expression.strip() + ) + if reference is None: + raise ValueError( + f"route path is not a literal or known string constant: {expression.strip()}" + ) + qualifier = reference.group(1).rstrip(":") + name = reference.group(2) + candidates = [key for key in constants if key[1] == name] + if qualifier: + candidates = [ + key for key in candidates if key[0] == qualifier or key[0].endswith(f"::{qualifier}") + ] + elif len(candidates) > 1: + candidates = [key for key in candidates if key[0] == module] + if len(candidates) != 1: + raise ValueError( + f"route path is not a literal or known string constant: {expression.strip()}" + ) + return constants[candidates[0]] + + +def registered_calls(mask: str, source: str, function_start: int, function_end: int, call: str): + pattern = re.compile(rf"\.\s*{re.escape(call)}\s*\(") + for match in pattern.finditer(mask, function_start, function_end): + opening = mask.find("(", match.start(), match.end()) + closing = matching_delimiter(mask, opening) + comma = split_first_top_level_comma(mask, opening + 1, closing) + if comma is None: + continue + yield ( + source[opening + 1 : comma].strip(), + mask[comma + 1 : closing], + source[comma + 1 : closing], + ) + + +def builder_prefixes(builders) -> dict[str, str]: + main = next( + (builder for builder in builders if builder[0] == "router::build_router"), + None, + ) + if main is None: + raise ValueError("could not find relay router::build_router") + _, _, path, source, mask, start, _, end = main + constants = route_constants() + prefixes = {} + for first, second_mask, second_source in registered_calls(mask, source, start, end, "nest"): + prefix = route_path(first, constants, module_path(path)) + target_match = re.search(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*\(", second_mask) + if target_match is None: + raise ValueError( + f"router::build_router: nest target is not a router builder call: " + f"{second_source.strip()}" + ) + target = re.sub(r"^(?:crate|self)::", "", target_match.group(1)) + candidates = [b[0] for b in builders if b[0] == target or b[0].endswith(f"::{target}")] + if len(candidates) != 1: + raise ValueError( + f"router::build_router: nest target {target} matches " + f"{len(candidates)} router builders, expected exactly 1" + ) + if candidates[0] in prefixes: + raise ValueError( + f"router::build_router: {candidates[0]} is nested more than once; " + "the inventory records one prefix per router builder" + ) + prefixes[candidates[0]] = prefix + return prefixes + + +# Service, fallback, and merge registrations bypass per-route classification. +# Pin the existing composition seams by builder and exact normalized argument; +# reject new or repeated compositions until their routes can be classified. +ALLOWED_UNROUTED_REGISTRATIONS = { + # These root aliases are constructed in build_router at the root prefix. + ("router::build_router", "merge", "media_router"), + ("router::build_router", "merge", "git_router"), + ("router::build_router", "merge", "git_policy_router"), + ("router::build_router", "merge", "optional"), + # Git settings already carry their complete /git paths; no nest prefix. + ("api::git::transport::git_router", "merge", "super::settings::router()"), + # SPA static bundles; it only serves files, never a community API. + ("router::build_router", "fallback_service", "spa_fallback"), + # Unknown accessory paths answer 404 instead of SPA HTML. + ( + "api::buzz_v1::router", + "fallback", + '|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }', + ), +} + + +def collect_registered_routes() -> Counter: + constants = route_constants() + builders = [] + for path in production_sources(): + source = path.read_text() + mask = mask_comments_and_strings(source) + for qualified_name, name, start, brace, end in router_functions(path, source, mask): + builders.append((qualified_name, name, path, source, mask, brace, start, end)) + + prefixes = builder_prefixes(builders) + registrations: Counter = Counter() + errors = [] + unrouted_seen = set() + for path in production_sources(): + source = path.read_text() + mask = mask_comments_and_strings(source) + test_ranges = test_module_ranges(mask) + path_builders = [builder for builder in builders if builder[2] == path] + # Root aliases have root paths only when merged outside a nest target. + nest_arguments = [] + for nest in re.finditer(r"\.\s*nest\s*\(", mask): + opening = mask.find("(", nest.start(), nest.end()) + nest_arguments.append((opening, matching_delimiter(mask, opening))) + + def enclosing_builder(position: int) -> str | None: + for builder in path_builders: + if builder[6] <= position < builder[7]: + return builder[0] + return None + + for match in re.finditer( + r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback|merge)\s*\(", + mask, + ): + if in_ranges(match.start(), test_ranges): + continue + call = match.group(1) + builder = enclosing_builder(match.start()) + line = source.count("\n", 0, match.start()) + 1 + location = f"{path.relative_to(ROOT)}:{line}" + if call.endswith("_service") or call in {"fallback", "merge"}: + opening = mask.find("(", match.start(), match.end()) + argument = " ".join( + source[opening + 1 : matching_delimiter(mask, opening)].split() + ) + registration = (builder, call, argument) + if ( + call == "merge" + and builder == "router::build_router" + and in_ranges(match.start(), nest_arguments) + ): + errors.append( + f"{location}: root .merge registration inside a .nest target " + "is not classified" + ) + elif registration not in ALLOWED_UNROUTED_REGISTRATIONS: + errors.append( + f"{location}: production .{call} registration is not classified; " + "use .route with an HTTP method or allow-list it" + ) + elif registration in unrouted_seen: + errors.append( + f"{location}: allow-listed .{call} registration appears more than once" + ) + unrouted_seen.add(registration) + elif builder is None: + errors.append( + f"{location}: production .{call} registration " + "is outside a recognized router builder" + ) + elif call == "nest" and builder != "router::build_router": + errors.append( + f"{location}: production .nest registration outside " + "router::build_router is not classified" + ) + + for qualified_name, name, path, source, mask, _, start, end in builders: + listener = "health" if qualified_name == "router::build_health_router" else "relay" + prefix = prefixes.get(qualified_name, "") + for path_expression, method_mask, _ in registered_calls(mask, source, start, end, "route"): + try: + route = route_path(path_expression, constants, module_path(path)) + except ValueError as error: + errors.append(f"{path.relative_to(ROOT)}::{name}: {error}") + continue + methods = { + method.upper() + for method in re.findall( + r"\b(get|head|post|put|delete|patch|options|trace|connect|any)\s*\(", + method_mask, + ) + } + if not methods: + errors.append( + f"{path.relative_to(ROOT)}::{name}: no Axum HTTP method in route expression" + ) + continue + full_path = f"{prefix.rstrip('/')}{route}" if prefix else route + for method in methods: + registrations[(listener, method, full_path)] += 1 + + if errors: + raise ValueError("route scanner could not classify registrations:\n " + "\n ".join(errors)) + duplicates = [route for route, count in registrations.items() if count > 1] + if duplicates: + rendered = ", ".join(f"{scope} {method} {path}" for scope, method, path in duplicates) + raise ValueError(f"duplicate production route registrations: {rendered}") + return registrations + + +def read_tsv(path: Path, expected_header: list[str]) -> list[dict[str, str]]: + with path.open(newline="") as file: + reader = csv.DictReader(file, delimiter="\t") + if reader.fieldnames != expected_header: + raise ValueError(f"{path.relative_to(ROOT)} header must be {expected_header}") + rows = list(reader) + if any(None in row or any(value is None for value in row.values()) for row in rows): + raise ValueError(f"{path.relative_to(ROOT)} contains a row with the wrong number of columns") + return rows + + +@lru_cache(maxsize=None) +def source_mask(path: Path) -> str | None: + if not path.is_file(): + return None + return mask_comments_and_strings(path.read_text()) + + +def test_function_exists(path: Path, name: str) -> bool: + mask = source_mask(path) + if mask is None: + return False + return re.search(rf"\bfn\s+{re.escape(name)}\s*\(", mask) is not None + + +def check_route_inventory() -> tuple[int, int, int]: + routes = read_tsv( + ROUTE_INVENTORY, + [ + "id", + "listener", + "method", + "path", + "auth_plane", + "membership", + "restriction", + "exercise", + "control", + ], + ) + by_key: dict[tuple[str, str, str], dict[str, str]] = {} + ids = set() + matrix_cases = set() + pending = [] + for row in routes: + route_id = row["id"] + if not route_id or route_id in ids: + raise ValueError(f"duplicate or empty route id: {route_id!r}") + ids.add(route_id) + key = (row["listener"], row["method"], row["path"]) + if key in by_key: + raise ValueError(f"duplicate inventory route: {key}") + by_key[key] = row + if row["listener"] not in {"relay", "health"}: + raise ValueError(f"{route_id}: unknown listener {row['listener']!r}") + if row["auth_plane"] not in AUTH_PLANES: + raise ValueError(f"{route_id}: unknown auth plane {row['auth_plane']!r}") + if row["membership"] not in MEMBERSHIP_CLASSES: + raise ValueError(f"{route_id}: unknown membership class {row['membership']!r}") + if row["method"] not in set(HTTP_METHODS.values()): + raise ValueError(f"{route_id}: unknown HTTP method {row['method']!r}") + + restriction = row["restriction"] + exercise = row["exercise"] + if restriction in RESTRICTION_CLASSES: + if not exercise or exercise == "classified": + raise ValueError(f"{route_id}: enforced restriction needs a behavior exercise") + elif restriction.startswith("not_applicable:"): + if not restriction.partition(":")[2] or exercise != "classified": + raise ValueError(f"{route_id}: restriction exemption needs a reason and classification") + else: + raise ValueError(f"{route_id}: unrecognized restriction class {restriction!r}") + + if row["membership"] == "exempt" and restriction.startswith("not_applicable:"): + if route_id != "invite_accept_policy": + raise ValueError( + f"{route_id}: membership exemption cannot silently exempt restriction" + ) + if exercise.startswith("matrix:"): + if row["control"] not in {"member", "admin", "owner"}: + raise ValueError(f"{route_id}: route-matrix row needs an allowed control principal") + matrix_cases.update(exercise.partition(":")[2].split("+")) + elif exercise.startswith("e2e:"): + test_name = exercise.partition(":")[2] + if not test_function_exists(ROUTE_TEST, test_name): + raise ValueError(f"{route_id}: missing E2E test {test_name!r}") + elif exercise.startswith("pg:"): + tests = exercise.partition(":")[2].split("+") + for test_name in tests: + found = any( + test_function_exists(path, test_name) + for path in RELAY_SRC.rglob("*.rs") + ) + if not found: + raise ValueError(f"{route_id}: missing PostgreSQL regression {test_name!r}") + elif exercise.startswith("pending:"): + pending.append((route_id, exercise)) + elif exercise != "classified": + raise ValueError(f"{route_id}: unrecognized exercise classification {exercise!r}") + + registrations = collect_registered_routes() + registered = set(registrations) + inventoried = set(by_key) + missing = sorted(registered - inventoried) + stale = sorted(inventoried - registered) + if missing or stale: + details = [] + if missing: + details.append("registered routes without inventory rows: " + ", ".join(map(str, missing))) + if stale: + details.append("inventory rows without registered routes: " + ", ".join(map(str, stale))) + raise ValueError("; ".join(details)) + + if pending: + raise ValueError(f"integrated ban coverage cannot retain pending sibling cases: {pending}") + claim = next((row for row in routes if row["id"] == "invite_claim"), None) + if claim is None or claim["membership"] != "exempt" or claim["restriction"] != "enforce": + raise ValueError("invite claim must remain membership-exempt and restriction-enforced") + acceptance = next((row for row in routes if row["id"] == "invite_accept_policy"), None) + if acceptance is None or acceptance["membership"] != "exempt": + raise ValueError("anonymous policy acceptance needs its separate pre-membership classification") + + route_test_mask = mask_comments_and_strings(ROUTE_TEST.read_text()) + supported = re.search( + r"const\s+COMMUNITY_BAN_MATRIX_CASES\s*:\s*&\[&str\]\s*=\s*&\[(.*?)\];", + route_test_mask, + re.DOTALL, + ) + if supported is None: + raise ValueError("route E2E test must declare COMMUNITY_BAN_MATRIX_CASES") + supported_cases = set(re.findall(r'"([a-z_]+)"', ROUTE_TEST.read_text()[supported.start() : supported.end()])) + if matrix_cases != supported_cases: + raise ValueError( + "route-matrix exercise cases differ from COMMUNITY_BAN_MATRIX_CASES: " + f"inventory-only={sorted(matrix_cases - supported_cases)}, " + f"test-only={sorted(supported_cases - matrix_cases)}" + ) + if "community_http_ban_matrix" not in route_test_mask: + raise ValueError("route inventory is not exercised by community_http_ban_matrix") + return len(routes), len(matrix_cases), len(pending) + + +def check_regression_inventory() -> int: + rows = read_tsv( + REGRESSION_INVENTORY, + ["id", "file", "test", "lane", "property"], + ) + seen = set() + for row in rows: + if not row["id"] or row["id"] in seen: + raise ValueError(f"duplicate or empty supporting regression id: {row['id']!r}") + seen.add(row["id"]) + if row["lane"] not in {"unit", "postgres-ci", "relay-e2e"}: + raise ValueError(f"{row['id']}: unknown validation lane {row['lane']!r}") + path = ROOT / row["file"] + if not test_function_exists(path, row["test"]): + raise ValueError(f"{row['id']}: missing referenced test {row['file']}::{row['test']}") + missing = REQUIRED_REGRESSIONS - seen + if missing: + raise ValueError(f"supporting regression inventory is missing {sorted(missing)}") + return len(rows) + + +def main() -> int: + try: + route_count, case_count, pending_count = check_route_inventory() + regression_count = check_regression_inventory() + except (OSError, ValueError, csv.Error) as error: + print(f"community-ban route inventory failed: {error}", file=sys.stderr) + return 1 + print( + "community-ban route inventory passed: " + f"{route_count} registered routes classified, {case_count} HTTP behavior cases, " + f"{regression_count} supporting regressions indexed, {pending_count} sibling implementation cases pending" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-community-ban-route-inventory.test.py b/scripts/check-community-ban-route-inventory.test.py new file mode 100644 index 00000000000..d0e4bcc1d41 --- /dev/null +++ b/scripts/check-community-ban-route-inventory.test.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Fixture self-tests: every route shape the scanner can't classify must fail.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import textwrap +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "check-community-ban-route-inventory.py" +SPEC = importlib.util.spec_from_file_location("route_inventory", SCRIPT) +scanner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(scanner) + +API_MOD = """ +pub mod admin; +pub mod gifs; +pub mod profiles; +""" +GIFS = """ +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +""" +PROFILES = """ +pub(crate) const SEARCH_PATH: &str = "/profiles/search"; +""" +ADMIN = """ +pub fn router(state: State) -> Router { + Router::new().route("/probe", get(probe)) +} +""" + + +def build_router(extra: str = "") -> str: + return textwrap.dedent( + f""" + pub fn build_router(state: State) -> Router {{ + Router::new() + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::profiles::SEARCH_PATH, post(api::profiles::search)) + .nest("/api/admin/v1", api::admin::router(state.clone())) + {extra} + }} + """ + ) + + +class RouteScannerFixtures(unittest.TestCase): + def scan(self, router: str, admin: str = ADMIN): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "crates/buzz-relay/src" + (src / "api").mkdir(parents=True) + (src / "router.rs").write_text(router) + (src / "api/mod.rs").write_text(API_MOD) + (src / "api/gifs.rs").write_text(GIFS) + (src / "api/profiles.rs").write_text(PROFILES) + (src / "api/admin.rs").write_text(admin) + saved = scanner.ROOT, scanner.RELAY_SRC + scanner.ROOT, scanner.RELAY_SRC = root, src + try: + return scanner.collect_registered_routes() + finally: + scanner.ROOT, scanner.RELAY_SRC = saved + + def assert_rejected(self, message: str, router: str, admin: str = ADMIN): + with self.assertRaisesRegex(ValueError, message): + self.scan(router, admin) + + def test_nested_builder_merge_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router(), + ADMIN.replace('get(probe))', 'get(probe)).merge(crate::api::profiles::router(state))'), + ) + + def test_unclassified_root_merge_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router(".merge(api::admin::router(state.clone()))"), + ) + + def test_existing_root_merge_alias_is_allowed_once(self): + self.scan(build_router(".merge(media_router)")) + self.assert_rejected( + r"allow-listed \.merge registration appears more than once", + build_router(".merge(media_router).merge(media_router)"), + ) + + def test_root_merge_alias_moved_inside_nest_is_rejected(self): + self.assert_rejected( + r"root \.merge registration inside a \.nest target is not classified", + build_router().replace( + 'api::admin::router(state.clone()))', + 'api::admin::router(state.clone()).merge(git_policy_router))', + ), + ) + + def test_merge_outside_router_builder_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router() + "\nfn hidden() { opaque.merge(private_router); }", + ) + + def test_same_named_constants_resolve_per_module(self): + routes = self.scan(build_router()) + self.assertEqual( + set(routes), + { + ("relay", "POST", "/gifs/search"), + ("relay", "POST", "/profiles/search"), + ("relay", "GET", "/api/admin/v1/probe"), + }, + ) + + def test_spa_fallback_service_is_allowed(self): + self.scan(build_router(".fallback_service(spa_fallback)")) + + def test_route_service_is_rejected(self): + self.assert_rejected( + r"\.route_service registration is not classified", + build_router('.route_service("/probe", service)'), + ) + + def test_nest_service_is_rejected(self): + self.assert_rejected( + r"\.nest_service registration is not classified", + build_router('.nest_service("/probe", service)'), + ) + + def test_fallback_service_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration is not classified", + build_router(), + ADMIN.replace('get(probe))', "get(probe)).fallback_service(service)"), + ) + + def test_non_spa_fallback_service_in_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration is not classified", + build_router( + ".fallback_service(spa_fallback)" + '.merge(Router::new().route("/x", get(x)).fallback_service(private_service))' + ), + ) + + def test_repeated_spa_fallback_service_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration appears more than once", + build_router(".fallback_service(spa_fallback).fallback_service(spa_fallback)"), + ) + + def test_handler_fallback_is_rejected(self): + self.assert_rejected( + r"\.fallback registration is not classified", + build_router(".fallback(private_handler)"), + ) + + def test_handler_fallback_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback registration is not classified", + build_router(), + ADMIN.replace('get(probe))', "get(probe)).fallback(private_handler)"), + ) + + def test_builder_nested_twice_is_rejected(self): + self.assert_rejected( + r"api::admin::router is nested more than once", + build_router('.nest("/alternate-admin", api::admin::router(state.clone()))'), + ) + + def test_nest_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.nest registration outside router::build_router", + build_router(), + ADMIN.replace('get(probe))', 'get(probe)).nest("/zz", other_router(state))'), + ) + + def test_unresolved_nest_target_is_rejected(self): + self.assert_rejected( + r"nest target missing::router matches 0 router builders", + build_router('.nest("/zz", missing::router(state.clone()))'), + ) + + def test_unknown_route_constant_is_rejected(self): + self.assert_rejected( + r"not a literal or known string constant: api::gifs::MISSING", + build_router(".route(api::gifs::MISSING, get(missing))"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f513a5ad314..73eb9975711 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -78,6 +78,12 @@ ensure_infra() { run_unit_tests() { section "Unit Tests (no infra required)" + run_test_step "community-ban route inventory" \ + python3 scripts/check-community-ban-route-inventory.py + + run_test_step "community-ban route scanner fixtures" \ + python3 scripts/check-community-ban-route-inventory.test.py + run_test_step "buzz-core tests" \ cargo test -p buzz-core --lib -- --nocapture