Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,10 @@ BUZZ_S3_ADDRESSING_STYLE=path
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8
# BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2
# BUZZ_MEDIA_UPLOADS_PER_MINUTE=30
# Require Blossom t=get auth and relay membership for GET/HEAD /media/*.
# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed.
# BUZZ_REQUIRE_MEDIA_GET_AUTH=false
# Legacy alias accepted by the relay while rollout docs catch up:
# BUZZ_REQUIRE_MEDIA_READ_AUTH=false
# GET/HEAD /media/* always require Blossom t=get auth and relay membership.
# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer
# read; setting either (including to false) changes nothing and the relay warns
# about it at startup.

# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,19 @@ jobs:
env:
RELAY_URL: ws://localhost:3000
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
# coverage that a real relay rejects bare reads and honours host- and
# hash-scoped tokens. They were #[ignore]d and selected by no CI job, so
# the lane never ran; select it here, where MinIO and the seeded
# 'localhost:3000' community already exist.
# --no-fail-fast: without it cargo stops after the first failing binary,
# so one broken case hides every later binary's result.
run: |
cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
RELAY_HTTP_URL: http://localhost:3000
- name: Upload relay logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
1 change: 0 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,6 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `REDIS_URL` | `redis://localhost:6379` | |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. |
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
Expand Down
53 changes: 14 additions & 39 deletions crates/buzz-relay/src/api/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,6 @@ async fn authenticate_media_read(
) -> Result<MediaReadAuth, MediaError> {
let tenant = bind_media_read_tenant(state, headers).await?;

if !state.config.require_media_get_auth {
return Ok(MediaReadAuth { tenant });
}

let auth_event = extract_blossom_auth(headers)?;
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?;
Expand All @@ -514,12 +510,8 @@ async fn authenticate_media_read(
Ok(MediaReadAuth { tenant })
}

fn blob_cache_control(require_auth: bool) -> &'static str {
if require_auth {
"private, max-age=31536000, immutable"
} else {
"public, max-age=31536000, immutable"
}
fn blob_cache_control() -> &'static str {
"private, max-age=31536000, immutable"
}

