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:
co-authored by
Claude Opus 4.6
parent
4b5eb4ed29
commit
aa6c7c2e6a
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user