feat: add per-endpoint rate limiting for sensitive operations (PENTEST-04)

New EndpointRateLimiter in session.rs tracks requests per (method, IP)
with configurable limits and time windows:

Financial operations (5 req/5min):
- wallet.send, lnd.sendcoins, lnd.payinvoice, lnd.create-psbt,
  lnd.finalize-psbt, wallet.ecash-send

Channel operations (3 req/5min):
- lnd.openchannel, lnd.closechannel

Backup operations (2-3 req/10min):
- backup.create, backup.restore

Container/package installs (5 req/5min):
- container-install, package.install

System operations (2 req/5min):
- system.reboot, system.shutdown, update.apply

Identity/auth (3-10 req/5min):
- identity.create, identity.issue-credential, auth.changePassword

Returns HTTP 429 with Retry-After header when limits exceeded.
Verified on live server: auth.changePassword blocks at 4th request,
lnd.sendcoins blocks at 6th request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 14:46:25 +00:00
parent ec92e5e756
commit 4995dc2656
3 changed files with 327 additions and 11 deletions

View File

@@ -33,7 +33,7 @@ use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::session::{self, LoginRateLimiter, SessionStore};
use crate::session::{self, EndpointRateLimiter, LoginRateLimiter, SessionStore};
use crate::state::StateManager;
use anyhow::{Context, Result};
use hyper::{Request, Response, StatusCode};
@@ -83,6 +83,7 @@ pub struct RpcHandler {
port_allocator: Arc<Mutex<PortAllocator>>,
pub session_store: SessionStore,
login_rate_limiter: LoginRateLimiter,
endpoint_rate_limiter: EndpointRateLimiter,
}
impl RpcHandler {
@@ -111,6 +112,7 @@ impl RpcHandler {
port_allocator,
session_store,
login_rate_limiter: LoginRateLimiter::new(),
endpoint_rate_limiter: EndpointRateLimiter::new(),
})
}
@@ -214,6 +216,30 @@ impl RpcHandler {
}
}
// Rate limit sensitive endpoints (wallet, identity, backup, container, etc.)
{
let client_ip = extract_client_ip(&parts.headers);
if !self.endpoint_rate_limiter.check(&rpc_req.method, client_ip).await {
let rpc_resp = RpcResponse {
result: None,
error: Some(RpcError {
code: 429,
message: "Rate limit exceeded for this operation. Try again later.".to_string(),
data: None,
}),
};
let resp_body = serde_json::to_vec(&rpc_resp)
.context("Failed to serialize response")?;
return Ok(Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("Content-Type", "application/json")
.header("Retry-After", "60")
.body(hyper::Body::from(resp_body))
.unwrap());
}
self.endpoint_rate_limiter.record(&rpc_req.method, client_ip).await;
}
// Extract params; clone for post-routing use (login 2FA check needs password)
let params = rpc_req.params;
let login_params: Option<serde_json::Value> = if rpc_req.method == "auth.login" {