mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
fix(desktop): enforce the markdown viewer's 2 MiB cap natively during fetch
Routing the viewer through the generic fetch_media_bytes command meant an attachment with a forged or absent imeta size could occupy up to the 50 MiB download cap in memory and IPC before the frontend decoder rejected it. Add a markdown-specific fetch_markdown_doc_bytes command whose 2 MiB cap is owned by Rust: an oversized Content-Length is refused before the body is read, and the streamed byte count aborts mid-transfer when the header is missing or dishonest. The untrusted imeta pre-gate stays UX-only. The cap checks are extracted into pure helpers (declared_length_refusal_error, append_chunk_within_cap) so both enforcement points are unit-tested without a Tauri State; media_download.rs tests move to a sibling file per the repo's _tests.rs convention to stay under the per-file line cap. The panel maps the native refusal onto the existing too-large download fallback, and new e2e coverage proves the forged-size fallback plus the reload/back-forward contract. The viewer command skips MIME sniffing deliberately: bytes are strictly UTF-8-decoded and rendered escaped, and a legitimate .md may begin with bytes that sniff as a blocked type. Signed-off-by: Riley Crane <rileycrane@squareup.com> Co-authored-by: Fizz <d4ec90109b43092b0ec94cf136850915a3732a29ab3089a1dec43d2ed43dd6eb@buzz.block.builderlab.xyz> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,15 @@ use crate::relay::{classify_request_error, relay_api_base_url_with_override, rel
|
||||
/// Maximum download size: 50 MiB. Prevents OOM from oversized responses.
|
||||
const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Maximum markdown-document size for the in-app viewer: 2 MiB.
|
||||
///
|
||||
/// This is the *enforcement* boundary for the viewer — the frontend's
|
||||
/// matching `MAX_MARKDOWN_DOC_BYTES` pre-gate reads the untrusted imeta
|
||||
/// `size` field and is UX-only (a forged or absent size must not buy a
|
||||
/// larger fetch). Keep in sync with
|
||||
/// `desktop/src/shared/ui/markdown/markdownDocFile.ts`.
|
||||
const MAX_MARKDOWN_DOC_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
/// Download request timeout.
|
||||
const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
@@ -165,6 +174,35 @@ pub async fn fetch_media_bytes(
|
||||
Ok(tauri::ipc::Response::new(bytes))
|
||||
}
|
||||
|
||||
/// Fetch a markdown document attachment for the in-app viewer, enforcing the
|
||||
/// viewer's 2 MiB ceiling natively during the fetch.
|
||||
///
|
||||
/// The generic `fetch_media_bytes` command streams up to the 50 MiB download
|
||||
/// cap; routing the viewer through it would let an attachment with a forged
|
||||
/// or absent imeta `size` occupy 50 MiB of memory and IPC before the
|
||||
/// frontend's decoder rejected it. This command makes the 2 MiB limit a
|
||||
/// native boundary: Content-Length over the cap is refused before the body
|
||||
/// is read, and the streamed byte count aborts mid-transfer when the header
|
||||
/// is missing or dishonest.
|
||||
///
|
||||
/// Deliberately skips `detect_and_validate_mime`: the bytes are only ever
|
||||
/// strictly UTF-8-decoded and rendered through the escaping markdown
|
||||
/// pipeline (never handed to the webview as a document), and a legitimate
|
||||
/// `.md` file may open with bytes that sniff as a blocked type — e.g. an
|
||||
/// SVG snippet pasted at the top of the file. Binary payloads fail the
|
||||
/// frontend's fatal UTF-8 decode and fall back to download.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_markdown_doc_bytes(
|
||||
url: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
let relay_base = relay_api_base_url_with_override(&state);
|
||||
validate_download_url(&url, &relay_base)?;
|
||||
|
||||
let bytes = fetch_blob_bytes_with_cap(&url, &state, MAX_MARKDOWN_DOC_BYTES).await?;
|
||||
Ok(tauri::ipc::Response::new(bytes))
|
||||
}
|
||||
|
||||
/// Copy an image from a relay media URL directly to the system clipboard.
|
||||
///
|
||||
/// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via
|
||||
@@ -307,14 +345,8 @@ async fn fetch_blob_bytes_with_cap(
|
||||
}
|
||||
|
||||
// Check Content-Length header upfront if present.
|
||||
if let Some(content_length) = resp.content_length() {
|
||||
if content_length > cap {
|
||||
return Err(format!(
|
||||
"file too large ({} MiB, max {} MiB)",
|
||||
content_length / (1024 * 1024),
|
||||
cap / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
if let Some(err) = declared_length_refusal_error(resp.content_length(), cap) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Stream the response with a running byte count to enforce the size cap
|
||||
@@ -323,15 +355,43 @@ async fn fetch_blob_bytes_with_cap(
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| classify_request_error(&e))?;
|
||||
if bytes.len() as u64 + chunk.len() as u64 > cap {
|
||||
return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024)));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
append_chunk_within_cap(&mut bytes, &chunk, cap)?;
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The refusal for a declared Content-Length that exceeds the byte cap, or
|
||||
/// `None` when the header is absent or within bounds. An absent header is
|
||||
/// allowed through — `append_chunk_within_cap` still enforces the cap on the
|
||||
/// streamed bytes, so a missing or dishonest length never buys a larger
|
||||
/// download. Pulled out of `fetch_blob_bytes_with_cap` so the pre-body
|
||||
/// refusal is unit-testable without a Tauri `State`.
|
||||
fn declared_length_refusal_error(content_length: Option<u64>, cap: u64) -> Option<String> {
|
||||
let content_length = content_length?;
|
||||
(content_length > cap).then(|| {
|
||||
format!(
|
||||
"file too large ({} MiB, max {} MiB)",
|
||||
content_length / (1024 * 1024),
|
||||
cap / (1024 * 1024)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Append a response chunk to the accumulator, refusing once the running
|
||||
/// total would exceed `cap`. This is the enforcement point that holds even
|
||||
/// when Content-Length is missing or dishonest: the transfer aborts
|
||||
/// mid-stream instead of buffering past the cap. Pulled out of
|
||||
/// `fetch_blob_bytes_with_cap` so the cutoff is unit-testable without a
|
||||
/// Tauri `State`.
|
||||
fn append_chunk_within_cap(bytes: &mut Vec<u8>, chunk: &[u8], cap: u64) -> Result<(), String> {
|
||||
if bytes.len() as u64 + chunk.len() as u64 > cap {
|
||||
return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024)));
|
||||
}
|
||||
bytes.extend_from_slice(chunk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The snapshot file format inferred from the sanitized filename suffix.
|
||||
/// Carries the format-specific byte cap used during bounded fetch.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -524,469 +584,5 @@ pub async fn fetch_snapshot_bytes(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_json_returns_json_kind_and_correct_cap() {
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap();
|
||||
assert_eq!(kind, SnapshotFileKind::AgentJson);
|
||||
assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_png_returns_png_kind_and_correct_cap() {
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
assert_eq!(kind, SnapshotFileKind::AgentPng);
|
||||
assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_plain_json_rejected() {
|
||||
assert!(snapshot_kind_for_filename("data.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_deceptive_name_rejected() {
|
||||
// foo.agent.json.exe must not match .agent.json
|
||||
assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_plain_png_rejected() {
|
||||
assert!(snapshot_kind_for_filename("photo.png").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_agent_json_only_rejected() {
|
||||
// "agent.json" without the leading dot — plain filename, not the suffix
|
||||
assert!(snapshot_kind_for_filename("agentjson").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() {
|
||||
let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap();
|
||||
let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap();
|
||||
assert_eq!(json, SnapshotFileKind::TeamJson);
|
||||
assert_eq!(png, SnapshotFileKind::TeamPng);
|
||||
assert_eq!(json.cap(), 25 * 1024 * 1024);
|
||||
assert_eq!(png.cap(), 50 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_team_png_filename_with_json_bytes_rejected() {
|
||||
let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#;
|
||||
let kind = snapshot_kind_for_filename("review.team.png").unwrap();
|
||||
let error = ensure_bytes_match_kind(bytes, kind).unwrap_err();
|
||||
assert!(error.contains(".team.png") && error.contains("not a PNG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_team_declared_size_over_cap_rejected() {
|
||||
let kind = snapshot_kind_for_filename("review.team.json").unwrap();
|
||||
assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok());
|
||||
let error =
|
||||
ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err();
|
||||
assert!(error.contains("25 MiB"));
|
||||
}
|
||||
|
||||
// ── Focused boundary tests: format mismatch and consistency ──────────────
|
||||
//
|
||||
// These tests exercise the guard logic that fetch_snapshot_bytes applies
|
||||
// after the bounded fetch + hash check. The validation has two layers:
|
||||
//
|
||||
// 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename)
|
||||
// must match the actual byte format (PNG magic or absence of it).
|
||||
// 2. decode_snapshot_from_bytes: rejects malformed manifests including
|
||||
// JSON with level:none + non-empty entries.
|
||||
//
|
||||
// We verify each rejection path directly — no live HTTP required.
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_png_filename_with_json_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::None,
|
||||
entries: vec![],
|
||||
},
|
||||
};
|
||||
let json_bytes = encode_snapshot_json(&snapshot).unwrap();
|
||||
// .agent.png filename → Png kind; JSON bytes must be rejected.
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
let result = ensure_bytes_match_kind(&json_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.png filename with JSON bytes must be rejected by the magic-byte guard"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("not a PNG"),
|
||||
"error must describe the mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
// This is the trust-hole case: memory-bearing JSON delivered under a
|
||||
// .agent.png label to bypass the PNG no-memory policy.
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything,
|
||||
entries: vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Secret memory.".to_string(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
let json_bytes = encode_snapshot_json(&snapshot).unwrap();
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
let result = ensure_bytes_match_kind(&json_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.png filename with memory-bearing JSON bytes must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_json_filename_with_png_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::None,
|
||||
entries: vec![],
|
||||
},
|
||||
};
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
// .agent.json filename → Json kind; PNG bytes must be rejected.
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap();
|
||||
let result = ensure_bytes_match_kind(&png_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.json filename with PNG bytes must be rejected by the magic-byte guard"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("bytes are a PNG"),
|
||||
"error must describe the mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_boundary_json_none_level_with_entries_rejected() {
|
||||
use crate::commands::personas::decode_snapshot_from_bytes;
|
||||
// Construct JSON bytes directly: level=none but entries non-empty.
|
||||
// encode_snapshot_json does not guard against this, so we can produce it.
|
||||
let raw = serde_json::json!({
|
||||
"format": "buzz-agent-snapshot",
|
||||
"version": 1,
|
||||
"definition": { "name": "test" },
|
||||
"profile": { "displayName": "Test" },
|
||||
"memory": {
|
||||
"level": "none",
|
||||
"entries": [{"slug": "core", "body": "leaked"}]
|
||||
}
|
||||
});
|
||||
let bytes = serde_json::to_vec(&raw).unwrap();
|
||||
let result = decode_snapshot_from_bytes(&bytes);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("'none' but entries are present"),
|
||||
"error must describe the consistency violation"
|
||||
);
|
||||
}
|
||||
|
||||
const RELAY_BASE: &str = "https://relay.example.com";
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_url() {
|
||||
assert!(validate_download_url(
|
||||
"https://relay.example.com/media/abcdef1234567890.jpg",
|
||||
RELAY_BASE,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_url_png() {
|
||||
assert!(
|
||||
validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_relay_origin_rejected() {
|
||||
let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_private_ip_rejected() {
|
||||
let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_loopback_rejected() {
|
||||
let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_localhost_allowed_for_localhost_relay() {
|
||||
assert!(validate_download_url(
|
||||
"http://localhost:3000/media/abc.jpg",
|
||||
"http://localhost:3000",
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_missing_media_path_rejected() {
|
||||
let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("/media/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_https_scheme_rejected() {
|
||||
let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_http_non_localhost_rejected() {
|
||||
let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_root_path_rejected() {
|
||||
let result = validate_download_url("https://relay.example.com/", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("/media/"));
|
||||
}
|
||||
|
||||
// Video Download reuses `download_file`, which runs the same
|
||||
// `validate_download_url` gate as image download. `validate_download_url`
|
||||
// is extension-agnostic (it only checks scheme, origin, and the `/media/`
|
||||
// path prefix), so a relay-hosted mp4/webm passes exactly like an image,
|
||||
// and an off-relay or private-host video is rejected exactly like an
|
||||
// off-relay image. These cases pin that parity so a future change can't
|
||||
// silently narrow the video download path's SSRF protection.
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_video_mp4() {
|
||||
assert!(validate_download_url(
|
||||
"https://relay.example.com/media/abcdef1234567890.mp4",
|
||||
RELAY_BASE,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_video_webm() {
|
||||
assert!(
|
||||
validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_relay_video_rejected() {
|
||||
let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_private_host_video_rejected() {
|
||||
// Off-relay private host serving a video must be rejected before any
|
||||
// fetch — same SSRF gate as image download.
|
||||
let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
/// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx,
|
||||
/// and the command-facing error must identify the refused redirect.
|
||||
///
|
||||
/// `validate_download_url` only vets the *initial* URL, so a relay that
|
||||
/// returned a redirect to an off-origin or private host would, under a
|
||||
/// redirect-following client, forward the minted media Authorization
|
||||
/// header across origins. The client `build_media_fetch_client()` produces
|
||||
/// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built
|
||||
/// with `redirect::Policy::none()`, so the 302 comes back verbatim and
|
||||
/// `redirect_refusal_error` — the same mapping the command applies — turns
|
||||
/// it into an actionable redirect error, not a silent cross-origin fetch.
|
||||
///
|
||||
/// A loopback `std::net::TcpListener` (no extra tokio feature) serves one
|
||||
/// raw `302` pointing at an off-origin target and records how many
|
||||
/// connections it accepts.
|
||||
#[tokio::test]
|
||||
async fn media_fetch_client_does_not_follow_redirects() {
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let connections = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let server_connections = Arc::clone(&connections);
|
||||
let server = std::thread::spawn(move || {
|
||||
// Accept exactly one connection; if the client followed the
|
||||
// redirect it would open a second one to the (unrelated) target,
|
||||
// but that target is never this server, so a second accept here
|
||||
// would only happen on an unexpected retry. We serve one 302 and
|
||||
// return, so the count stays at 1 for a compliant no-redirect client.
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
server_connections.fetch_add(1, Ordering::SeqCst);
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf);
|
||||
let response = "HTTP/1.1 302 Found\r\n\
|
||||
Location: http://169.254.169.254/latest/meta-data/\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
// Drive the exact client the command path uses, not an ad-hoc one.
|
||||
let client = crate::app_state::build_media_fetch_client()
|
||||
.expect("media fetch client must build with no-redirect policy");
|
||||
let resp = client
|
||||
.get(format!("http://{addr}/media/clip.mp4"))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should complete without following the redirect");
|
||||
|
||||
// The 302 is returned verbatim — not the 169.254.x target's response.
|
||||
assert_eq!(resp.status().as_u16(), 302);
|
||||
assert!(!resp.status().is_success());
|
||||
|
||||
// The command maps that status through `redirect_refusal_error`; the
|
||||
// user-facing error must name the redirect, not read as a generic
|
||||
// relay failure.
|
||||
let err = redirect_refusal_error(resp.status())
|
||||
.expect("a 3xx must map to a redirect-refusal error");
|
||||
assert!(
|
||||
err.contains("redirect") && err.contains("302"),
|
||||
"error must identify the refused 302 redirect, got: {err}",
|
||||
);
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(
|
||||
connections.load(Ordering::SeqCst),
|
||||
1,
|
||||
"exactly one request must be issued — the redirect must not be followed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_media_fetch_client_succeeds_with_no_redirect_policy() {
|
||||
// The fail-closed invariant: construction must not silently degrade to
|
||||
// a redirect-following client. If this ever starts failing, startup
|
||||
// panics loudly (see `build_app_state`) rather than substituting an
|
||||
// insecure client.
|
||||
assert!(
|
||||
crate::app_state::build_media_fetch_client().is_ok(),
|
||||
"media fetch client must build; a redirect-following fallback is forbidden",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_refusal_error_only_fires_for_3xx() {
|
||||
// 3xx → redirect-identifying error; success/non-3xx → None (fall
|
||||
// through to the normal success or relay-error handling).
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none());
|
||||
}
|
||||
}
|
||||
#[path = "media_download_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
516
desktop/src-tauri/src/commands/media_download_tests.rs
Normal file
516
desktop/src-tauri/src/commands/media_download_tests.rs
Normal file
@@ -0,0 +1,516 @@
|
||||
// Tests for commands/media_download.rs — split into a sibling file to keep
|
||||
// media_download.rs under the per-file line cap.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_json_returns_json_kind_and_correct_cap() {
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap();
|
||||
assert_eq!(kind, SnapshotFileKind::AgentJson);
|
||||
assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_png_returns_png_kind_and_correct_cap() {
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
assert_eq!(kind, SnapshotFileKind::AgentPng);
|
||||
assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_plain_json_rejected() {
|
||||
assert!(snapshot_kind_for_filename("data.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_deceptive_name_rejected() {
|
||||
// foo.agent.json.exe must not match .agent.json
|
||||
assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_plain_png_rejected() {
|
||||
assert!(snapshot_kind_for_filename("photo.png").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_agent_json_only_rejected() {
|
||||
// "agent.json" without the leading dot — plain filename, not the suffix
|
||||
assert!(snapshot_kind_for_filename("agentjson").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() {
|
||||
let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap();
|
||||
let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap();
|
||||
assert_eq!(json, SnapshotFileKind::TeamJson);
|
||||
assert_eq!(png, SnapshotFileKind::TeamPng);
|
||||
assert_eq!(json.cap(), 25 * 1024 * 1024);
|
||||
assert_eq!(png.cap(), 50 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_team_png_filename_with_json_bytes_rejected() {
|
||||
let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#;
|
||||
let kind = snapshot_kind_for_filename("review.team.png").unwrap();
|
||||
let error = ensure_bytes_match_kind(bytes, kind).unwrap_err();
|
||||
assert!(error.contains(".team.png") && error.contains("not a PNG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_team_declared_size_over_cap_rejected() {
|
||||
let kind = snapshot_kind_for_filename("review.team.json").unwrap();
|
||||
assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok());
|
||||
let error =
|
||||
ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err();
|
||||
assert!(error.contains("25 MiB"));
|
||||
}
|
||||
|
||||
// ── Focused boundary tests: format mismatch and consistency ──────────────
|
||||
//
|
||||
// These tests exercise the guard logic that fetch_snapshot_bytes applies
|
||||
// after the bounded fetch + hash check. The validation has two layers:
|
||||
//
|
||||
// 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename)
|
||||
// must match the actual byte format (PNG magic or absence of it).
|
||||
// 2. decode_snapshot_from_bytes: rejects malformed manifests including
|
||||
// JSON with level:none + non-empty entries.
|
||||
//
|
||||
// We verify each rejection path directly — no live HTTP required.
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_png_filename_with_json_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::None,
|
||||
entries: vec![],
|
||||
},
|
||||
};
|
||||
let json_bytes = encode_snapshot_json(&snapshot).unwrap();
|
||||
// .agent.png filename → Png kind; JSON bytes must be rejected.
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
let result = ensure_bytes_match_kind(&json_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.png filename with JSON bytes must be rejected by the magic-byte guard"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("not a PNG"),
|
||||
"error must describe the mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
// This is the trust-hole case: memory-bearing JSON delivered under a
|
||||
// .agent.png label to bypass the PNG no-memory policy.
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything,
|
||||
entries: vec![AgentSnapshotMemoryEntry {
|
||||
slug: "core".to_string(),
|
||||
body: "Secret memory.".to_string(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
let json_bytes = encode_snapshot_json(&snapshot).unwrap();
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
|
||||
let result = ensure_bytes_match_kind(&json_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.png filename with memory-bearing JSON bytes must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_boundary_json_filename_with_png_bytes_rejected() {
|
||||
use crate::managed_agents::agent_snapshot::{
|
||||
encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory,
|
||||
AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION,
|
||||
};
|
||||
let snapshot = AgentSnapshot {
|
||||
format: FORMAT_DISCRIMINATOR.to_string(),
|
||||
version: FORMAT_VERSION,
|
||||
definition: AgentSnapshotDefinition {
|
||||
name: "test".to_string(),
|
||||
source_is_builtin: false,
|
||||
system_prompt: None,
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
parallelism: None,
|
||||
respond_to: None,
|
||||
respond_to_allowlist: vec![],
|
||||
name_pool: vec![],
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
},
|
||||
profile: AgentSnapshotProfile {
|
||||
display_name: "Test".to_string(),
|
||||
about: None,
|
||||
avatar_data_url: None,
|
||||
avatar_url: None,
|
||||
},
|
||||
memory: AgentSnapshotMemory {
|
||||
level: crate::managed_agents::agent_snapshot::MemoryLevel::None,
|
||||
entries: vec![],
|
||||
},
|
||||
};
|
||||
let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
|
||||
// .agent.json filename → Json kind; PNG bytes must be rejected.
|
||||
let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap();
|
||||
let result = ensure_bytes_match_kind(&png_bytes, kind);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
".agent.json filename with PNG bytes must be rejected by the magic-byte guard"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("bytes are a PNG"),
|
||||
"error must describe the mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_boundary_json_none_level_with_entries_rejected() {
|
||||
use crate::commands::personas::decode_snapshot_from_bytes;
|
||||
// Construct JSON bytes directly: level=none but entries non-empty.
|
||||
// encode_snapshot_json does not guard against this, so we can produce it.
|
||||
let raw = serde_json::json!({
|
||||
"format": "buzz-agent-snapshot",
|
||||
"version": 1,
|
||||
"definition": { "name": "test" },
|
||||
"profile": { "displayName": "Test" },
|
||||
"memory": {
|
||||
"level": "none",
|
||||
"entries": [{"slug": "core", "body": "leaked"}]
|
||||
}
|
||||
});
|
||||
let bytes = serde_json::to_vec(&raw).unwrap();
|
||||
let result = decode_snapshot_from_bytes(&bytes);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("'none' but entries are present"),
|
||||
"error must describe the consistency violation"
|
||||
);
|
||||
}
|
||||
|
||||
const RELAY_BASE: &str = "https://relay.example.com";
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_url() {
|
||||
assert!(validate_download_url(
|
||||
"https://relay.example.com/media/abcdef1234567890.jpg",
|
||||
RELAY_BASE,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_url_png() {
|
||||
assert!(
|
||||
validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_relay_origin_rejected() {
|
||||
let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_private_ip_rejected() {
|
||||
let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_loopback_rejected() {
|
||||
let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_localhost_allowed_for_localhost_relay() {
|
||||
assert!(validate_download_url(
|
||||
"http://localhost:3000/media/abc.jpg",
|
||||
"http://localhost:3000",
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_missing_media_path_rejected() {
|
||||
let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("/media/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_https_scheme_rejected() {
|
||||
let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_http_non_localhost_rejected() {
|
||||
let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_root_path_rejected() {
|
||||
let result = validate_download_url("https://relay.example.com/", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("/media/"));
|
||||
}
|
||||
|
||||
// Video Download reuses `download_file`, which runs the same
|
||||
// `validate_download_url` gate as image download. `validate_download_url`
|
||||
// is extension-agnostic (it only checks scheme, origin, and the `/media/`
|
||||
// path prefix), so a relay-hosted mp4/webm passes exactly like an image,
|
||||
// and an off-relay or private-host video is rejected exactly like an
|
||||
// off-relay image. These cases pin that parity so a future change can't
|
||||
// silently narrow the video download path's SSRF protection.
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_video_mp4() {
|
||||
assert!(validate_download_url(
|
||||
"https://relay.example.com/media/abcdef1234567890.mp4",
|
||||
RELAY_BASE,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_valid_relay_video_webm() {
|
||||
assert!(
|
||||
validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_non_relay_video_rejected() {
|
||||
let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_download_url_private_host_video_rejected() {
|
||||
// Off-relay private host serving a video must be rejected before any
|
||||
// fetch — same SSRF gate as image download.
|
||||
let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("relay origin"));
|
||||
}
|
||||
|
||||
/// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx,
|
||||
/// and the command-facing error must identify the refused redirect.
|
||||
///
|
||||
/// `validate_download_url` only vets the *initial* URL, so a relay that
|
||||
/// returned a redirect to an off-origin or private host would, under a
|
||||
/// redirect-following client, forward the minted media Authorization
|
||||
/// header across origins. The client `build_media_fetch_client()` produces
|
||||
/// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built
|
||||
/// with `redirect::Policy::none()`, so the 302 comes back verbatim and
|
||||
/// `redirect_refusal_error` — the same mapping the command applies — turns
|
||||
/// it into an actionable redirect error, not a silent cross-origin fetch.
|
||||
///
|
||||
/// A loopback `std::net::TcpListener` (no extra tokio feature) serves one
|
||||
/// raw `302` pointing at an off-origin target and records how many
|
||||
/// connections it accepts.
|
||||
#[tokio::test]
|
||||
async fn media_fetch_client_does_not_follow_redirects() {
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let connections = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let server_connections = Arc::clone(&connections);
|
||||
let server = std::thread::spawn(move || {
|
||||
// Accept exactly one connection; if the client followed the
|
||||
// redirect it would open a second one to the (unrelated) target,
|
||||
// but that target is never this server, so a second accept here
|
||||
// would only happen on an unexpected retry. We serve one 302 and
|
||||
// return, so the count stays at 1 for a compliant no-redirect client.
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
server_connections.fetch_add(1, Ordering::SeqCst);
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf);
|
||||
let response = "HTTP/1.1 302 Found\r\n\
|
||||
Location: http://169.254.169.254/latest/meta-data/\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
// Drive the exact client the command path uses, not an ad-hoc one.
|
||||
let client = crate::app_state::build_media_fetch_client()
|
||||
.expect("media fetch client must build with no-redirect policy");
|
||||
let resp = client
|
||||
.get(format!("http://{addr}/media/clip.mp4"))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should complete without following the redirect");
|
||||
|
||||
// The 302 is returned verbatim — not the 169.254.x target's response.
|
||||
assert_eq!(resp.status().as_u16(), 302);
|
||||
assert!(!resp.status().is_success());
|
||||
|
||||
// The command maps that status through `redirect_refusal_error`; the
|
||||
// user-facing error must name the redirect, not read as a generic
|
||||
// relay failure.
|
||||
let err =
|
||||
redirect_refusal_error(resp.status()).expect("a 3xx must map to a redirect-refusal error");
|
||||
assert!(
|
||||
err.contains("redirect") && err.contains("302"),
|
||||
"error must identify the refused 302 redirect, got: {err}",
|
||||
);
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(
|
||||
connections.load(Ordering::SeqCst),
|
||||
1,
|
||||
"exactly one request must be issued — the redirect must not be followed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_media_fetch_client_succeeds_with_no_redirect_policy() {
|
||||
// The fail-closed invariant: construction must not silently degrade to
|
||||
// a redirect-following client. If this ever starts failing, startup
|
||||
// panics loudly (see `build_app_state`) rather than substituting an
|
||||
// insecure client.
|
||||
assert!(
|
||||
crate::app_state::build_media_fetch_client().is_ok(),
|
||||
"media fetch client must build; a redirect-following fallback is forbidden",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_refusal_error_only_fires_for_3xx() {
|
||||
// 3xx → redirect-identifying error; success/non-3xx → None (fall
|
||||
// through to the normal success or relay-error handling).
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none());
|
||||
assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_doc_cap_is_two_mib() {
|
||||
assert_eq!(MAX_MARKDOWN_DOC_BYTES, 2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_length_over_markdown_cap_refused_before_body() {
|
||||
let err =
|
||||
declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES + 1), MAX_MARKDOWN_DOC_BYTES)
|
||||
.expect("length just over the cap must be refused");
|
||||
assert!(err.contains("too large"), "{err}");
|
||||
// A 50 MiB advertisement passes the generic download cap but must be
|
||||
// refused by the markdown viewer's cap before any body bytes are read.
|
||||
assert!(
|
||||
declared_length_refusal_error(Some(MAX_DOWNLOAD_BYTES), MAX_MARKDOWN_DOC_BYTES).is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_length_at_cap_or_absent_is_admitted() {
|
||||
assert!(
|
||||
declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES), MAX_MARKDOWN_DOC_BYTES)
|
||||
.is_none()
|
||||
);
|
||||
// Absent Content-Length is admitted here by design — the streaming
|
||||
// accumulator below still owns the cap, so a stripped or dishonest
|
||||
// header cannot buy a larger download.
|
||||
assert!(declared_length_refusal_error(None, MAX_MARKDOWN_DOC_BYTES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_bytes_hit_markdown_cap_without_content_length() {
|
||||
// Simulate a chunked transfer with no (or dishonest) Content-Length: the
|
||||
// running total must abort the moment a chunk would cross the cap, and
|
||||
// nothing past the cap may be buffered.
|
||||
let cap = MAX_MARKDOWN_DOC_BYTES;
|
||||
let mut bytes = Vec::new();
|
||||
let chunk = vec![b'a'; 1024 * 1024]; // 1 MiB chunks
|
||||
append_chunk_within_cap(&mut bytes, &chunk, cap).expect("first MiB fits");
|
||||
append_chunk_within_cap(&mut bytes, &chunk, cap).expect("exactly at the cap is allowed");
|
||||
assert_eq!(bytes.len() as u64, cap);
|
||||
let err = append_chunk_within_cap(&mut bytes, b"x", cap)
|
||||
.expect_err("one byte past the cap must abort");
|
||||
assert!(err.contains("too large"), "{err}");
|
||||
assert_eq!(
|
||||
bytes.len() as u64,
|
||||
cap,
|
||||
"no bytes past the cap were buffered"
|
||||
);
|
||||
}
|
||||
@@ -671,6 +671,7 @@ pub fn run() {
|
||||
save_png_data_url,
|
||||
download_file,
|
||||
fetch_media_bytes,
|
||||
fetch_markdown_doc_bytes,
|
||||
copy_image_to_clipboard,
|
||||
copy_text_to_clipboard,
|
||||
read_clipboard_text,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Download, FileText, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { fetchMediaBytes } from "@/shared/api/tauriMedia";
|
||||
import {
|
||||
fetchMarkdownDocBytes,
|
||||
isMediaTooLargeError,
|
||||
} from "@/shared/api/tauriMedia";
|
||||
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import {
|
||||
@@ -52,8 +55,9 @@ function decodeErrorMessage(kind: "too-large" | "binary"): string {
|
||||
* Right auxiliary panel rendering a shared markdown attachment in-app.
|
||||
*
|
||||
* Relay media URLs require relay auth (plain browser requests 401), so the
|
||||
* content is fetched through the authenticated `fetch_media_bytes` Tauri
|
||||
* command and rendered with the same markdown pipeline chat messages use.
|
||||
* content is fetched through the authenticated `fetch_markdown_doc_bytes`
|
||||
* Tauri command — which enforces the viewer's 2 MiB cap natively during the
|
||||
* fetch — and rendered with the same markdown pipeline chat messages use.
|
||||
* The Preview/Code toggle switches between rendered markdown and the
|
||||
* syntax-highlighted source.
|
||||
*/
|
||||
@@ -74,7 +78,17 @@ export function MarkdownDocPanel({
|
||||
// document never changes under its URL — cache it for the session.
|
||||
const docQuery = useQuery<MarkdownDocDecodeResult>({
|
||||
queryKey: ["markdown-doc", url],
|
||||
queryFn: async () => decodeMarkdownDocBytes(await fetchMediaBytes(url)),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return decodeMarkdownDocBytes(await fetchMarkdownDocBytes(url));
|
||||
} catch (err) {
|
||||
// The native 2 MiB cap refuses oversized documents during the fetch
|
||||
// (the in-frontend decode check never sees their bytes). Surface it
|
||||
// as the too-large fallback rather than a generic fetch failure.
|
||||
if (isMediaTooLargeError(err)) return { kind: "too-large" };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
@@ -72,6 +72,34 @@ export async function fetchMediaBytes(
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a markdown document attachment for the in-app viewer.
|
||||
*
|
||||
* Unlike the generic `fetchMediaBytes` (50 MiB cap), the Rust command
|
||||
* enforces the viewer's 2 MiB ceiling natively — refusing an oversized
|
||||
* Content-Length before the body is read and aborting mid-stream when the
|
||||
* header is missing or dishonest — so a forged imeta `size` can never buy
|
||||
* a 50 MiB download and IPC copy.
|
||||
*/
|
||||
export async function fetchMarkdownDocBytes(
|
||||
url: string,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const bytes = await invokeTauri<ArrayBuffer>("fetch_markdown_doc_bytes", {
|
||||
url,
|
||||
});
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a markdown-doc fetch failure is the native size-cap refusal, as
|
||||
* opposed to a network or relay error. Matches the stable "file too large"
|
||||
* prefix produced by the Rust cap checks.
|
||||
*/
|
||||
export function isMediaTooLargeError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return message.includes("file too large");
|
||||
}
|
||||
|
||||
/** Read plain text without depending on embedded-webview clipboard grants. */
|
||||
export async function readTextFromSystemClipboard(): Promise<string> {
|
||||
// E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's
|
||||
|
||||
@@ -40,7 +40,8 @@ function formatFileSize(bytes: number): string {
|
||||
* Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the
|
||||
* in-app markdown viewer panel instead when a hosting surface provides one —
|
||||
* relay-hosted only, because the viewer fetches through the authenticated
|
||||
* `fetch_media_bytes` command, which accepts relay `/media/` origins alone.
|
||||
* `fetch_markdown_doc_bytes` command (native 2 MiB cap), which accepts
|
||||
* relay `/media/` origins alone.
|
||||
*/
|
||||
export function FileCard({
|
||||
href,
|
||||
|
||||
@@ -17,8 +17,13 @@ const MARKDOWN_DOC_EXTENSIONS = [".md", ".markdown", ".mdx"] as const;
|
||||
|
||||
/**
|
||||
* Maximum attachment size the viewer will render. Larger files fall back
|
||||
* to the download card path. Matches the ceiling proven workable by other
|
||||
* in-app text viewers; well under the 50 MiB `fetch_media_bytes` cap.
|
||||
* to the download card path.
|
||||
*
|
||||
* This constant powers the untrusted-imeta pre-gate (UX only) and the
|
||||
* defense-in-depth decode check. The *enforcement* boundary is the native
|
||||
* `fetch_markdown_doc_bytes` command's matching `MAX_MARKDOWN_DOC_BYTES`
|
||||
* cap in `media_download.rs`, which refuses oversized documents during the
|
||||
* streamed fetch — keep the two in sync.
|
||||
*/
|
||||
export const MAX_MARKDOWN_DOC_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -13543,6 +13543,18 @@ export function maybeInstallE2eTauriMocks() {
|
||||
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
case "fetch_markdown_doc_bytes": {
|
||||
// Mirrors the real command's native 2 MiB viewer cap (enforced in
|
||||
// Rust during the streamed fetch) so specs can prove the oversized
|
||||
// fallback: the refusal message must match the Rust cap error shape.
|
||||
const response = await fetch((payload as { url: string }).url);
|
||||
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > 2 * 1024 * 1024) {
|
||||
throw new Error("file too large (max 2 MiB)");
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
case "fetch_snapshot_bytes": {
|
||||
// The real command fetches + validates a snapshot attachment in memory
|
||||
// (size cap, SHA-256, decode). In E2E the bridge returns a minimal
|
||||
|
||||
@@ -126,6 +126,63 @@ test("markdown attachment opens the in-app viewer with Preview/Code toggle", asy
|
||||
await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("open document survives reload and back/forward navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await sendMarkdownAttachment(page);
|
||||
await page.getByTestId("file-card").last().click();
|
||||
|
||||
const panel = () => page.getByTestId("markdown-doc-panel");
|
||||
await expect(
|
||||
panel().getByRole("heading", { name: "Release Notes" }),
|
||||
).toBeVisible();
|
||||
|
||||
// The document lives in the URL (`doc`/`docName` params), so a reload
|
||||
// must restore the open panel with its content.
|
||||
await page.reload();
|
||||
await expect(
|
||||
panel().getByRole("heading", { name: "Release Notes" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Opening the panel pushed a history entry: back closes it, forward
|
||||
// restores it — the advertised back/forward contract.
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0);
|
||||
await page.goForward();
|
||||
await expect(
|
||||
panel().getByRole("heading", { name: "Release Notes" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("a document over the native 2 MiB cap falls back to download", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The imeta `size` is untrusted and here it lies small — the card offers
|
||||
// the viewer. The served body is over the cap, so the (mocked) native
|
||||
// fetch refuses it mid-transfer and the panel must show the too-large
|
||||
// fallback instead of rendering, proving enforcement does not rest on
|
||||
// the advertised size.
|
||||
await page.unroute(`**/media/${DOC_SHA}.bin`);
|
||||
await page.route(`**/media/${DOC_SHA}.bin`, (route) =>
|
||||
route.fulfill({
|
||||
body: Buffer.alloc(2 * 1024 * 1024 + 1, 0x61),
|
||||
contentType: "application/octet-stream",
|
||||
}),
|
||||
);
|
||||
await sendMarkdownAttachment(page);
|
||||
|
||||
const card = page.getByTestId("file-card").last();
|
||||
await expect(card).toHaveAttribute("aria-label", "Open release-notes.md");
|
||||
await card.click();
|
||||
|
||||
const panel = page.getByTestId("markdown-doc-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText("This file is too large to preview.");
|
||||
await expect(
|
||||
panel.getByRole("button", { name: "Download file" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("non-markdown attachments keep the download-card behavior", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user