fix: harden input validation across all RPC endpoints (PENTEST-02)

Manual security audit of 130+ RPC endpoints. Critical fixes:
- LND: validate pubkey (66-char hex), Bitcoin addresses, channel points,
  amount bounds, payment request format, memo length, peer address
- Package: validate_app_id on start/stop/restart/bundled-app handlers,
  validate volume host paths (must be under /var/lib/archipelago/),
  validate Docker image in bundled-app-start
- Container: validate_app_id on all 6 handlers, canonicalize manifest paths
- Network: path traversal prevention in connection request deletion
- Backup: backup ID validation in delete handler
- Webhooks: URL scheme validation, SSRF prevention for private IPs
- Security: validate app_id in secret rotation
- Interfaces: WiFi password length/null validation, strict IP/gateway/DNS
  parsing for static ethernet config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 14:32:49 +00:00
co-authored by Claude Opus 4.6
parent 4b5eb4ed29
commit aa6c7c2e6a
9 changed files with 774 additions and 33 deletions
+22 -11
View File
@@ -1,4 +1,5 @@
use super::RpcHandler;
use super::package::validate_app_id;
use anyhow::{Context, Result};
impl RpcHandler {
@@ -17,24 +18,29 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
// Validate manifest path: reject path traversal and paths outside apps/
if manifest_path.contains("..") {
// Validate manifest path: reject traversal, resolve to canonical path
if manifest_path.contains("..") || manifest_path.contains('\0') {
return Err(anyhow::anyhow!(
"Invalid manifest_path: path traversal not allowed"
));
}
let path = std::path::Path::new(manifest_path);
if path.is_absolute() {
let apps_dir = self.config.data_dir.join("apps");
if !path.starts_with(&apps_dir) {
return Err(anyhow::anyhow!(
"Invalid manifest_path: must be under the apps directory"
));
}
let apps_dir = self.config.data_dir.join("apps");
let resolved = if std::path::Path::new(manifest_path).is_absolute() {
std::path::PathBuf::from(manifest_path)
} else {
apps_dir.join(manifest_path)
};
let canonical = resolved
.canonicalize()
.context("Invalid manifest_path: file not found")?;
if !canonical.starts_with(&apps_dir) {
return Err(anyhow::anyhow!(
"Invalid manifest_path: must be under the apps directory"
));
}
// Load manifest
let manifest_content = tokio::fs::read_to_string(manifest_path)
let manifest_content = tokio::fs::read_to_string(&canonical)
.await
.context("Failed to read manifest file")?;
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
@@ -62,6 +68,7 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.start_container(app_id)
@@ -85,6 +92,7 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.stop_container(app_id)
@@ -108,6 +116,7 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let preserve_data = params
.get("preserve_data")
.and_then(|v| v.as_bool())
@@ -206,6 +215,7 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let status = orchestrator
.get_container_status(app_id)
@@ -229,6 +239,7 @@ impl RpcHandler {
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let lines = params
.get("lines")
.and_then(|v| v.as_u64())