diff --git a/capsules/object-provider/Cargo.lock b/capsules/object-provider/Cargo.lock index d5114091d..914d1dc39 100644 --- a/capsules/object-provider/Cargo.lock +++ b/capsules/object-provider/Cargo.lock @@ -167,9 +167,6 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] [[package]] name = "arc-swap" @@ -1190,7 +1187,7 @@ dependencies = [ "elastos-protected-content-provider-contracts", "elastos-protected-content-rights", "getrandom 0.2.17", - "nix", + "nix 0.29.0", "serde", "serde_json", "sha2 0.10.9", @@ -1274,17 +1271,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1589,7 +1575,7 @@ dependencies = [ "elastos-common", "elastos-compute", "libc", - "nix", + "nix 0.29.0", "serde", "serde_json", "thiserror 1.0.69", @@ -1689,7 +1675,7 @@ dependencies = [ "ghash", "hkdf", "hpke", - "nix", + "nix 0.29.0", "rand 0.10.2", "rand 0.9.5", "rand_chacha 0.3.1", @@ -1734,7 +1720,7 @@ dependencies = [ "elastos-protected-content-provider-contracts", "elastos-wallet-contract", "hex", - "nix", + "nix 0.29.0", "serde", "serde_json", "sha2 0.10.9", @@ -3855,6 +3841,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -6053,6 +6051,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -7446,18 +7450,16 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "9.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.20", + "nix 0.31.3", + "typed-path", ] [[package]] diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index 324256b60..eda3abe91 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -167,9 +167,6 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] [[package]] name = "arc-swap" @@ -1197,7 +1194,7 @@ dependencies = [ "elastos-protected-content-provider-contracts", "elastos-protected-content-rights", "getrandom 0.2.17", - "nix", + "nix 0.29.0", "serde", "serde_json", "sha2 0.10.9", @@ -1281,17 +1278,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1598,7 +1584,7 @@ dependencies = [ "elastos-common", "elastos-compute", "libc", - "nix", + "nix 0.29.0", "serde", "serde_json", "tempfile", @@ -1706,7 +1692,7 @@ dependencies = [ "hkdf", "hpke", "k256", - "nix", + "nix 0.29.0", "rand 0.10.2", "rand 0.9.5", "rand_chacha 0.3.1", @@ -1765,7 +1751,7 @@ dependencies = [ "elastos-wallet-contract", "hex", "k256", - "nix", + "nix 0.29.0", "serde", "serde_json", "sha2 0.10.9", @@ -3926,6 +3912,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -6165,6 +6163,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -7704,18 +7708,16 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "9.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.20", + "nix 0.31.3", + "typed-path", ] [[package]] diff --git a/elastos/crates/elastos-server/Cargo.toml b/elastos/crates/elastos-server/Cargo.toml index 56bf3b4ac..18fb4371a 100644 --- a/elastos/crates/elastos-server/Cargo.toml +++ b/elastos/crates/elastos-server/Cargo.toml @@ -104,7 +104,7 @@ tempfile = "3.10" # Archive extraction (for capsule artifacts) flate2 = "1.0" tar = "0.4" -zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } +zip = { version = "9.0.4", default-features = false, features = ["deflate-flate2"] } libc = "0.2" # Built-in Carrier transport (P2P for updates, gossip messaging, DHT discovery). diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/library.rs b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs index d8477c0db..0253c229f 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/library.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs @@ -892,7 +892,7 @@ fn zip_text_files(bytes: &[u8]) -> std::collections::BTreeMap { if entry.is_dir() { continue; } - let name = entry.name().to_string(); + let name = entry.name().unwrap().into_owned(); let mut body = String::new(); entry.read_to_string(&mut body).unwrap(); files.insert(name, body); @@ -3870,6 +3870,203 @@ async fn test_library_provider_rejects_unsafe_zip_entries() { .contains("relative and safe")); } +/// Every path under `root` (files, folders and symlinks, not followed). +fn walk_paths(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + stack.push(path.clone()); + } + out.push(path); + } + } + out +} + +#[tokio::test] +async fn test_library_provider_extract_zip_refuses_absolute_dotdot_and_symlink_entries() { + use std::io::Write as _; + + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + + let (status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ "parent_uri": root, "name": "Documents" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ "parent_uri": documents_uri, "name": "Imports" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + // Each archive holds one hostile entry. The whole-archive extract must + // refuse it and write nothing for it, inside or outside the Library. + // (archive name, entry kind, entry name, symlink target, expected message) + let cases: [(&str, &str, &str, &str, &str); 5] = [ + ( + "Absolute.zip", + "file", + "/absolute-escape.txt", + "", + "relative and safe", + ), + ( + "DotDot.zip", + "file", + "../dotdot-escape.txt", + "", + "relative and safe", + ), + ( + "Inner.zip", + "file", + "Nested/../inner-escape.txt", + "", + "relative and safe", + ), + ( + "Backslash.zip", + "file", + "..\\backslash-escape.txt", + "", + "invalid library object name", + ), + ( + "Symlink.zip", + "symlink", + "symlink-escape.txt", + "/etc/passwd", + "non-file", + ), + ]; + for (archive_name, kind, entry_name, link_target, expected) in cases { + let archive_bytes = { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + if kind == "symlink" { + writer + .add_symlink(entry_name, link_target, options) + .unwrap(); + } else { + writer.start_file(entry_name, options).unwrap(); + writer.write_all(b"escape").unwrap(); + } + writer.finish().unwrap().into_inner() + }; + let zip_uri = format!("{documents_uri}/{archive_name}"); + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK, "{archive_name}"); + assert_eq!(write["status"], "ok", "{archive_name}: {write}"); + + let (extract_status, extracted) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ "uri": zip_uri }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK, "{archive_name}"); + assert_eq!( + extracted["status"], "error", + "{archive_name}: hostile entry {entry_name:?} was not refused: {extracted}" + ); + assert!( + extracted["message"].as_str().unwrap().contains(expected), + "{archive_name}: {extracted}" + ); + + if kind == "symlink" { + // The listing marks it blocked and a selected extract refuses it. + let (list_status, listed) = post_library( + app.clone(), + &token, + "archive_entries", + json!({ "uri": zip_uri }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + let rows = listed["data"]["entries"].as_array().unwrap(); + assert!( + rows.iter().any(|entry| entry["path"] == entry_name + && entry["kind"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("non-file")), + "{listed}" + ); + let (select_status, selected) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": [entry_name], + }), + ) + .await; + assert_eq!(select_status, StatusCode::OK); + assert_eq!(selected["status"], "ok", "{selected}"); + assert_eq!( + selected["data"]["receipt"]["progress"]["blocked_entries"], + 1 + ); + assert!(selected["data"]["blocked"][0]["reason"] + .as_str() + .unwrap() + .contains("non-file")); + } + } + + // Nothing from any hostile entry exists anywhere under the data dir, and + // no symlink was created. + for path in walk_paths(dir.path()) { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + assert!( + !name.contains("escape"), + "hostile entry was written: {}", + path.display() + ); + assert!( + !std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink(), + "symlink was created: {}", + path.display() + ); + } +} + #[tokio::test] async fn test_library_gateway_reads_webspace_files_through_runtime_provider() { let dir = tempfile::tempdir().unwrap(); diff --git a/elastos/crates/elastos-server/src/library.rs b/elastos/crates/elastos-server/src/library.rs index 9e537d229..84c791d0a 100644 --- a/elastos/crates/elastos-server/src/library.rs +++ b/elastos/crates/elastos-server/src/library.rs @@ -4239,7 +4239,10 @@ fn preview_zip_archive_entry( let mut file = archive .by_index(index) .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; - let raw_name = file.name().to_string(); + let raw_name = file + .name() + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))? + .into_owned(); let Ok(normalized) = normalized_archive_entry_path(Path::new(&raw_name)) else { continue; }; @@ -4457,7 +4460,10 @@ fn list_zip_archive_entries(bytes: Vec) -> anyhow::Result<(Vec, bool) let file = archive .by_index(index) .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; - let raw_name = file.name().to_string(); + let raw_name = file + .name() + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))? + .into_owned(); let path = Path::new(&raw_name); let normalized = match normalized_archive_entry_path(path) { Ok(path) => path, @@ -4939,7 +4945,10 @@ fn collect_selected_zip_writes( let mut file = archive .by_index(index) .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; - let raw_name = file.name().to_string(); + let raw_name = file + .name() + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))? + .into_owned(); let normalized = match normalized_archive_entry_path(Path::new(&raw_name)) { Ok(path) => path, Err(err) => { @@ -5429,8 +5438,10 @@ fn extract_zip_archive( let mut file = archive .by_index(index) .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; - let entry_path = file - .enclosed_name() + let raw_name = file + .name() + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let entry_path = zip_entry_enclosed_path(&raw_name) .ok_or_else(|| anyhow!("library archive entry path must be relative and safe"))?; let entry_uri = archive_entry_uri(&destination.uri, &entry_path)?; if entry_uri == destination.uri { @@ -5461,6 +5472,29 @@ fn extract_zip_archive( Ok(()) } +/// The path a whole-archive ZIP extract writes for an entry name, or `None` +/// when the name holds a NUL byte, is absolute (a root or a drive prefix), or +/// climbs above the archive root. `archive_entry_uri` then refuses any `..` +/// that is left. This keeps the rule in the runtime: zip 9 `enclosed_name` +/// strips a leading `/` or drive prefix and folds `..` instead, which would +/// write entries that this extract refuses. +fn zip_entry_enclosed_path(name: &str) -> Option { + if name.contains('\0') { + return None; + } + let path = PathBuf::from(name); + let mut depth = 0usize; + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => return None, + Component::ParentDir => depth = depth.checked_sub(1)?, + Component::Normal(_) => depth += 1, + Component::CurDir => {} + } + } + Some(path) +} + fn archive_entry_uri(destination_uri: &str, path: &Path) -> anyhow::Result { let mut uri = destination_uri.trim_end_matches('/').to_string(); for component in path.components() { diff --git a/justfile b/justfile index 615e4f434..950ff5567 100644 --- a/justfile +++ b/justfile @@ -171,7 +171,6 @@ verify: ./scripts/vendor-ui-tokens.sh --check node scripts/carrier-dependency-generation-check.mjs just product-ui-source - node scripts/home-clipboard-source-gate.mjs node --test scripts/browser-window-close-handshake.test.mjs node --test scripts/home-two-runtime-acceptance.test.mjs node --test scripts/system-hosted-save.test.mjs diff --git a/scripts/home-clipboard-source-gate.mjs b/scripts/home-clipboard-source-gate.mjs index 1176dfa4e..c6210a29a 100644 --- a/scripts/home-clipboard-source-gate.mjs +++ b/scripts/home-clipboard-source-gate.mjs @@ -104,16 +104,22 @@ const canonicalClientImports = Object.freeze({ "capsules/system/browser/system.js": 'targetId: "system"', "capsules/chat-room/browser/chat-room.js": 'targetId: "chat-room"', }); +// A capsule can build its client options in a helper (Chat rebuilds its client +// when Home renews its token), so the gate binds the target, not the call shape: +// the file names exactly one targetId, and it is the fixed binding. for (const [path, targetBinding] of Object.entries(canonicalClientImports)) { const source = readFileSync(join(repoRoot, path), "utf8"); + const bindings = (source.match(/\btargetId\s*:[^,\n}]*/gu) || []).map((binding) => binding.trim()); assert( source.includes( 'from "/apps/home/home-clipboard-client.js?v=home-20260726a"', ) && - source.includes("createHomeClipboardClient({") && - source.includes(targetBinding) && + source.includes("createHomeClipboardClient(") && + bindings.length === 1 && + bindings[0] === targetBinding && source.includes("homeClipboard.start()"), `${path} must use the canonical Home Clipboard client and its fixed target binding`, + { bindings }, ); } diff --git a/scripts/home-clipboard-source-gate.test.mjs b/scripts/home-clipboard-source-gate.test.mjs new file mode 100644 index 000000000..1a0242ac9 --- /dev/null +++ b/scripts/home-clipboard-source-gate.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const chat = "capsules/chat-room/browser/chat-room.js"; +// Every file the gate reads. The gate scans only capsules/*/browser, so this +// tree holds the one trusted host and the capsules that must use the client. +const files = [ + "scripts/home-clipboard-source-gate.mjs", + "capsules/home/browser/home-clipboard-host.js", + "capsules/home/browser/home-clipboard-client.js", + "capsules/home/browser/home-clipboard-protocol.js", + "capsules/browser/browser/browser.js", + "capsules/wallet/browser/wallet.js", + "capsules/wallet-metamask/browser/wallet-metamask.js", + "capsules/wallet-unisat/browser/wallet-unisat.js", + "capsules/wallet-walletconnect/browser/wallet-walletconnect.js", + "capsules/library/browser/src/app.js", + "capsules/library/browser/src/actions.js", + "capsules/library/browser/src/dialog.js", + "capsules/documents/browser/documents.js", + "capsules/system/browser/system.js", + chat, + "capsules/chat-room-ui/src/lib.rs", +]; + +function gate(t, change) { + const root = mkdtempSync(join(tmpdir(), "clipboard-gate-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const file of files) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + copyFileSync(join(repoRoot, file), join(root, file)); + } + if (change) { + const source = readFileSync(join(root, chat), "utf8"); + const changed = change(source); + assert.notEqual(changed, source, "the fixture change applies"); + writeFileSync(join(root, chat), changed); + } + return spawnSync(process.execPath, [join(root, "scripts/home-clipboard-source-gate.mjs")], { encoding: "utf8" }); +} + +test("Chat's renewable Home Clipboard client passes the gate", (t) => { + const result = gate(t); + assert.equal(result.status, 0, result.stderr); + assert.ok(JSON.parse(result.stdout).migrated_capsules.includes(chat)); +}); + +for (const [name, change, message] of [ + ["a different target", (source) => source.replace('targetId: "chat-room"', 'targetId: "home"'), /fixed target binding/], + ["a second target", (source) => source.replace('targetId: "chat-room",', 'targetId: "chat-room",\n other: { targetId: "home" },'), /fixed target binding/], + ["no client start", (source) => source.replaceAll("homeClipboard.start()", "homeClipboard.ready()"), /fixed target binding/], + ["no canonical client", (source) => source.replaceAll("createHomeClipboardClient(", "createLocalClipboard("), /fixed target binding/], + ["direct navigator.clipboard", (source) => source + "\nnavigator.clipboard.writeText('x');\n", /Only the trusted top-level Home Clipboard host/], +]) { + test(`the gate refuses Chat with ${name}`, (t) => { + const result = gate(t, change); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, message); + }); +} diff --git a/scripts/test-source-gate.sh b/scripts/test-source-gate.sh index d1998f6e5..55af821d9 100755 --- a/scripts/test-source-gate.sh +++ b/scripts/test-source-gate.sh @@ -22,6 +22,8 @@ product_ui() { node scripts/home-shell-bridge-smoke.mjs node scripts/home-agent-shell-smoke.mjs node scripts/people-discovery-smoke.mjs + node scripts/home-clipboard-source-gate.mjs + node --test scripts/home-clipboard-source-gate.test.mjs } release() {