Skip to content

fix(media): use ciphertext-compatible Blossom defaults#852

Open
mubarakcoded wants to merge 1 commit into
masterfrom
fix/blossom-415-encrypted-media
Open

fix(media): use ciphertext-compatible Blossom defaults#852
mubarakcoded wants to merge 1 commit into
masterfrom
fix/blossom-415-encrypted-media

Conversation

@mubarakcoded

@mubarakcoded mubarakcoded commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace the Primal-only encrypted-media default with an ordered list of Blossom endpoints verified to accept opaque application/octet-stream ciphertext
  • preserve bounded, privacy-filtered Blossom rejection reasons so client errors explain failures such as HTTP 415
  • document that endpoint policy is signed group state and that existing groups require an admin migration

Root cause

MDK encrypts media before upload, so the Blossom request body is intentionally indistinguishable from random bytes and is sent as application/octet-stream. The previous built-in endpoint, https://blossom.primal.net, rejects that payload with HTTP 415. Android and iOS both delegate endpoint selection to MDK/the group's encrypted-media component, so the shared fix belongs here.

Existing groups

The defaults apply when creating new groups. Upgrading MDK does not rewrite signed state for existing groups; an active group admin must call replace_encrypted_media_blob_endpoints to migrate a group whose embedded endpoints reject encrypted blobs.

Verification

  • cargo test -p marmot-app: 323 unit, 11 audit-log, and 68 relay-runtime tests passed
  • focused media suite: 36 passed
  • endpoint policy integration regression: passed
  • just fast-ci: formatting, naming gate, workspace checks, feature checks, and Clippy all passed
  • authenticated live probes using 1 KiB cryptographically random payloads: each selected endpoint accepted application/octet-stream; successful probes were deleted immediately

Closes #851


Open in Stage

Summary by CodeRabbit

  • Bug Fixes

    • Encrypted media and group-image uploads now use an ordered set of compatible upload endpoints, improving fallback reliability.
    • Upload errors provide privacy-filtered server reasons when available, while removing sensitive details.
    • Prevented unsupported media upload responses caused by incompatible endpoints.
  • Documentation

    • Updated CLI and app documentation to explain endpoint selection, encrypted uploads, and endpoint migration requirements.

@stage-review

stage-review Bot commented Jul 13, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 5 individual chapters for you:

Title
1 Define ciphertext-compatible Blossom default endpoints
2 Implement privacy-filtered Blossom error reporting
3 Wire default endpoints into group state
4 Verify endpoint defaults and error filtering
5 Update documentation and changelog
Open in Stage

Chapters generated by Stage for commit 75a90d1 on Jul 13, 2026 3:26pm UTC.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The PR replaces the single default encrypted-media Blossom endpoint with an ordered endpoint list, embeds that policy into new groups, documents migration and configuration behavior, and preserves bounded privacy-filtered server rejection reasons for failed uploads.

Encrypted media upload behavior

