mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay): remove media bearer-token auth (#1444)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
parent
15ad7ae87e
commit
0701f47f4a
@@ -17,23 +17,20 @@ use axum::{
|
||||
};
|
||||
use base64::Engine;
|
||||
use buzz_audit::{AuditAction, NewAuditEntry};
|
||||
use buzz_auth::Scope;
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use buzz_media::{BlobDescriptor, MediaError};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Axum extractor that validates Blossom auth + API token scopes from headers
|
||||
/// BEFORE the request body is read. This prevents unauthenticated clients from
|
||||
/// forcing the server to buffer up to 50MB of body data.
|
||||
/// Axum extractor that validates Blossom auth, the BUD-11 hash binding, and
|
||||
/// relay membership (NIP-43, when enabled) from headers BEFORE the request
|
||||
/// body is read. This prevents unauthenticated clients from forcing the
|
||||
/// server to buffer up to 50MB of body data.
|
||||
///
|
||||
/// Axum processes `FromRequestParts` extractors before `FromRequest` (body)
|
||||
/// extractors, so auth rejection happens before any body buffering.
|
||||
pub(crate) struct AuthenticatedUpload {
|
||||
auth_event: nostr::Event,
|
||||
#[allow(dead_code)] // scopes validated in extractor; stored for future per-scope handler logic
|
||||
scopes: Vec<Scope>,
|
||||
/// Community resolved from the request host at extraction time (row zero for
|
||||
/// this HTTP door), identical to the WS door in `router.rs` and the bridge
|
||||
/// door in `bridge.rs`. Server-resolved, never client-supplied.
|
||||
@@ -125,12 +122,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
// host, so an unauthenticated caller cannot probe which communities
|
||||
// exist on this deployment.
|
||||
//
|
||||
// This MUST run before scope resolution so the API-token lookup is
|
||||
// keyed on (community_id, token_hash) — see Gap 2 / row-44 conformance
|
||||
// obligation. Resolving scopes without a tenant in hand would query
|
||||
// api_tokens by hash alone, defeating the cross-community fence.
|
||||
//
|
||||
// It also runs before Blossom auth verification (step 2) so the
|
||||
// This MUST run before Blossom auth verification (step 2) so the
|
||||
// `server`-tag check validates against the *bound tenant host*, not a
|
||||
// process-global domain — a relay process serves many tenant hosts, and
|
||||
// the stock CLI tags its own configured relay host (conformance row 52).
|
||||
@@ -176,12 +168,13 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
// 5. Resolve scopes (API token or dev mode), scoped to the bound tenant.
|
||||
let scopes = resolve_upload_scopes(headers, state, &tenant, &auth_event.pubkey).await?;
|
||||
buzz_auth::require_scope(&scopes, Scope::FilesWrite)
|
||||
.map_err(|_| MediaError::InsufficientScope)?;
|
||||
|
||||
// 6. Relay membership gate (NIP-43).
|
||||
// 5. Relay membership gate (NIP-43). Blossom auth proves the signer
|
||||
// authorized this exact upload hash for this server; NIP-43 answers
|
||||
// whether that Nostr key may use this community's media store. This is
|
||||
// the only upload authority: independent of bearer-token / api_tokens
|
||||
// storage and of `require_auth_token` (which governs the REST API, not
|
||||
// media). On open relays (membership disabled) any valid Blossom signer
|
||||
// may upload, matching the WS door's admission policy.
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
crate::api::relay_members::enforce_relay_membership(
|
||||
state,
|
||||
@@ -204,7 +197,6 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
|
||||
Ok(AuthenticatedUpload {
|
||||
auth_event,
|
||||
scopes,
|
||||
tenant,
|
||||
_upload_permit: upload_permit,
|
||||
})
|
||||
@@ -223,7 +215,6 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
/// Expects:
|
||||
/// - `Authorization: Nostr <base64(kind:24242 event)>` — Blossom auth
|
||||
/// - `X-SHA-256: <hex>` — Required per BUD-11
|
||||
/// - `X-Auth-Token: buzz_*` — API token for scope resolution (optional in dev mode)
|
||||
/// - `Content-Type: video/mp4` — routes to video validation path; all other types use image path
|
||||
/// - Raw binary body (the file bytes)
|
||||
///
|
||||
@@ -749,86 +740,6 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result<nostr::Event, MediaError>
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Resolve permission scopes for an upload caller, scoped to the request's tenant.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `X-Auth-Token: buzz_*` header — API token path (validates owner matches Blossom signer)
|
||||
/// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes
|
||||
///
|
||||
/// The token lookup is keyed on `(tenant.community(), token_hash)` — see
|
||||
/// [`buzz_db::api_token::get_api_token_by_hash_including_revoked`] for the
|
||||
/// row-44 conformance rationale. A token minted in community A presented to a
|
||||
/// host that resolves to community B must not authorize.
|
||||
async fn resolve_upload_scopes(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
blossom_pubkey: &nostr::PublicKey,
|
||||
) -> Result<Vec<Scope>, MediaError> {
|
||||
// 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token.
|
||||
if let Some(token) = headers
|
||||
.get("x-auth-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|t| t.starts_with("buzz_"))
|
||||
{
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
let record = state
|
||||
.db
|
||||
.get_api_token_by_hash_including_revoked(tenant.community(), &hash)
|
||||
.await
|
||||
.map_err(|_| MediaError::Unauthorized)?
|
||||
.ok_or(MediaError::Unauthorized)?;
|
||||
|
||||
if record.revoked_at.is_some() {
|
||||
return Err(MediaError::TokenRevoked);
|
||||
}
|
||||
if let Some(expires_at) = record.expires_at {
|
||||
if expires_at < chrono::Utc::now() {
|
||||
return Err(MediaError::TokenExpired);
|
||||
}
|
||||
}
|
||||
|
||||
// Token owner must match the Blossom signer — prevents token theft attacks.
|
||||
let blossom_bytes = blossom_pubkey.to_bytes().to_vec();
|
||||
if record.owner_pubkey != blossom_bytes {
|
||||
return Err(MediaError::PubkeyMismatch);
|
||||
}
|
||||
|
||||
return Ok(record
|
||||
.scopes
|
||||
.iter()
|
||||
.filter_map(|s| s.parse::<Scope>().ok())
|
||||
.collect());
|
||||
}
|
||||
|
||||
// 2. Dev mode: no API token required.
|
||||
if state.config.require_auth_token {
|
||||
return Err(MediaError::Unauthorized);
|
||||
}
|
||||
|
||||
// Dev mode is active — any valid Blossom signer can upload.
|
||||
// This must never be enabled in production.
|
||||
tracing::warn!(
|
||||
"dev mode upload: no API token required — ensure require_auth_token=true in production"
|
||||
);
|
||||
|
||||
// 3. Pubkey allowlist check (dev mode only).
|
||||
if state.config.pubkey_allowlist_enabled {
|
||||
let pubkey_bytes = blossom_pubkey.to_bytes().to_vec();
|
||||
if !state
|
||||
.db
|
||||
.is_pubkey_allowed(tenant.community(), &pubkey_bytes)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(MediaError::Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
// Dev mode: grant file scopes.
|
||||
Ok(vec![Scope::FilesRead, Scope::FilesWrite])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -116,7 +116,6 @@ class BlobDescriptor {
|
||||
|
||||
class MediaUploadService {
|
||||
final String _baseUrl;
|
||||
final String? _apiToken;
|
||||
final String? _nsec;
|
||||
final PickGalleryImage _pickGalleryImage;
|
||||
final PickGalleryVideo _pickGalleryVideo;
|
||||
@@ -129,7 +128,6 @@ class MediaUploadService {
|
||||
|
||||
MediaUploadService({
|
||||
required String baseUrl,
|
||||
required String? apiToken,
|
||||
required String? nsec,
|
||||
required PickGalleryImage pickGalleryImage,
|
||||
required PickGalleryVideo pickGalleryVideo,
|
||||
@@ -139,7 +137,6 @@ class MediaUploadService {
|
||||
DateTime Function()? now,
|
||||
http.Client? httpClient,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_apiToken = apiToken,
|
||||
_nsec = nsec,
|
||||
_pickGalleryImage = pickGalleryImage,
|
||||
_pickGalleryVideo = pickGalleryVideo,
|
||||
@@ -262,9 +259,6 @@ class MediaUploadService {
|
||||
'Content-Type': mimeType,
|
||||
'X-SHA-256': sha256,
|
||||
};
|
||||
if (_apiToken case final token? when token.isNotEmpty) {
|
||||
headers['X-Auth-Token'] = token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -641,7 +635,6 @@ final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
|
||||
final picker = ImagePicker();
|
||||
final service = MediaUploadService(
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: null,
|
||||
nsec: config.nsec,
|
||||
pickGalleryImage: () => picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
|
||||
@@ -160,7 +160,6 @@ void main() {
|
||||
final nsec = keychain.nsec;
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response(
|
||||
@@ -226,7 +225,6 @@ void main() {
|
||||
final nsec = keychain.nsec;
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response(
|
||||
@@ -294,7 +292,6 @@ void main() {
|
||||
final nsec = keychain.nsec;
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response('bad upload', 401);
|
||||
@@ -329,7 +326,6 @@ void main() {
|
||||
final nsec = keychain.nsec;
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async =>
|
||||
@@ -366,7 +362,6 @@ void main() {
|
||||
final nsec = keychain.nsec;
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async =>
|
||||
@@ -422,7 +417,6 @@ void main() {
|
||||
try {
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
return http.Response(
|
||||
|
||||
@@ -258,7 +258,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example:8443',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -311,7 +310,6 @@ void main() {
|
||||
test('returns null when the gallery picker is cancelled', () async {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: null,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async => null,
|
||||
@@ -343,7 +341,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'http://[::1]:3000',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -396,7 +393,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -447,7 +443,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -499,7 +494,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -550,7 +544,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -603,7 +596,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -633,7 +625,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async =>
|
||||
@@ -658,7 +649,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response('{}', 200),
|
||||
@@ -702,7 +692,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
@@ -725,7 +714,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response('{}', 200),
|
||||
@@ -753,7 +741,6 @@ void main() {
|
||||
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async => XFile.fromData(
|
||||
@@ -874,7 +861,6 @@ void main() {
|
||||
try {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => xfile,
|
||||
@@ -920,7 +906,6 @@ void main() {
|
||||
try {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: nsec,
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => xfile,
|
||||
@@ -948,7 +933,6 @@ void main() {
|
||||
test('returns null when video picker is cancelled', () async {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: null,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async => null,
|
||||
@@ -969,7 +953,6 @@ void main() {
|
||||
try {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
apiToken: null,
|
||||
nsec: null,
|
||||
pickGalleryVideo: () async => XFile(file.path),
|
||||
pickGalleryImage: () async => null,
|
||||
|
||||
Reference in New Issue
Block a user