This filename is stored in the catalog and later used in `content_file_path()`:
```rust
// content_server.rs:96
let clean_name = item.filename.trim_start_matches('/');
let primary = data_dir.join(CONTENT_DIR).join(clean_name); // No .. check!
```
`trim_start_matches('/')` strips leading slashes but does NOT strip `..` sequences. A filename like `../../etc/shadow` resolves to `{data_dir}/content/files/../../etc/shadow` → `/var/lib/archipelago/content/../../etc/shadow` → `/var/lib/etc/shadow` (or deeper traversals reach `/etc/shadow`).
When a peer later requests `/content/{uuid}`, `serve_content()` looks up the item by UUID (safely validated) but then calls `content_file_path()` with the attacker-controlled filename, serving arbitrary files.
**Requires**: Authentication (content.add is not in UNAUTHENTICATED_METHODS). But once added, content is served to unauthenticated peers.
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
```
In `backup_to_usb()`:
```rust
// full.rs:334-337
let mount_path = Path::new(mount_point);
if !mount_path.exists() || !mount_path.is_dir() {
anyhow::bail!("Mount point not accessible");
}
let dest_dir = mount_path.join("archipelago-backups");
fs::create_dir_all(&dest_dir).await?;
```
No canonicalization, no boundary check. An attacker can write backup files to any writable directory on the filesystem. While the write goes into a subdirectory `archipelago-backups/`, it still creates directories and writes encrypted backup blobs to arbitrary locations.
**Evidence**: This method is in `UNAUTHENTICATED_METHODS` (no session required) and accepts arbitrary peer data with NO signature verification and NO `validate_did()` call:
```rust
// federation.rs:340-370
let did = params.get("did").and_then(|v| v.as_str())...;
let onion = params.get("onion").and_then(|v| v.as_str())...;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())...;
Compare with other federation methods that DO call `validate_did()`. This method doesn't, AND it sets `TrustLevel::Trusted` automatically. An attacker on the LAN can inject arbitrary trusted peers. The injected DID could contain path traversal characters since `validate_did()` is never called.
**Evidence**: Unauthenticated method that updates any known peer's onion address without proof of ownership:
```rust
// federation.rs:431-448
let did = params.get("did")...;
let new_onion = params.get("new_onion")...;
let found = nodes.iter_mut().find(|n| n.did == did);
node.onion = new_onion.to_string(); // No signature check!
```
Combined with INJ-003, an attacker can: (1) discover peer DIDs via `federation.get-state` (also unauthenticated), (2) change any peer's address to their own, redirecting federation traffic.
This becomes `--health-cmd=...` passed to `podman run`. If `rpc_pass` contains shell metacharacters (`$()`, backticks, `;`, `|`), arbitrary commands execute inside the Bitcoin container during health checks.
The password comes from `/var/lib/archipelago/secrets/bitcoin-rpc-password` or `BITCOIN_RPC_PASSWORD` env var — not directly from RPC input. Exploitation requires either: (a) writing to the secrets file, or (b) controlling the environment variable. The command runs inside the container, not on the host.
**Suggested exploit**: If you can write to the secrets file:
**Evidence**: The health check endpoint string is passed directly to `sh -c` inside a container:
```rust
let output = Command::new("podman")
.arg("exec").arg(&self.container_name)
.arg("sh").arg("-c").arg(endpoint) // Unvalidated
.output().await;
```
The `endpoint` comes from `HealthCheck` struct, which is populated from app manifests. If an attacker can modify a manifest file or if the manifest system accepts user-uploaded manifests, this becomes exploitable. Currently, manifests come from validated local files with `canonicalize()` + boundary checks on the path, so exploitation is unlikely.
**Evidence**: Script file content is read and embedded verbatim into a shell wrapper:
```rust
let script_content = fs::read_to_string(script_path).await?;
let wrapper_script = format!("#!/bin/sh\nset -e\n{}\n", script_content);
```
Then written to `/tmp/parmanode-{name}.sh` and executed in an Alpine container. The temp file path uses `script_name` (derived from `file_stem()`) which could contain shell metacharacters in the filename. However, the script_path is derived from `module_path.join("install.sh")`, which is locally controlled.
Additionally, `/tmp` is world-writable — a TOCTOU race condition could replace the temp file between write and execution.
---
## Non-Findings (Verified Secure)
| Area | Status | Details |
|------|--------|---------|
| **SQL Injection** | N/A | No SQL database; all storage is JSON files via serde |
| **SSTI** | N/A | No template engines (no tera, handlebars, askama); backend returns pure JSON |
| **App ID injection** | Secure | `validate_app_id()` enforces `[a-z0-9-]` whitelist, max 64 chars |