Layer / File(s) Summary
Default endpoint policy and group wiring
crates/marmot-app/src/media/*, crates/marmot-app/src/client/*, crates/marmot-app/src/lib.rs, crates/marmot-app/tests/*
New groups use the ordered built-in Blossom endpoints unless configured otherwise, with the endpoint list publicly re-exported and validated by tests.
Upload diagnostics and validation
crates/marmot-app/src/media/blossom.rs, crates/marmot-app/src/media/tests.rs
Failed uploads extract bounded reasons from headers or bodies, sanitize sensitive content, and test both retained and filtered diagnostics.
Documentation and release notes
crates/cli/README.md, crates/marmot-app/README.md, crates/cli/CHANGELOG.md
CLI and application documentation describe ciphertext-compatible endpoints, signed group policy, configuration, migration, and the updated failure behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: switching media uploads to ciphertext-compatible Blossom defaults.
Linked Issues check ✅ Passed The changes satisfy #851 by replacing the default Blossom endpoint, preserving failover, adding migration guidance, privacy-safe diagnostics, and tests.
Out of Scope Changes check ✅ Passed The changes stay focused on encrypted-media Blossom defaults, diagnostics, tests, and documentation with no obvious unrelated additions.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/blossom-415-encrypted-media

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/marmot-app/README.md`:
- Around line 60-63: Document the endpoint contracts separately: in
crates/marmot-app/README.md (lines 60-63), state that ordered endpoint selection
and failover apply to encrypted-media uploads, while encrypted group-image
uploads use only the primary ciphertext-compatible endpoint; in
crates/cli/CHANGELOG.md (lines 20-22), revise the release note to describe the
same distinct behaviors.

In `@crates/marmot-app/src/media/blossom.rs`:
- Around line 110-135: Update privacy_safe_server_reason to reject any URL
scheme pattern, including ws://, wss://, ftp://, file://, and other
scheme-prefixed URLs, rather than only checking HTTP and HTTPS. Preserve the
existing normalization and sensitive-token filters while ensuring relay URLs
cannot pass through into AppError messages.

In `@crates/marmot-app/tests/relay_runtime.rs`:
- Around line 4102-4115: The default endpoint assertion in the relay runtime
test must account for MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS overrides. Update
the expected endpoints to use the effective configured endpoint list, or isolate
this test from the override while preserving its admin-only/full-replacement
coverage; do not hard-code DEFAULT_BLOSSOM_SERVER_URLS when an override is
active.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 74c66598-e150-4e3d-9666-0f26d09e02a5

📥 Commits

Reviewing files that changed from the base of the PR and between 894e768 and 75a90d1.

📒 Files selected for processing (9)
  • crates/cli/CHANGELOG.md
  • crates/cli/README.md
  • crates/marmot-app/README.md
  • crates/marmot-app/src/client/mod.rs
  • crates/marmot-app/src/lib.rs
  • crates/marmot-app/src/media/blossom.rs
  • crates/marmot-app/src/media/mod.rs
  • crates/marmot-app/src/media/tests.rs
  • crates/marmot-app/tests/relay_runtime.rs

Comment on lines +60 to +63
Encrypted media and encrypted group images are uploaded as opaque `application/octet-stream` blobs. A compatible
Blossom server must accept arbitrary binary data rather than only recognizable image, audio, or video payloads. New
groups use the ordered built-in ciphertext-compatible endpoint list unless the host build supplies
`MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS`; upload attempts follow that order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the two endpoint-selection contracts separately.

The current documentation conflates ordered encrypted-media failover with single-endpoint encrypted group-image upload.

  • crates/marmot-app/README.md#L60-L63: state that ordered endpoint selection applies to encrypted media, while group-image upload uses the primary ciphertext-compatible endpoint.
  • crates/cli/CHANGELOG.md#L20-L22: revise the release note to match those distinct behaviors.
📍 Affects 2 files
  • crates/marmot-app/README.md#L60-L63 (this comment)
  • crates/cli/CHANGELOG.md#L20-L22
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/marmot-app/README.md` around lines 60 - 63, Document the endpoint
contracts separately: in crates/marmot-app/README.md (lines 60-63), state that
ordered endpoint selection and failover apply to encrypted-media uploads, while
encrypted group-image uploads use only the primary ciphertext-compatible
endpoint; in crates/cli/CHANGELOG.md (lines 20-22), revise the release note to
describe the same distinct behaviors.

Comment on lines +110 to +135
fn privacy_safe_server_reason(reason: &str) -> Option<String> {
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
if reason.is_empty()
|| reason.len() > 256
|| !reason
.chars()
.all(|character| character.is_ascii() && !character.is_ascii_control())
{
return None;
}
let lowercase = reason.to_ascii_lowercase();
if ["http://", "https://", "nostr:", "npub1", "nsec1"]
.iter()
.any(|needle| lowercase.contains(needle))
{
return None;
}
if reason.split_ascii_whitespace().any(|token| {
let token = token.trim_matches(|character: char| !character.is_ascii_alphanumeric());
(token.len() >= 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit()))
|| (token.len() >= 48 && token.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}) {
return None;
}
Some(reason)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

privacy_safe_server_reason misses ws:///wss:// (and other) URL schemes.

The scheme-substring filter only checks "http://"/"https://", so a hostile/compromised Blossom server can still smuggle a wss://… (or ftp://, file://, etc.) relay-shaped URL through X-Reason or the body into the resulting AppError — undercutting this helper's own stated goal of keeping "blob hashes, pubkeys, URLs" out of app errors, and the repo guideline to never log relay URLs.

As per coding guidelines, **/*.rs: "Keep tracing and logging privacy-safe: use explicit crate/module target and method fields, aggregate values only, and never log account IDs, group IDs, message IDs, relay URLs, public keys, payloads, ciphertext, plaintext, or key material."

