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:
Tyler
2026-07-01 19:25:24 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler Longwell
parent 15ad7ae87e
commit 0701f47f4a
4 changed files with 12 additions and 131 deletions
+12 -101
View File
@@ -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,