mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
This change requires a valid signed Blossom authorization request and current relay membership for every media GET and HEAD request. It removes the unauthenticated compatibility path and updates desktop reads to send the required authorization. This blocks anonymous retrieval and access after relay-membership revocation. It does not yet bind a blob to its originating channel, so someone removed from a private channel can still read a known blob while remaining a relay member. That channel-ACL follow-up remains required before closing the full finding. ## Testing - `git diff --check origin/main...codex/security-media-read-auth` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom <jm@squareup.com> Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> Signed-off-by: Eli Foster <efoster@squareup.com> Co-authored-by: Eli Foster <efoster@squareup.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
461 lines
17 KiB
Rust
461 lines
17 KiB
Rust
//! End-to-end media upload tests (Blossom / NIP-96 style).
|
||
//!
|
||
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
|
||
//! All tests are `#[ignore]` so they don't run in CI by default.
|
||
//!
|
||
//! # Running
|
||
//!
|
||
//! ```text
|
||
//! cargo test -p buzz-test-client --test e2e_media -- --ignored --nocapture
|
||
//! ```
|
||
//!
|
||
//! Override the relay URL:
|
||
//!
|
||
//! ```text
|
||
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p buzz-test-client --test e2e_media -- --ignored
|
||
//! ```
|
||
|
||
use std::time::Duration;
|
||
|
||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||
use reqwest::Client;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
fn relay_http_url() -> String {
|
||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||
}
|
||
|
||
fn http_client() -> Client {
|
||
Client::builder()
|
||
.timeout(Duration::from_secs(15))
|
||
.build()
|
||
.expect("failed to build HTTP client")
|
||
}
|
||
|
||
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
|
||
fn sign_blossom_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", "upload"]).expect("t tag"),
|
||
Tag::parse(["x", sha256]).expect("x tag"),
|
||
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
|
||
];
|
||
EventBuilder::new(Kind::from(24242), "Upload test")
|
||
.tags(tags)
|
||
.sign_with_keys(keys)
|
||
.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 <base64url(json)>` header value.
|
||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||
format!(
|
||
"Nostr {}",
|
||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||
)
|
||
}
|
||
|
||
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
|
||
fn tiny_jpeg() -> Vec<u8> {
|
||
vec![
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||
0xFF, 0xD9,
|
||
]
|
||
}
|
||
|
||
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
|
||
/// Also checks BlobDescriptor fields and thumbnail endpoint.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_and_get() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
println!("sha256: {sha256}");
|
||
println!("relay: {}", relay_http_url());
|
||
|
||
// Standards-compliant BUD-02 upload route.
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &sha256)
|
||
.body(jpeg.clone())
|
||
.send()
|
||
.await
|
||
.expect("upload PUT failed");
|
||
|
||
let status = resp.status();
|
||
let body_text = resp.text().await.unwrap_or_default();
|
||
println!("PUT /upload → {status}: {body_text}");
|
||
assert_eq!(status, 200, "upload should succeed");
|
||
|
||
// Parse BlobDescriptor
|
||
let descriptor: serde_json::Value =
|
||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||
println!("BlobDescriptor: {descriptor:#}");
|
||
|
||
assert_eq!(
|
||
descriptor["sha256"].as_str().unwrap(),
|
||
sha256,
|
||
"sha256 must match"
|
||
);
|
||
assert!(
|
||
descriptor["url"].as_str().unwrap().contains(&sha256),
|
||
"url must contain sha256"
|
||
);
|
||
assert!(
|
||
descriptor["size"].as_u64().unwrap() > 0,
|
||
"size must be positive"
|
||
);
|
||
assert!(
|
||
descriptor["type"].as_str().is_some(),
|
||
"mime type must be present"
|
||
);
|
||
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
|
||
println!(
|
||
"dim: {:?}, blurhash: {:?}",
|
||
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");
|
||
assert_eq!(get_resp.status(), 200, "GET should return 200");
|
||
let returned_bytes = get_resp.bytes().await.unwrap();
|
||
assert_eq!(
|
||
returned_bytes.as_ref(),
|
||
jpeg.as_slice(),
|
||
"GET must return original bytes"
|
||
);
|
||
|
||
// 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");
|
||
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
|
||
assert!(
|
||
head_resp.headers().get("content-type").is_some(),
|
||
"HEAD must include content-type"
|
||
);
|
||
|
||
// GET thumbnail — /media/{sha256}.thumb.jpg
|
||
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");
|
||
println!("GET thumbnail → {}", thumb_resp.status());
|
||
// Thumbnail may be same as original for 1x1 images — just check 200
|
||
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
|
||
}
|
||
|
||
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_idempotent() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
let upload = |keys: &Keys| {
|
||
let auth = sign_blossom_auth(keys, &sha256);
|
||
client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", sha256.clone())
|
||
.body(jpeg.clone())
|
||
.send()
|
||
};
|
||
|
||
let r1: serde_json::Value = upload(&keys)
|
||
.await
|
||
.expect("first upload failed")
|
||
.json()
|
||
.await
|
||
.expect("first descriptor parse");
|
||
|
||
// Second upload — different key, same content
|
||
let keys2 = Keys::generate();
|
||
let r2: serde_json::Value = upload(&keys2)
|
||
.await
|
||
.expect("second upload failed")
|
||
.json()
|
||
.await
|
||
.expect("second descriptor parse");
|
||
|
||
assert_eq!(
|
||
r1["sha256"], r2["sha256"],
|
||
"sha256 must be identical on re-upload"
|
||
);
|
||
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
|
||
}
|
||
|
||
/// Upload without an Authorization header must return 401.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_no_auth_returns_401() {
|
||
let client = http_client();
|
||
let jpeg = tiny_jpeg();
|
||
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Content-Type", "image/jpeg")
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("no-auth → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "upload without auth must be 401");
|
||
}
|
||
|
||
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_missing_x_sha256_returns_401() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
// Intentionally omit X-SHA-256
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("missing-x-sha256 → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
|
||
}
|
||
|
||
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
|
||
#[tokio::test]
|
||
#[ignore]
|
||
async fn test_upload_hash_mismatch_returns_400() {
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let jpeg = tiny_jpeg();
|
||
let wrong_hash = "f".repeat(64); // definitely not the real sha256
|
||
|
||
let auth = sign_blossom_auth(&keys, &wrong_hash);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &wrong_hash)
|
||
.body(jpeg)
|
||
.send()
|
||
.await
|
||
.expect("request failed");
|
||
|
||
println!("hash-mismatch → {}", resp.status());
|
||
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
|
||
}
|
||
|
||
/// 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)
|
||
.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]
|
||
#[ignore]
|
||
async fn test_upload_real_image() {
|
||
let image_path = match std::env::var("TEST_IMAGE_PATH") {
|
||
Ok(p) => p,
|
||
Err(_) => {
|
||
println!("Skipping: TEST_IMAGE_PATH not set");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let client = http_client();
|
||
let keys = Keys::generate();
|
||
let bytes = std::fs::read(&image_path).expect("read image file");
|
||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||
let size = bytes.len();
|
||
|
||
println!("image: {image_path}");
|
||
println!("size: {size} bytes");
|
||
println!("sha256: {sha256}");
|
||
|
||
let auth = sign_blossom_auth(&keys, &sha256);
|
||
let resp = client
|
||
.put(format!("{}/media/upload", relay_http_url()))
|
||
.header("Authorization", blossom_auth_header(&auth))
|
||
.header("Content-Type", "image/jpeg")
|
||
.header("X-SHA-256", &sha256)
|
||
.body(bytes.clone())
|
||
.send()
|
||
.await
|
||
.expect("upload PUT failed");
|
||
|
||
let status = resp.status();
|
||
let body_text = resp.text().await.unwrap_or_default();
|
||
println!("PUT /media/upload → {status}: {body_text}");
|
||
assert_eq!(status, 200, "upload should succeed");
|
||
|
||
let descriptor: serde_json::Value =
|
||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||
println!("BlobDescriptor: {descriptor:#}");
|
||
|
||
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
|
||
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
|
||
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
|
||
assert!(
|
||
descriptor["dim"].as_str().is_some(),
|
||
"real image should have dim"
|
||
);
|
||
assert!(
|
||
descriptor["blurhash"].as_str().is_some(),
|
||
"real image should have blurhash"
|
||
);
|
||
|
||
// GET bytes back and verify
|
||
let get_url = descriptor["url"].as_str().unwrap();
|
||
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!(
|
||
returned.as_ref(),
|
||
bytes.as_slice(),
|
||
"GET must return original bytes"
|
||
);
|
||
|
||
println!("✅ Real image upload round-trip passed");
|
||
}
|