🛡️ Proposed fix: filter any URL scheme, not just http(s)
     let lowercase = reason.to_ascii_lowercase();
-    if ["http://", "https://", "nostr:", "npub1", "nsec1"]
+    if ["://", "nostr:", "npub1", "nsec1"]
         .iter()
         .any(|needle| lowercase.contains(needle))
     {
         return None;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn privacy_safe_server_reason(reason: &str) -> Option<String> {
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
if reason.is_empty()
|| reason.len() > 256
|| !reason
.chars()
.all(|character| character.is_ascii() && !character.is_ascii_control())
{
return None;
}
let lowercase = reason.to_ascii_lowercase();
if ["http://", "https://", "nostr:", "npub1", "nsec1"]
.iter()
.any(|needle| lowercase.contains(needle))
{
return None;
}
if reason.split_ascii_whitespace().any(|token| {
let token = token.trim_matches(|character: char| !character.is_ascii_alphanumeric());
(token.len() >= 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit()))
|| (token.len() >= 48 && token.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}) {
return None;
}
Some(reason)
}
fn privacy_safe_server_reason(reason: &str) -> Option<String> {
let reason = reason.split_whitespace().collect::<Vec<_>>().join(" ");
if reason.is_empty()
|| reason.len() > 256
|| !reason
.chars()
.all(|character| character.is_ascii() && !character.is_ascii_control())
{
return None;
}
let lowercase = reason.to_ascii_lowercase();
if ["://", "nostr:", "npub1", "nsec1"]
.iter()
.any(|needle| lowercase.contains(needle))
{
return None;
}
if reason.split_ascii_whitespace().any(|token| {
let token = token.trim_matches(|character: char| !character.is_ascii_alphanumeric());
(token.len() >= 32 && token.bytes().all(|byte| byte.is_ascii_hexdigit()))
|| (token.len() >= 48 && token.bytes().all(|byte| byte.is_ascii_alphanumeric()))
}) {
return None;
}
Some(reason)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/marmot-app/src/media/blossom.rs` around lines 110 - 135, Update
privacy_safe_server_reason to reject any URL scheme pattern, including ws://,
wss://, ftp://, file://, and other scheme-prefixed URLs, rather than only
checking HTTP and HTTPS. Preserve the existing normalization and sensitive-token
filters while ensuring relay URLs cannot pass through into AppError messages.

Source: Coding guidelines

Comment on lines +4102 to +4115
let initial_group = app.group("alice", &group_id_hex).unwrap().unwrap();
let initial_endpoints = initial_group
.encrypted_media
.default_blob_endpoints
.iter()
.map(|endpoint| endpoint.base_url.as_str())
.collect::<Vec<_>>();
assert_eq!(
initial_endpoints,
marmot_app::DEFAULT_BLOSSOM_SERVER_URLS
.iter()
.map(|endpoint| format!("{endpoint}/"))
.collect::<Vec<_>>()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the default-endpoint assertion honor supported overrides.

MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS can replace the built-in list, but this test always expects DEFAULT_BLOSSOM_SERVER_URLS. A build using that supported override will fail before testing admin-only/full-replacement behavior. Either isolate the test from the override or assert the effective configured endpoint list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/marmot-app/tests/relay_runtime.rs` around lines 4102 - 4115, The
default endpoint assertion in the relay runtime test must account for
MARMOT_ENCRYPTED_MEDIA_BLOB_ENDPOINTS overrides. Update the expected endpoints
to use the effective configured endpoint list, or isolate this test from the
override while preserving its admin-only/full-replacement coverage; do not
hard-code DEFAULT_BLOSSOM_SERVER_URLS when an override is active.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encrypted media uploads fail with HTTP 415 against default Blossom endpoint

2 participants