/// Whether a path-segment extension is a safe token.
Expand Down Expand Up @@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant(
req_headers: &HeaderMap,
) -> Result<Response, MediaError> {
validate_media_path(sha256_ext)?;
let cache_control = blob_cache_control(state.config.require_media_get_auth);
let cache_control = blob_cache_control();

// Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative.
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
Expand Down Expand Up @@ -801,10 +793,9 @@ pub async fn head_blob(
Path(sha256_ext): Path<String>,
) -> Result<Response, MediaError> {
validate_media_path(&sha256_ext)?;
let require_media_get_auth = state.config.require_media_get_auth;
let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?;
let tenant = media_auth.tenant;
let cache_control = blob_cache_control(require_media_get_auth);
let cache_control = blob_cache_control();

// Sidecar gate FIRST — reject before any blob I/O.
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
Expand Down Expand Up @@ -946,13 +937,8 @@ mod tests {
}

async fn test_state() -> Arc<AppState> {
test_state_with_media_get_auth(false).await
}

async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc<AppState> {
let mut config = crate::config::Config::from_env().expect("default config loads");
config.require_relay_membership = false;
config.require_media_get_auth = require_media_get_auth;
config.redis_url = "redis://127.0.0.1:1".to_string();
config.media_uploads_per_minute = 1;
config.media_max_concurrent_uploads = 2;
Expand Down Expand Up @@ -994,8 +980,8 @@ mod tests {
Arc::new(state)
}

async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router {
let state = test_state_with_media_get_auth(require_media_get_auth).await;
async fn media_get_auth_router() -> axum::Router {
let state = test_state().await;
axum::Router::new()
.route(
"/media/{sha256_ext}",
Expand Down Expand Up @@ -1041,20 +1027,9 @@ mod tests {
}

#[tokio::test]
async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() {
let response = media_get_auth_router(false)
.await
.oneshot(media_request("GET", None))
.await
.expect("response");

assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() {
async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() {
for method in ["GET", "HEAD"] {
let response = media_get_auth_router(true)
let response = media_get_auth_router()
.await
.oneshot(media_request(method, None))
.await
Expand All @@ -1065,10 +1040,10 @@ mod tests {
}

#[tokio::test]
async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() {
async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() {
let keys = Keys::generate();
let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None));
let response = media_get_auth_router(true)
let response = media_get_auth_router()
.await
.oneshot(media_request("GET", Some(auth)))
.await
Expand All @@ -1078,7 +1053,7 @@ mod tests {
}

#[tokio::test]
async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() {
async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() {
let keys = Keys::generate();
let now = Timestamp::now().as_secs();
let expiration = (now + 300).to_string();
Expand All @@ -1102,7 +1077,7 @@ mod tests {

for tags in cases {
let auth = media_get_auth_header(&keys, tags);
let response = media_get_auth_router(true)
let response = media_get_auth_router()
.await
.oneshot(media_request("GET", Some(auth)))
.await
Expand All @@ -1119,15 +1094,15 @@ mod tests {
}

#[tokio::test]
async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() {
async fn media_read_accepts_range_header_only_after_auth() {
let keys = Keys::generate();
let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None));
let mut request = media_request("GET", Some(auth));
request
.headers_mut()
.insert(header::RANGE, "bytes=0-0".parse().expect("range header"));

let response = media_get_auth_router(true)
let response = media_get_auth_router()
.await
.oneshot(request)
.await
Expand Down
102 changes: 85 additions & 17 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,6 @@ pub struct Config {
/// Maximum media upload starts accepted from one pubkey per minute.
pub media_uploads_per_minute: u32,

/// Require Blossom kind:24242 `t=get` auth plus relay membership before
/// serving media GET/HEAD. Default off for staged client rollout.
pub require_media_get_auth: bool,

/// Whether tamper-evident event/media audit logging is enabled. Defaults to true.
/// This does not control the separate `moderation_actions` audit trail.
/// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it.
Expand Down Expand Up @@ -417,6 +413,31 @@ fn ensure_git_path(
Ok(git_repo_path)
}

/// Env vars that once gated authenticated media reads.
///
/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH`
/// was documented in `.env.example` as an accepted alias but was never read by
/// the relay. Media reads are now unconditionally authenticated, so both are
/// inert and an operator still setting either — especially to `false` — holds a
/// belief about their deployment that is no longer true.
const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH",
];

/// Which of `names` are present, so startup can warn that they do nothing.
///
/// `lookup` is injected rather than calling `std::env::var` directly: process
/// env is global mutable state, so a test that set real vars would race every
/// other test in the binary.
fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option<String>) -> Vec<&'a str> {
names
.iter()
.copied()
.filter(|name| lookup(name).is_some())
.collect()
}

impl Config {
/// Loads configuration from environment variables, falling back to development defaults.
pub fn from_env() -> Result<Self, ConfigError> {
Expand Down Expand Up @@ -739,14 +760,13 @@ impl Config {
.filter(|&v| v > 0)
.unwrap_or(30);

let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH")
.map(|v| {
v == "true"
|| v == "1"
|| v.eq_ignore_ascii_case("yes")
|| v.eq_ignore_ascii_case("on")
})
.unwrap_or(false);
for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) {
warn!(
"{name} is set but is no longer read — GET/HEAD /media/* always require \
Blossom t=get auth plus relay membership. Remove it; a value of `false` \
does not re-open unauthenticated media reads."
);
}

let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE")
.ok()
Expand Down Expand Up @@ -965,7 +985,6 @@ impl Config {
media_max_concurrent_uploads,
media_max_concurrent_uploads_per_pubkey,
media_uploads_per_minute,
require_media_get_auth,
audit_enabled,
ephemeral_ttl_override,
git_repo_path,
Expand Down Expand Up @@ -997,6 +1016,59 @@ mod tests {
// value set by `invalid_bind_addr_returns_error`, causing a flaky failure.
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Look up against a fixed set, standing in for process env.
fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + use<'a> {
move |name| {
set.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}

/// The case that matters: an operator who pinned the old flag to `false`
/// must be told it is inert, not left believing media reads are still open.
#[test]
fn inert_media_read_auth_vars_are_reported_even_when_false() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]),
);

assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]);
}

/// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an
/// accepted alias but the relay never read it, so operators may hold it
/// today. It warns too.
#[test]
fn inert_media_read_auth_vars_include_the_documented_alias() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[
("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"),
("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"),
]),
);

assert_eq!(
found,
vec![
"BUZZ_REQUIRE_MEDIA_GET_AUTH",
"BUZZ_REQUIRE_MEDIA_READ_AUTH"
]
);
}

#[test]
fn inert_media_read_auth_vars_stay_quiet_when_unset() {
let found = inert_env_vars(
&INERT_MEDIA_READ_AUTH_VARS,
env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]),
);

assert!(found.is_empty(), "unrelated vars must not warn: {found:?}");
}

#[test]
fn defaults_are_valid() {
let _guard = ENV_MUTEX.lock().unwrap();
Expand Down Expand Up @@ -1034,10 +1106,6 @@ mod tests {
!config.serve_git_web_gui,
"serve_git_web_gui should default to false"
);
assert!(
!config.require_media_get_auth,
"require_media_get_auth should default to false for staged client rollout"
);
assert_eq!(
config.media.s3_addressing_style,
buzz_media::config::S3AddressingStyle::Path,
Expand Down
22 changes: 16 additions & 6 deletions crates/buzz-test-client/tests/conformance_multitenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2612,17 +2612,27 @@ mod pubsub_presence_typing {
mod media_blossom {
use super::*;

/// Obligation: public blob `GET/HEAD /media/{sha256.ext}` stays
/// unauthenticated (N=1 compat, shared CAS bytes). The community boundary is
/// the metadata/descriptor/upload-auth/quota/audit layer: B's private upload
/// metadata/errors must not be observable from A, even when the blob bytes
/// are deduplicated and shared.
/// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth
/// scoped to the serving host or the blob hash, and the request is bound to the
/// tenant resolved from the request headers. A bare read is rejected before any
/// storage lookup, so the endpoint does not leak blob existence.
///
/// CAS bytes are still deduplicated across communities, so the boundary is not
/// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus
/// the per-tenant read binding. B's private upload metadata and errors must not
/// be observable from A even when the underlying blob is shared.
///
/// Known limitation, deferred: relay membership plus knowledge of a hash is
/// sufficient to read a blob. Read auth binds host and tenant, not the channel
/// ACL of the message the blob was attached to.
#[tokio::test]
#[ignore]
async fn media_metadata_boundary_holds_while_blob_bytes_shared() {
pending_lane(
"buzz-media",
"shared SHA bytes OK; A cannot read B's upload metadata/quota/audit; errors generic",
"reads require host/hash-scoped Blossom auth and bind to the header tenant; \
bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \
metadata/quota/audit; errors generic",
);
}
}
Expand Down
Loading
Loading