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
+193
View File
@@ -226,9 +226,17 @@ impl RpcHandler {
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
// Validate pubkey: must be 66-char hex (compressed secp256k1)
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
}
if amount < 20000 {
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
}
if amount > 16_777_215 {
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
}
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
@@ -236,6 +244,10 @@ impl RpcHandler {
// First connect to the peer if an address is provided
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
// Validate peer address format (host:port)
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
return Err(anyhow::anyhow!("Invalid peer address format"));
}
let connect_body = serde_json::json!({
"addr": { "pubkey": pubkey, "host": addr },
"perm": true
@@ -282,6 +294,13 @@ impl RpcHandler {
if parts.len() != 2 {
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
}
// Validate txid is 64-char hex and output_index is numeric
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
}
if parts[1].parse::<u32>().is_err() {
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
}
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
@@ -346,6 +365,14 @@ impl RpcHandler {
if amount < 546 {
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
}
if amount > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
}
// Validate Bitcoin address format (basic: length and allowed chars)
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
@@ -390,6 +417,14 @@ impl RpcHandler {
if amount_sats < 0 {
return Err(anyhow::anyhow!("Amount must be non-negative"));
}
if amount_sats > 21_000_000 * 100_000_000 {
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
}
// Limit memo length to prevent abuse
if memo.len() > 639 {
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
}
info!(amount_sats = amount_sats, "Creating Lightning invoice");
@@ -435,6 +470,15 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
if payment_request.len() < 10 || payment_request.len() > 2048 {
return Err(anyhow::anyhow!("Invalid payment request length"));
}
let lower = payment_request.to_lowercase();
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
}
info!("Paying Lightning invoice");
let (client, macaroon_hex) = self.lnd_client().await?;
@@ -481,6 +525,155 @@ impl RpcHandler {
"amount_sats": amount_sat,
}))
}
/// Create an unsigned PSBT for hardware wallet signing.
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
pub(super) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let outputs = params.get("outputs")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
if outputs.is_empty() {
return Err(anyhow::anyhow!("outputs must not be empty"));
}
// Build the outputs map for LND: { "address": "amount_sats_as_string" }
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
let mut total_amount: i64 = 0;
for output in outputs {
let addr = output.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
// Validate Bitcoin address format
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
}
let amount = output.get("amount_sats")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
if amount < 546 {
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
}
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
total_amount += amount;
}
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
.and_then(|v| v.as_u64())
.unwrap_or(10);
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
let (client, macaroon_hex) = self.lnd_client().await?;
let fund_body = serde_json::json!({
"raw": {
"outputs": lnd_outputs,
},
"sat_per_vbyte": sat_per_vbyte,
"spend_unconfirmed": false,
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
.await
.context("Failed to create PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse PSBT response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
}
let funded_psbt = body.get("funded_psbt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let change_output_index = body.get("change_output_index")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
Ok(serde_json::json!({
"psbt_base64": funded_psbt,
"change_output_index": change_output_index,
"total_amount_sats": total_amount,
"fee_rate_sat_per_vbyte": sat_per_vbyte,
}))
}
/// Finalize a signed PSBT and broadcast the transaction.
/// Takes a PSBT that has been signed by a hardware wallet.
pub(super) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let signed_psbt = params.get("signed_psbt_base64")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
info!("Finalizing signed PSBT from hardware wallet");
let (client, macaroon_hex) = self.lnd_client().await?;
let finalize_body = serde_json::json!({
"funded_psbt": signed_psbt,
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
.await
.context("Failed to finalize PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse finalize response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
}
let raw_final_tx = body.get("raw_final_tx")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Broadcast the finalized transaction
let publish_body = serde_json::json!({
"tx_hex": raw_final_tx,
});
let pub_resp = client
.post("https://127.0.0.1:8080/v2/wallet/tx")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&publish_body)
.send()
.await
.context("Failed to broadcast transaction")?;
let pub_status = pub_resp.status();
let pub_body: serde_json::Value = pub_resp.json().await
.context("Failed to parse broadcast response")?;
if !pub_status.is_success() {
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
}
Ok(serde_json::json!({
"raw_final_tx": raw_final_tx,
"broadcast": true,
}))
}
}
// Channel types