mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
fix(invites): reject unknown claim codes before the admission lock
A v2 claim took the community-wide admission lock before looking up its token, so forged `v2.` codes from fresh keys could serialize a community's claims, bans and timeouts and hold shared writer-pool connections while queued. Check that the code exists with an indexed read first; the locked `FOR UPDATE` read stays authoritative. A restricted claimant presenting an unknown code now gets `invite_invalid`, which reveals nothing. Also from review: mint's owner-link 503 reuses `OWNER_LINK_ERROR` like the NIP-FI path; comments explain the admission wait behind membership snapshots, the defensive claim-owner revocation, and why issuer restrictions map to `invite_invalid`; TESTING.md notes that a rollback past 0074 needs `BUZZ_AUTO_MIGRATE` off (docs/database.md Rule 5). Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
This commit is contained in:
@@ -219,6 +219,9 @@ keep it closed until it is active and repaired, then recheck zero before admitti
|
||||
traffic. After repair, timeouts/mutes only block temporarily; active bans still
|
||||
revoke permanently. A rollback to code that ignores `revoked_at` requires
|
||||
invitation admission paused, using the same traffic/writer controls if needed.
|
||||
It also requires `BUZZ_AUTO_MIGRATE` off: with auto-migration on (the Helm
|
||||
default), the old binary fails at startup with SQLx `VersionMissing(74)`. See
|
||||
[docs/database.md](docs/database.md) Rule 5.
|
||||
Legacy owner attribution uses the agent's currently recorded
|
||||
`agent_owner_pubkey`; deleting an owner user row out of band clears that link
|
||||
through its foreign key, so a later repair cannot recover the former
|
||||
|
||||
@@ -199,6 +199,9 @@ pub async fn mint_relay_invite(
|
||||
|
||||
/// Lock invitation admission against community restriction and owner writes.
|
||||
/// Call before reading restrictions or locking an invite row, and hold to commit.
|
||||
/// A joined claim holds it through its NIP-43 membership snapshot, so mint, ban,
|
||||
/// timeout and first owner link wait for that snapshot, which grows with the
|
||||
/// member count. Claims reject unknown codes before taking it.
|
||||
pub(crate) async fn lock_admission(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
community: CommunityId,
|
||||
@@ -420,6 +423,21 @@ pub async fn claim_relay_invite_with_owner(
|
||||
verified_owner: Option<&str>,
|
||||
relay_keypair: &nostr::Keys,
|
||||
) -> Result<MembershipWrite<InviteClaimResult>> {
|
||||
// Unknown codes are free to forge, so reject them before taking the
|
||||
// community-wide admission lock; the locked read below stays authoritative.
|
||||
let known: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM relay_invites \
|
||||
WHERE community_id = $1 AND token_hash = $2)",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(token_hash)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if !known {
|
||||
log_claim_outcome(community, None, "invalid", None, None);
|
||||
return Ok(MembershipWrite::unchanged(ClaimOutcome::Invalid.into()));
|
||||
}
|
||||
|
||||
let mut tx = crate::begin_community_write_transaction(
|
||||
pool,
|
||||
community,
|
||||
|
||||
@@ -82,7 +82,9 @@ pub(super) async fn persist_claim_owner(
|
||||
}
|
||||
let link_created = sqlx::query("UPDATE users SET agent_owner_pubkey=$3 WHERE community_id=$1 AND pubkey=$2 AND agent_owner_pubkey IS NULL")
|
||||
.bind(community.as_uuid()).bind(&claimant_bytes).bind(&owner_bytes).execute(&mut *tx).await?.rows_affected() > 0;
|
||||
// Defensive parity with other owner writers; admission already rejects banned owners.
|
||||
// Defense in depth: `claimant_restricted` already refused a banned owner
|
||||
// under the same admission lock, so this finds nothing today. It keeps
|
||||
// parity with every other owner writer if that check ever moves.
|
||||
if link_created {
|
||||
super::revoke_restricted_agent_invites(tx, community, &claimant_bytes, &owner_bytes)
|
||||
.await?;
|
||||
|
||||
@@ -357,3 +357,22 @@ async fn linked_authorization_owner_and_missing_agent_do_not_wait_on_admission()
|
||||
));
|
||||
claim.rollback().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn unknown_claim_code_is_rejected_without_waiting_on_admission() {
|
||||
let (pool, community, _, claimant) = fixture().await;
|
||||
let mut held = pool.begin().await.unwrap();
|
||||
lock_admission(&mut held, community).await.unwrap();
|
||||
// A forged code must not queue behind admission; if the claim takes the
|
||||
// lock before looking the code up, this bounded call cannot finish.
|
||||
let outcome = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
claim_relay_invite_outcome(&pool, community, &[0xAB; 32], &claimant, None),
|
||||
)
|
||||
.await
|
||||
.expect("unknown code waited on the admission lock")
|
||||
.unwrap();
|
||||
assert_eq!(outcome, ClaimOutcome::Invalid);
|
||||
held.rollback().await.unwrap();
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ async fn mint_invite_checked(
|
||||
super::relay_members::OwnerLink::Unavailable => {
|
||||
return api_error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"restriction lookup unavailable",
|
||||
crate::handlers::auth::OWNER_LINK_ERROR,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -517,6 +517,9 @@ pub async fn claim_invite(
|
||||
buzz_db::relay_invite::ClaimOutcome::Exhausted => {
|
||||
Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted"))
|
||||
}
|
||||
// Deliberately indistinguishable, even for an issuer timeout whose
|
||||
// link works again later: a claim never reveals the issuer's
|
||||
// moderation state.
|
||||
buzz_db::relay_invite::ClaimOutcome::Revoked
|
||||
| buzz_db::relay_invite::ClaimOutcome::IssuerRestricted => {
|
||||
Err(api_error(StatusCode::FORBIDDEN, "invite_invalid"))
|
||||
|
||||
@@ -351,7 +351,7 @@ pub(super) mod postgres_tests {
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
read_json(response).await["error"],
|
||||
"restriction lookup unavailable"
|
||||
crate::handlers::auth::OWNER_LINK_ERROR
|
||||
);
|
||||
let after: i64 = sqlx::query_scalar("SELECT count(*) FROM relay_invites")
|
||||
.fetch_one(state.db.pool())
|
||||
|
||||
Reference in New Issue
Block a user