From 83feb2b114008ff1de7c3f58931da67f185d4de3 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 31 Jul 2026 14:07:16 -0700 Subject: [PATCH 1/6] Require authentication for media reads by default Make Blossom GET and HEAD require signed read authorization plus current relay membership unless an operator explicitly opts out. Co-authored-by: Jordan Mecom Signed-off-by: Jordan Mecom --- crates/buzz-relay/src/config.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..818abc6485 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -210,7 +210,8 @@ pub struct Config { 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. + /// serving media GET/HEAD. Defaults on so private attachment URLs are not + /// bearer capabilities after membership is revoked. pub require_media_get_auth: bool, /// Whether tamper-evident event/media audit logging is enabled. Defaults to true. @@ -746,7 +747,7 @@ impl Config { || v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("on") }) - .unwrap_or(false); + .unwrap_or(true); let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() @@ -1035,8 +1036,8 @@ mod tests { "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" + config.require_media_get_auth, + "require_media_get_auth should default to true" ); assert_eq!( config.media.s3_addressing_style, From c43e99c38012e483122f7ed67cb1319a49c6e60a Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 31 Jul 2026 14:55:19 -0700 Subject: [PATCH 2/6] Require authentication for all media reads Co-authored-by: Jordan Mecom Signed-off-by: Jordan Mecom --- .env.example | 6 +-- TESTING.md | 1 - crates/buzz-relay/src/api/media.rs | 53 ++++++-------------- crates/buzz-relay/src/config.rs | 19 ------- deploy/charts/buzz/templates/NOTES.txt | 5 -- deploy/charts/buzz/templates/deployment.yaml | 1 - deploy/charts/buzz/tests/render_test.yaml | 29 ----------- deploy/charts/buzz/values.schema.json | 1 - deploy/charts/buzz/values.yaml | 6 --- desktop/src-tauri/src/commands/media.rs | 8 ++- docs/admin/README.md | 6 +-- 11 files changed, 21 insertions(+), 114 deletions(-) diff --git a/.env.example b/.env.example index b9bfcada0e..01d9fcb4f1 100644 --- a/.env.example +++ b/.env.example @@ -102,11 +102,7 @@ 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. # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/TESTING.md b/TESTING.md index 51a5eb44c1..bdd77fb4b6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 | diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a2f3640bde 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -493,10 +493,6 @@ async fn authenticate_media_read( ) -> Result { 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)?; @@ -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. @@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant( req_headers: &HeaderMap, ) -> Result { 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") { @@ -801,10 +793,9 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { 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") { @@ -946,13 +937,8 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await - } - - async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { 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; @@ -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}", @@ -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 @@ -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 @@ -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(); @@ -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 @@ -1119,7 +1094,7 @@ 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)); @@ -1127,7 +1102,7 @@ mod tests { .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 diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 818abc6485..323f525488 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -209,11 +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. Defaults on so private attachment URLs are not - /// bearer capabilities after membership is revoked. - 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. @@ -740,15 +735,6 @@ 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(true); - let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() .and_then(|v| v.parse::().ok()) @@ -966,7 +952,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, @@ -1035,10 +1020,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 true" - ); assert_eq!( config.media.s3_addressing_style, buzz_media::config::S3AddressingStyle::Path, diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index b409f4d942..a0dd96a1a4 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -62,11 +62,6 @@ {{- if not .Values.relay.requireRelayMembership }} ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. {{- end }} -{{- if not .Values.relay.requireMediaGetAuth }} - ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - Anyone who learns a media URL/hash can fetch private attachments. Only - use for local development or fully public communities. -{{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. You must run `buzz-admin migrate` against the database before every diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 67a93138c5..094cf11b7e 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -130,7 +130,6 @@ spec: - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } {{- if .Values.relay.corsOrigins }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index cf08210781..196a4a5303 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -48,17 +48,6 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml - # Security default: media GET/HEAD reads must be auth-gated out of the - # box. A private attachment must never be publicly readable by URL/hash - # in an unmodified render. If this assertion fails, someone flipped the - # default — treat that as a security regression, not a config tweak. - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "true" - template: templates/deployment.yaml - - it: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com @@ -85,24 +74,6 @@ tests: value: "virtual" template: templates/deployment.yaml - - it: lets an explicit value opt out of media read auth for dev/public deployments - set: - relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" - externalPostgresql.url: postgres://u:p@h:5432/d - externalRedis.url: redis://h:6379 - s3.endpoint: http://minio:9000 - s3.accessKey: a - s3.secretKey: s - relay.requireMediaGetAuth: false - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "false" - template: templates/deployment.yaml - - it: lets an explicit value disable huddle audio in a single-replica render set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 9cb6a02c9b..5cb3e5e42c 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -61,7 +61,6 @@ "sendBuffer": { "type": "integer", "minimum": 1 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, - "requireMediaGetAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" }, "huddleAudioAvailable": { "type": ["boolean", "null"], diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 810f8a9658..71402d091a 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -107,12 +107,6 @@ relay: sendBuffer: 1000 requireAuthToken: true requireRelayMembership: true - # Authenticated media reads: relay GET/HEAD /media/* requires Blossom - # kind 24242 t=get plus relay membership. Enabled by default so private - # attachments are never publicly readable by URL/hash. Only set false for - # local development or fully public communities — desktop, mobile, and CLI - # clients all attach read auth. - requireMediaGetAuth: true allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b340238..da72c5d634 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -350,11 +350,9 @@ pub(crate) fn sign_blossom_get_auth_header( /// Mint a `t=get` Authorization header value for a relay media fetch, or /// `None` when signing is unavailable (identity in recovery mode). /// -/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_GET_AUTH` flag -/// is off, an unauthenticated request still succeeds, so degrading to no -/// header (instead of erroring) keeps media rendering during key recovery. -/// Once the flag is on, these requests will 403 — the correct outcome for an -/// identity that can't prove membership. +/// When signing is unavailable, callers send no header and the relay rejects +/// the read. This keeps recovery mode from accidentally treating a media URL +/// as a bearer capability. /// /// Safety contract: callers must only attach the returned header to URLs /// constructed from (or validated against) the app's own relay base URL — diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..f49cbdd71a 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -54,9 +54,9 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback, unreferenced hashes, malformed paths, and cross-community substitutions all collapse to `404`. -Only `GET` and `HEAD` are routed. Existing community `/media/*` authorization is -unchanged, including `BUZZ_REQUIRE_MEDIA_GET_AUTH`; the browser receives no -Blossom credential or reusable signed URL. Responses are uncached, `nosniff`, +Only `GET` and `HEAD` are routed. Community `/media/*` reads always require +Blossom authorization and relay membership; the browser receives no reusable +signed URL. Responses are uncached, `nosniff`, governed by a restrictive CSP, streamed from object storage, and non-previewable content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback From aa161da341e0a2b24327fd3599663d8c1223bb0d Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Tue, 4 Aug 2026 10:26:45 -0700 Subject: [PATCH 3/6] feat(relay): warn when inert media read-auth env vars are set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media reads are now unconditionally authenticated, so BUZZ_REQUIRE_MEDIA_GET_AUTH no longer does anything. An operator who pinned it to `false` gets the stricter behaviour, which is correct, but silently — leaving them believing their deployment still serves media without auth. Warn at startup instead. BUZZ_REQUIRE_MEDIA_READ_AUTH is included because .env.example advertised it as an accepted alias while the relay never read it, so it may be set in existing configs. That stale line is gone now; note the removal in .env.example so operators grepping for either name find the answer. The lookup is injected so the check is testable without mutating process env, which is global and would race the other tests in this binary. Co-Authored-By: Claude Opus 5 Signed-off-by: Eli Foster --- .env.example | 3 ++ crates/buzz-relay/src/config.rs | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/.env.example b/.env.example index 01d9fcb4f1..0f7bbba6f1 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,9 @@ BUZZ_S3_ADDRESSING_STYLE=path # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 # 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) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 323f525488..4300c68a8d 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -413,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) -> 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 { @@ -735,6 +760,14 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(30); + 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() .and_then(|v| v.parse::().ok()) @@ -983,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 + 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(); From e2ea443c860f7725d4953bff4cbacfa535b6f971 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Wed, 5 Aug 2026 14:02:33 -0700 Subject: [PATCH 4/6] test(media): authenticate blob reads in the media acceptance lane Reads now require kind:24242 `t=get` auth, so every successful GET/HEAD/thumbnail/video/range/missing-object case in the media lane has to mint one. Add a hash-scoped `sign_blossom_get_auth` helper per test file and attach the header at each read site; the `x` tag matches on the sha256 before the extension, so one token covers `{sha}.jpg` and `{sha}.thumb.jpg` alike. Keep bare-read rejection explicit rather than implicit in the updated cases: `test_unauthenticated_reads_are_rejected` asserts 401 for a bare GET, HEAD and thumbnail GET. Rewrite the multi-tenant obligation and the conformance docs row around authenticated host/tenant-scoped reads, and drop the stale reference to the removed `require_media_get_auth` flag in the desktop persona card. Co-Authored-By: Claude Opus 5 --- .../tests/conformance_multitenant.rs | 22 +++-- crates/buzz-test-client/tests/e2e_media.rs | 90 ++++++++++++++++++- .../tests/e2e_media_extended.rs | 27 +++++- .../buzz-test-client/tests/e2e_media_video.rs | 35 +++++++- .../src-tauri/src/commands/personas/card.rs | 6 +- docs/multi-tenant-conformance.md | 2 +- 6 files changed, 167 insertions(+), 15 deletions(-) diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..4c8c8904ac 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -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", ); } } diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 14001f641c..690fd9c8a5 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -48,6 +48,26 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event for the given sha256. +/// +/// Reads are authenticated unconditionally, so every successful GET/HEAD in this +/// file has to present one of these. The `x` tag is hash-scoped and covers the +/// derived paths too -- the relay matches on the sha256 before the extension, so +/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + /// Build `Authorization: Nostr ` header value. fn blossom_auth_header(event: &nostr::Event) -> String { format!( @@ -144,10 +164,14 @@ async fn test_upload_and_get() { descriptor["dim"], descriptor["blurhash"] ); + // Reads are authenticated, so mint one hash-scoped token for all three below. + let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)); + // GET /media/{sha256}.jpg — bytes must match let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); let get_resp = client .get(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("GET /media/{sha256}.jpg failed"); @@ -162,6 +186,7 @@ async fn test_upload_and_get() { // HEAD /media/{sha256}.jpg — must return 200 with content-type let head_resp = client .head(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("HEAD /media/{sha256}.jpg failed"); @@ -175,6 +200,7 @@ async fn test_upload_and_get() { let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); let thumb_resp = client .get(&thumb_url) + .header("Authorization", &read_auth) .send() .await .expect("GET thumbnail failed"); @@ -293,19 +319,69 @@ async fn test_upload_hash_mismatch_returns_400() { assert_eq!(resp.status(), 401, "hash mismatch must be 401"); } -/// GET a sha256 that was never uploaded must return 404. +/// GET an authenticated sha256 that was never uploaded must return 404. +/// +/// The token has to be valid for the 404 to be reachable at all: authentication +/// runs before the storage lookup, so a bare request is rejected with 401 and +/// never distinguishes "missing" from "unauthorized" (see +/// `test_unauthenticated_reads_are_rejected`). #[tokio::test] #[ignore] async fn test_get_nonexistent_returns_404() { let client = http_client(); + let keys = Keys::generate(); let missing_sha256 = "0".repeat(64); let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); - let resp = client.get(&url).send().await.expect("GET failed"); + let resp = client + .get(&url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)), + ) + .send() + .await + .expect("GET failed"); println!("missing blob → {}", resp.status()); assert_eq!(resp.status(), 404, "missing blob must be 404"); } +/// Bare reads are rejected with 401 before any storage lookup. +/// +/// This is the boundary PR #4610 made unconditional: there is no longer a config +/// flag that lets an unauthenticated GET through, so the acceptance lane has to +/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401 +/// here rather than a 404 proves auth runs ahead of the storage lookup and that the +/// endpoint does not leak blob existence to an unauthenticated caller. +#[tokio::test] +#[ignore] +async fn test_unauthenticated_reads_are_rejected() { + let client = http_client(); + let missing_sha256 = "0".repeat(64); + let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); + let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url()); + + let get_resp = client.get(&blob_url).send().await.expect("bare GET failed"); + println!("bare GET → {}", get_resp.status()); + assert_eq!(get_resp.status(), 401, "bare GET must be 401"); + + let head_resp = client + .head(&blob_url) + .send() + .await + .expect("bare HEAD failed"); + println!("bare HEAD → {}", head_resp.status()); + assert_eq!(head_resp.status(), 401, "bare HEAD must be 401"); + + let thumb_resp = client + .get(&thumb_url) + .send() + .await + .expect("bare thumbnail GET failed"); + println!("bare thumbnail GET → {}", thumb_resp.status()); + assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401"); +} + /// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). /// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. #[tokio::test] @@ -363,7 +439,15 @@ async fn test_upload_real_image() { // GET bytes back and verify let get_url = descriptor["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET failed"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET failed"); assert_eq!(get_resp.status(), 200); let returned = get_resp.bytes().await.unwrap(); assert_eq!( diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 955bd9d6c4..74a2c444cf 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -39,6 +39,21 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .unwrap() } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so round-trip GETs must present one of these. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -168,9 +183,14 @@ async fn test_upload_png_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".png")); println!("✅ PNG upload: {}", desc["url"]); - // GET back + // GET back — reads are authenticated, so scope a token to the uploaded hash. + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); @@ -192,8 +212,13 @@ async fn test_upload_gif_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".gif")); println!("✅ GIF upload: {}", desc["url"]); + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 64a5878f13..2ec0b1e698 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -40,6 +40,23 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so blob and range GETs must present one of these -- without it +/// the 206 and 416 range behaviour below would never be reached. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -272,7 +289,15 @@ async fn test_video_upload_and_get() { // GET the blob back let get_url = desc["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET blob"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET blob"); assert_eq!(get_resp.status(), StatusCode::OK); let body = get_resp.bytes().await.expect("body bytes"); assert_eq!(body.len(), mp4.len()); @@ -345,6 +370,10 @@ async fn test_video_range_request_206() { // Range request: first 100 bytes let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header("Range", "bytes=0-99") .send() .await @@ -389,6 +418,10 @@ async fn test_video_range_request_416() { // Request a range beyond the file size let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header( "Range", format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 29a5c35e6a..14c7c196b2 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -668,9 +668,9 @@ pub async fn mint_agent_card( .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, Some(url) if url.starts_with("http://") || url.starts_with("https://") => { // Relay-hosted avatars (kind:0 pictures under the relay's /media/) - // may require Blossom get-auth (`require_media_get_auth`). Mint the - // header ONLY for same-origin URLs so the token never leaves the - // relay (same contract as `media_download.rs`). + // require Blossom get-auth. Mint the header ONLY for same-origin URLs + // so the token never leaves the relay (same contract as + // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); let auth = is_same_origin(url, &relay_base) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 3cd56066eb..d8877b5931 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -49,7 +49,7 @@ Conformance obligations: | Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | | Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. | | Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | -| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | +| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; `GET/HEAD /media/{sha256.ext}` requires a Blossom `t=get` auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has `channel_id = None`. | Upload and read request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. | Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient. | | Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | | Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | | Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. | From 171c360da570b72fe5f04c22f87e7f854dac5181 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Wed, 5 Aug 2026 14:38:33 -0700 Subject: [PATCH 5/6] ci: run the media read-auth e2e lane The `e2e_media`, `e2e_media_extended` and `e2e_media_video` binaries were `#[ignore]`d and selected by no job, so nothing verified that a real relay rejects bare reads or honours host- and hash-scoped `t=get` tokens. Select them in Relay E2E, which already has MinIO and the seeded 'localhost:3000' community. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60507182d5..3f962b31cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -768,6 +768,17 @@ 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. + run: | + cargo test -p buzz-test-client --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 From ddde9e9545ed08951a06103d788fcd3026f66e62 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Wed, 5 Aug 2026 15:11:16 -0700 Subject: [PATCH 6/6] test(media): strip pHYs from the PNG fixture and stop hiding lane failures The lane's first CI run failed `test_upload_png_roundtrip` with 422: the ffmpeg-generated fixture carries a pHYs chunk, and `validate_png_metadata_free` rejects pHYs as an identity channel. Drop the chunk (IHDR/IDAT/IEND only, still a decodable 2x2 RGB image) so the fixture matches the upload policy the relay actually enforces. Add --no-fail-fast to the lane: cargo stopped after e2e_media_extended failed, so e2e_media_video never reported at all. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 +++- crates/buzz-test-client/tests/e2e_media_extended.rs | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f962b31cb..0f4ec0d6a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -774,8 +774,10 @@ jobs: # 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 --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + 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 diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 74a2c444cf..8a9283c040 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -113,15 +113,15 @@ fn tiny_jpeg() -> Vec { } fn tiny_png() -> Vec { - // Valid 2x2 red PNG generated by ffmpeg + // Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped: + // `validate_png_metadata_free` rejects pHYs as an identity channel, so the + // original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only. vec![ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, - 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, - 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, - 0xae, 0x42, 0x60, 0x82, + 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, + 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, + 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ] }